HTML templates for printable form letters, #17349
[freeside.git] / FS / FS / msg_template.pm
1 package FS::msg_template;
2
3 use strict;
4 use base qw( FS::Record );
5 use Text::Template;
6 use FS::Misc qw( generate_email send_email );
7 use FS::Conf;
8 use FS::Record qw( qsearch qsearchs );
9 use FS::UID qw( dbh );
10
11 use FS::cust_main;
12 use FS::cust_msg;
13 use FS::template_content;
14
15 use Date::Format qw( time2str );
16 use HTML::Entities qw( decode_entities encode_entities ) ;
17 use HTML::FormatText;
18 use HTML::TreeBuilder;
19
20 use File::Temp;
21 use IPC::Run qw(run);
22 use vars qw( $DEBUG $conf );
23
24 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
25
26 $DEBUG=0;
27
28 =head1 NAME
29
30 FS::msg_template - Object methods for msg_template records
31
32 =head1 SYNOPSIS
33
34   use FS::msg_template;
35
36   $record = new FS::msg_template \%hash;
37   $record = new FS::msg_template { 'column' => 'value' };
38
39   $error = $record->insert;
40
41   $error = $new_record->replace($old_record);
42
43   $error = $record->delete;
44
45   $error = $record->check;
46
47 =head1 DESCRIPTION
48
49 An FS::msg_template object represents a customer message template.
50 FS::msg_template inherits from FS::Record.  The following fields are currently
51 supported:
52
53 =over 4
54
55 =item msgnum - primary key
56
57 =item msgname - Name of the template.  This will appear in the user interface;
58 if it needs to be localized for some users, add it to the message catalog.
59
60 =item agentnum - Agent associated with this template.  Can be NULL for a 
61 global template.
62
63 =item mime_type - MIME type.  Defaults to text/html.
64
65 =item from_addr - Source email address.
66
67 =item disabled - disabled ('Y' or NULL).
68
69 =back
70
71 =head1 METHODS
72
73 =over 4
74
75 =item new HASHREF
76
77 Creates a new template.  To add the template to the database, see L<"insert">.
78
79 Note that this stores the hash reference, not a distinct copy of the hash it
80 points to.  You can ask the object for a copy with the I<hash> method.
81
82 =cut
83
84 # the new method can be inherited from FS::Record, if a table method is defined
85
86 sub table { 'msg_template'; }
87
88 =item insert [ CONTENT ]
89
90 Adds this record to the database.  If there is an error, returns the error,
91 otherwise returns false.
92
93 A default (no locale) L<FS::template_content> object will be created.  CONTENT 
94 is an optional hash containing 'subject' and 'body' for this object.
95
96 =cut
97
98 sub insert {
99   my $self = shift;
100   my %content = @_;
101
102   my $oldAutoCommit = $FS::UID::AutoCommit;
103   local $FS::UID::AutoCommit = 0;
104   my $dbh = dbh;
105
106   my $error = $self->SUPER::insert;
107   if ( !$error ) {
108     $content{'msgnum'} = $self->msgnum;
109     $content{'subject'} ||= '';
110     $content{'body'} ||= '';
111     my $template_content = new FS::template_content (\%content);
112     $error = $template_content->insert;
113   }
114
115   if ( $error ) {
116     $dbh->rollback if $oldAutoCommit;
117     return $error;
118   }
119
120   $dbh->commit if $oldAutoCommit;
121   return;
122 }
123
124 =item delete
125
126 Delete this record from the database.
127
128 =cut
129
130 # the delete method can be inherited from FS::Record
131
132 =item replace [ OLD_RECORD ] [ CONTENT ]
133
134 Replaces the OLD_RECORD with this one in the database.  If there is an error,
135 returns the error, otherwise returns false.
136
137 CONTENT is an optional hash containing 'subject', 'body', and 'locale'.  If 
138 supplied, an L<FS::template_content> object will be created (or modified, if 
139 one already exists for this locale).
140
141 =cut
142
143 sub replace {
144   my $self = shift;
145   my $old = ( ref($_[0]) and $_[0]->isa('FS::Record') ) 
146               ? shift
147               : $self->replace_old;
148   my %content = @_;
149   
150   my $oldAutoCommit = $FS::UID::AutoCommit;
151   local $FS::UID::AutoCommit = 0;
152   my $dbh = dbh;
153
154   my $error = $self->SUPER::replace($old);
155
156   if ( !$error and %content ) {
157     $content{'locale'} ||= '';
158     my $new_content = qsearchs('template_content', {
159                         'msgnum' => $self->msgnum,
160                         'locale' => $content{'locale'},
161                       } );
162     if ( $new_content ) {
163       $new_content->subject($content{'subject'});
164       $new_content->body($content{'body'});
165       $error = $new_content->replace;
166     }
167     else {
168       $content{'msgnum'} = $self->msgnum;
169       $new_content = new FS::template_content \%content;
170       $error = $new_content->insert;
171     }
172   }
173
174   if ( $error ) {
175     $dbh->rollback if $oldAutoCommit;
176     return $error;
177   }
178
179   warn "committing FS::msg_template->replace\n" if $DEBUG and $oldAutoCommit;
180   $dbh->commit if $oldAutoCommit;
181   return;
182 }
183     
184
185
186 =item check
187
188 Checks all fields to make sure this is a valid template.  If there is
189 an error, returns the error, otherwise returns false.  Called by the insert
190 and replace methods.
191
192 =cut
193
194 # the check method should currently be supplied - FS::Record contains some
195 # data checking routines
196
197 sub check {
198   my $self = shift;
199
200   my $error = 
201     $self->ut_numbern('msgnum')
202     || $self->ut_text('msgname')
203     || $self->ut_foreign_keyn('agentnum', 'agent', 'agentnum')
204     || $self->ut_textn('mime_type')
205     || $self->ut_enum('disabled', [ '', 'Y' ] )
206     || $self->ut_textn('from_addr')
207   ;
208   return $error if $error;
209
210   $self->mime_type('text/html') unless $self->mime_type;
211
212   $self->SUPER::check;
213 }
214
215 =item content_locales
216
217 Returns a hashref of the L<FS::template_content> objects attached to 
218 this template, with the locale as key.
219
220 =cut
221
222 sub content_locales {
223   my $self = shift;
224   return $self->{'_content_locales'} ||= +{
225     map { $_->locale , $_ } 
226     qsearch('template_content', { 'msgnum' => $self->msgnum })
227   };
228 }
229
230 =item prepare OPTION => VALUE
231
232 Fills in the template and returns a hash of the 'from' address, 'to' 
233 addresses, subject line, and body.
234
235 Options are passed as a list of name/value pairs:
236
237 =over 4
238
239 =item cust_main
240
241 Customer object (required).
242
243 =item object
244
245 Additional context object (currently, can be a cust_main, cust_pkg, 
246 cust_bill, cust_pay, cust_pay_pending, or svc_(acct, phone, broadband, 
247 domain) ).  If the object is a svc_*, its cust_pkg will be fetched and 
248 used for substitution.
249
250 As a special case, this may be an arrayref of two objects.  Both 
251 objects will be available for substitution, with their field names 
252 prefixed with 'new_' and 'old_' respectively.  This is used in the 
253 rt_ticket export when exporting "replace" events.
254
255 =item from_config
256
257 Configuration option to use as the source address, based on the customer's 
258 agentnum.  If unspecified (or the named option is empty), 'invoice_from' 
259 will be used.
260
261 The I<from_addr> field in the template takes precedence over this.
262
263 =item to
264
265 Destination address.  The default is to use the customer's 
266 invoicing_list addresses.  Multiple addresses may be comma-separated.
267
268 =item substitutions
269
270 A hash reference of additional substitutions
271
272 =back
273
274 =cut
275
276 sub prepare {
277   my( $self, %opt ) = @_;
278
279   my $cust_main = $opt{'cust_main'} or die 'cust_main required';
280   my $object = $opt{'object'} or die 'object required';
281
282   # localization
283   my $locale = $cust_main->locale || '';
284   warn "no locale for cust#".$cust_main->custnum."; using default content\n"
285     if $DEBUG and !$locale;
286   my $content = $self->content($cust_main->locale);
287   warn "preparing template '".$self->msgname."' to cust#".$cust_main->custnum."\n"
288     if($DEBUG);
289
290   my $subs = $self->substitutions;
291
292   ###
293   # create substitution table
294   ###  
295   my %hash;
296   my @objects = ($cust_main);
297   my @prefixes = ('');
298   my $svc;
299   if( ref $object ) {
300     if( ref($object) eq 'ARRAY' ) {
301       # [new, old], for provisioning tickets
302       push @objects, $object->[0], $object->[1];
303       push @prefixes, 'new_', 'old_';
304       $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
305     }
306     else {
307       push @objects, $object;
308       push @prefixes, '';
309       $svc = $object if $object->isa('FS::svc_Common');
310     }
311   }
312   if( $svc ) {
313     push @objects, $svc->cust_svc->cust_pkg;
314     push @prefixes, '';
315   }
316
317   foreach my $obj (@objects) {
318     my $prefix = shift @prefixes;
319     foreach my $name (@{ $subs->{$obj->table} }) {
320       if(!ref($name)) {
321         # simple case
322         $hash{$prefix.$name} = $obj->$name();
323       }
324       elsif( ref($name) eq 'ARRAY' ) {
325         # [ foo => sub { ... } ]
326         $hash{$prefix.($name->[0])} = $name->[1]->($obj);
327       }
328       else {
329         warn "bad msg_template substitution: '$name'\n";
330         #skip it?
331       } 
332     } 
333   } 
334
335   if ( $opt{substitutions} ) {
336     $hash{$_} = $opt{substitutions}->{$_} foreach keys %{$opt{substitutions}};
337   }
338
339   $_ = encode_entities($_ || '') foreach values(%hash);
340
341   ###
342   # clean up template
343   ###
344   my $subject_tmpl = new Text::Template (
345     TYPE   => 'STRING',
346     SOURCE => $content->subject,
347   );
348   my $subject = $subject_tmpl->fill_in( HASH => \%hash );
349
350   my $body = $content->body;
351   my ($skin, $guts) = eviscerate($body);
352   @$guts = map { 
353     $_ = decode_entities($_); # turn all punctuation back into itself
354     s/\r//gs;           # remove \r's
355     s/<br[^>]*>/\n/gsi; # and <br /> tags
356     s/<p>/\n/gsi;       # and <p>
357     s/<\/p>//gsi;       # and </p>
358     s/\240/ /gs;        # and &nbsp;
359     $_
360   } @$guts;
361   
362   $body = '{ use Date::Format qw(time2str); "" }';
363   while(@$skin || @$guts) {
364     $body .= shift(@$skin) || '';
365     $body .= shift(@$guts) || '';
366   }
367
368   ###
369   # fill-in
370   ###
371
372   my $body_tmpl = new Text::Template (
373     TYPE          => 'STRING',
374     SOURCE        => $body,
375   );
376
377   $body = $body_tmpl->fill_in( HASH => \%hash );
378
379   ###
380   # and email
381   ###
382
383   my @to;
384   if ( exists($opt{'to'}) ) {
385     @to = split(/\s*,\s*/, $opt{'to'});
386   }
387   else {
388     @to = $cust_main->invoicing_list_emailonly;
389   }
390   # no warning when preparing with no destination
391
392   my $from_addr = $self->from_addr;
393
394   if ( !$from_addr ) {
395     if ( $opt{'from_config'} ) {
396       $from_addr = scalar( $conf->config($opt{'from_config'}, 
397                                          $cust_main->agentnum) );
398     }
399     $from_addr ||= scalar( $conf->config('invoice_from',
400                                          $cust_main->agentnum) );
401   }
402 #  my @cust_msg = ();
403 #  if ( $conf->exists('log_sent_mail') and !$opt{'preview'} ) {
404 #    my $cust_msg = FS::cust_msg->new({
405 #        'custnum' => $cust_main->custnum,
406 #        'msgnum'  => $self->msgnum,
407 #        'status'  => 'prepared',
408 #      });
409 #    $cust_msg->insert;
410 #    @cust_msg = ('cust_msg' => $cust_msg);
411 #  }
412
413   (
414     'custnum' => $cust_main->custnum,
415     'msgnum'  => $self->msgnum,
416     'from' => $from_addr,
417     'to'   => \@to,
418     'bcc'  => $self->bcc_addr || undef,
419     'subject'   => $subject,
420     'html_body' => $body,
421     'text_body' => HTML::FormatText->new(leftmargin => 0, rightmargin => 70
422                     )->format( HTML::TreeBuilder->new_from_content($body) ),
423   );
424
425 }
426
427 =item send OPTION => VALUE
428
429 Fills in the template and sends it to the customer.  Options are as for 
430 'prepare'.
431
432 =cut
433
434 # broken out from prepare() in case we want to queue the sending,
435 # preview it, etc.
436 sub send {
437   my $self = shift;
438   send_email(generate_email($self->prepare(@_)));
439 }
440
441 =item render OPTION => VALUE ...
442
443 Fills in the template and renders it to a PDF document.  Returns the 
444 name of the PDF file.
445
446 Options are as for 'prepare', but 'from' and 'to' are meaningless.
447
448 =cut
449
450 # will also have options to set paper size, margins, etc.
451
452 sub render {
453   my $self = shift;
454   eval "use PDF::WebKit";
455   die $@ if $@;
456   my %opt = @_;
457   my %hash = $self->prepare(%opt);
458   my $html = $hash{'html_body'};
459
460   my $tmp = 'msg'.$self->msgnum.'-'.time2str('%Y%m%d', time).'-XXXXXXXX';
461   my $dir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc";
462
463   # Graphics/stylesheets should probably go in /var/www on the Freeside 
464   # machine.
465   my $kit = PDF::WebKit->new(\$html); #%options
466   # hack to use our wrapper script
467   $kit->configure(sub { shift->wkhtmltopdf('freeside-wkhtmltopdf') });
468   my $fh = File::Temp->new(
469     TEMPLATE  => $tmp,
470     DIR       => $dir,
471     UNLINK    => 0,
472     SUFFIX    => '.pdf'
473   );
474
475   print $fh $kit->to_pdf;
476   close $fh;
477   return $fh->filename;
478 }
479
480 =item print OPTIONS
481
482 Render a PDF and send it to the printer.  OPTIONS are as for 'render'.
483
484 =cut
485
486 sub print {
487   my $file = render(@_);
488   my @lpr = $conf->config('lpr');
489   run ([@lpr, '-r'], '<', $file)
490     or die "lpr error:\n$?\n";
491 }
492
493
494 # helper sub for package dates
495 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
496
497 # helper sub for money amounts
498 my $money = sub { ($conf->money_char || '$') . sprintf('%.2f', $_[0] || 0) };
499
500 # helper sub for usage-related messages
501 my $usage_warning = sub {
502   my $svc = shift;
503   foreach my $col (qw(seconds upbytes downbytes totalbytes)) {
504     my $amount = $svc->$col; next if $amount eq '';
505     my $method = $col.'_threshold';
506     my $threshold = $svc->$method; next if $threshold eq '';
507     return [$col, $amount, $threshold] if $amount <= $threshold;
508     # this only returns the first one that's below threshold, if there are 
509     # several.
510   }
511   return ['', '', ''];
512 };
513
514 #my $conf = new FS::Conf;
515
516 #return contexts and fill-in values
517 # If you add anything, be sure to add a description in 
518 # httemplate/edit/msg_template.html.
519 sub substitutions {
520   { 'cust_main' => [qw(
521       display_custnum agentnum agent_name
522
523       last first company
524       name name_short contact contact_firstlast
525       address1 address2 city county state zip
526       country
527       daytime night mobile fax
528
529       has_ship_address
530       ship_name ship_name_short ship_contact ship_contact_firstlast
531       ship_address1 ship_address2 ship_city ship_county ship_state ship_zip
532       ship_country
533
534       paymask payname paytype payip
535       num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
536       classname categoryname
537       balance
538       credit_limit
539       invoicing_list_emailonly
540       cust_status ucfirst_cust_status cust_statuscolor
541
542       signupdate dundate
543       packages recurdates
544       ),
545       [ invoicing_email => sub { shift->invoicing_list_emailonly_scalar } ],
546       #compatibility: obsolete ship_ fields - use the non-ship versions
547       map (
548         { my $field = $_;
549           [ "ship_$field"   => sub { shift->$field } ]
550         }
551         qw( last first company daytime night fax )
552       ),
553       # ship_name, ship_name_short, ship_contact, ship_contact_firstlast
554       # still work, though
555       [ expdate           => sub { shift->paydate_epoch } ], #compatibility
556       [ signupdate_ymd    => sub { $ymd->(shift->signupdate) } ],
557       [ dundate_ymd       => sub { $ymd->(shift->dundate) } ],
558       [ paydate_my        => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
559       [ otaker_first      => sub { shift->access_user->first } ],
560       [ otaker_last       => sub { shift->access_user->last } ],
561       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
562       [ company_name      => sub { 
563           $conf->config('company_name', shift->agentnum) 
564         } ],
565       [ company_address   => sub {
566           $conf->config('company_address', shift->agentnum)
567         } ],
568       [ company_phonenum  => sub {
569           $conf->config('company_phonenum', shift->agentnum)
570         } ],
571     ],
572     # next_bill_date
573     'cust_pkg'  => [qw( 
574       pkgnum pkg_label pkg_label_long
575       location_label
576       status statuscolor
577     
578       start_date setup bill last_bill 
579       adjourn susp expire 
580       labels_short
581       ),
582       [ pkg               => sub { shift->part_pkg->pkg } ],
583       [ pkg_category      => sub { shift->part_pkg->categoryname } ],
584       [ pkg_class         => sub { shift->part_pkg->classname } ],
585       [ cancel            => sub { shift->getfield('cancel') } ], # grrr...
586       [ start_ymd         => sub { $ymd->(shift->getfield('start_date')) } ],
587       [ setup_ymd         => sub { $ymd->(shift->getfield('setup')) } ],
588       [ next_bill_ymd     => sub { $ymd->(shift->getfield('bill')) } ],
589       [ last_bill_ymd     => sub { $ymd->(shift->getfield('last_bill')) } ],
590       [ adjourn_ymd       => sub { $ymd->(shift->getfield('adjourn')) } ],
591       [ susp_ymd          => sub { $ymd->(shift->getfield('susp')) } ],
592       [ expire_ymd        => sub { $ymd->(shift->getfield('expire')) } ],
593       [ cancel_ymd        => sub { $ymd->(shift->getfield('cancel')) } ],
594
595       # not necessarily correct for non-flat packages
596       [ setup_fee         => sub { shift->part_pkg->option('setup_fee') } ],
597       [ recur_fee         => sub { shift->part_pkg->option('recur_fee') } ],
598
599       [ freq_pretty       => sub { shift->part_pkg->freq_pretty } ],
600
601     ],
602     'cust_bill' => [qw(
603       invnum
604       _date
605     )],
606     #XXX not really thinking about cust_bill substitutions quite yet
607     
608     # for welcome and limit warning messages
609     'svc_acct' => [qw(
610       svcnum
611       username
612       domain
613       ),
614       [ password          => sub { shift->getfield('_password') } ],
615       [ column            => sub { &$usage_warning(shift)->[0] } ],
616       [ amount            => sub { &$usage_warning(shift)->[1] } ],
617       [ threshold         => sub { &$usage_warning(shift)->[2] } ],
618     ],
619     'svc_domain' => [qw(
620       svcnum
621       domain
622       ),
623       [ registrar         => sub {
624           my $registrar = qsearchs('registrar', 
625             { registrarnum => shift->registrarnum} );
626           $registrar ? $registrar->registrarname : ''
627         }
628       ],
629       [ catchall          => sub { 
630           my $svc_acct = qsearchs('svc_acct', { svcnum => shift->catchall });
631           $svc_acct ? $svc_acct->email : ''
632         }
633       ],
634     ],
635     'svc_phone' => [qw(
636       svcnum
637       phonenum
638       countrycode
639       domain
640       )
641     ],
642     'svc_broadband' => [qw(
643       svcnum
644       speed_up
645       speed_down
646       ip_addr
647       mac_addr
648       )
649     ],
650     # for payment receipts
651     'cust_pay' => [qw(
652       paynum
653       _date
654       ),
655       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
656       # overrides the one in cust_main in cases where a cust_pay is passed
657       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
658       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
659       [ payinfo           => sub { 
660           my $cust_pay = shift;
661           ($cust_pay->payby eq 'CARD' || $cust_pay->payby eq 'CHEK') ?
662             $cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
663         } ],
664     ],
665     # for payment decline messages
666     # try to support all cust_pay fields
667     # 'error' is a special case, it contains the raw error from the gateway
668     'cust_pay_pending' => [qw(
669       _date
670       error
671       ),
672       [ paid              => sub { sprintf("%.2f", shift->paid) } ],
673       [ payby             => sub { FS::payby->shortname(shift->payby) } ],
674       [ date              => sub { time2str("%a %B %o, %Y", shift->_date) } ],
675       [ payinfo           => sub {
676           my $pending = shift;
677           ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
678             $pending->paymask : $pending->decrypt($pending->payinfo)
679         } ],
680     ],
681   };
682 }
683
684 =item content LOCALE
685
686 Returns the L<FS::template_content> object appropriate to LOCALE, if there 
687 is one.  If not, returns the one with a NULL locale.
688
689 =cut
690
691 sub content {
692   my $self = shift;
693   my $locale = shift;
694   qsearchs('template_content', 
695             { 'msgnum' => $self->msgnum, 'locale' => $locale }) || 
696   qsearchs('template_content',
697             { 'msgnum' => $self->msgnum, 'locale' => '' });
698 }
699
700 =item agent
701
702 Returns the L<FS::agent> object for this template.
703
704 =cut
705
706 sub agent {
707   qsearchs('agent', { 'agentnum' => $_[0]->agentnum });
708 }
709
710 sub _upgrade_data {
711   my ($self, %opts) = @_;
712
713   my @fixes = (
714     [ 'alerter_msgnum',  'alerter_template',   '',               '', '' ],
715     [ 'cancel_msgnum',   'cancelmessage',      'cancelsubject',  '', '' ],
716     [ 'decline_msgnum',  'declinetemplate',    '',               '', '' ],
717     [ 'impending_recur_msgnum', 'impending_recur_template', '',  '', 'impending_recur_bcc' ],
718     [ 'payment_receipt_msgnum', 'payment_receipt_email', '',     '', '' ],
719     [ 'welcome_msgnum',  'welcome_email',      'welcome_email-subject', 'welcome_email-from', '' ],
720     [ 'warning_msgnum',  'warning_email',      'warning_email-subject', 'warning_email-from', '' ],
721   );
722  
723   my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
724   foreach my $agentnum (@agentnums) {
725     foreach (@fixes) {
726       my ($newname, $oldname, $subject, $from, $bcc) = @$_;
727       if ($conf->exists($oldname, $agentnum)) {
728         my $new = new FS::msg_template({
729           'msgname'   => $oldname,
730           'agentnum'  => $agentnum,
731           'from_addr' => ($from && $conf->config($from, $agentnum)) || '',
732           'bcc_addr'  => ($bcc && $conf->config($from, $agentnum)) || '',
733           'subject'   => ($subject && $conf->config($subject, $agentnum)) || '',
734           'mime_type' => 'text/html',
735           'body'      => join('<BR>',$conf->config($oldname, $agentnum)),
736         });
737         my $error = $new->insert;
738         die $error if $error;
739         $conf->set($newname, $new->msgnum, $agentnum);
740         $conf->delete($oldname, $agentnum);
741         $conf->delete($from, $agentnum) if $from;
742         $conf->delete($subject, $agentnum) if $subject;
743       }
744     }
745   }
746   foreach my $msg_template ( qsearch('msg_template', {}) ) {
747     if ( $msg_template->subject || $msg_template->body ) {
748       # create new default content
749       my %content;
750       $content{subject} = $msg_template->subject;
751       $msg_template->set('subject', '');
752
753       # work around obscure Pg/DBD bug
754       # https://rt.cpan.org/Public/Bug/Display.html?id=60200
755       # (though the right fix is to upgrade DBD)
756       my $body = $msg_template->body;
757       if ( $body =~ /^x([0-9a-f]+)$/ ) {
758         # there should be no real message templates that look like that
759         warn "converting template body to TEXT\n";
760         $body = pack('H*', $1);
761       }
762       $content{body} = $body;
763       $msg_template->set('body', '');
764
765       my $error = $msg_template->replace(%content);
766       die $error if $error;
767     }
768   }
769 }
770
771 sub eviscerate {
772   # Every bit as pleasant as it sounds.
773   #
774   # We do this because Text::Template::Preprocess doesn't
775   # actually work.  It runs the entire template through 
776   # the preprocessor, instead of the code segments.  Which 
777   # is a shame, because Text::Template already contains
778   # the code to do this operation.
779   my $body = shift;
780   my (@outside, @inside);
781   my $depth = 0;
782   my $chunk = '';
783   while($body || $chunk) {
784     my ($first, $delim, $rest);
785     # put all leading non-delimiters into $first
786     ($first, $rest) =
787         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
788     $chunk .= $first;
789     # put a leading delimiter into $delim if there is one
790     ($delim, $rest) =
791       ($rest =~ /^([{}]?)(.*)$/s);
792
793     if( $delim eq '{' ) {
794       $chunk .= '{';
795       if( $depth == 0 ) {
796         push @outside, $chunk;
797         $chunk = '';
798       }
799       $depth++;
800     }
801     elsif( $delim eq '}' ) {
802       $depth--;
803       if( $depth == 0 ) {
804         push @inside, $chunk;
805         $chunk = '';
806       }
807       $chunk .= '}';
808     }
809     else {
810       # no more delimiters
811       if( $depth == 0 ) {
812         push @outside, $chunk . $rest;
813       } # else ? something wrong
814       last;
815     }
816     $body = $rest;
817   }
818   (\@outside, \@inside);
819 }
820
821 =back
822
823 =head1 BUGS
824
825 =head1 SEE ALSO
826
827 L<FS::Record>, schema.html from the base documentation.
828
829 =cut
830
831 1;
832