RT# 78547 - Flag to disable email/print/fax/etc during tests or reports
[freeside.git] / FS / FS / msg_template / email.pm
1 package FS::msg_template::email;
2 use base qw( FS::msg_template );
3
4 use strict;
5 use vars qw( $DEBUG $conf );
6
7 # stuff needed for template generation
8 use Date::Format qw( time2str );
9 use File::Temp;
10 use IPC::Run qw(run);
11 use Text::Template;
12
13 use HTML::Entities qw( decode_entities encode_entities ) ;
14 use HTML::FormatText;
15 use HTML::TreeBuilder;
16 use Encode;
17
18 # needed to send email
19 use FS::Misc qw( generate_email );
20 use FS::Conf;
21 use Email::Sender::Simple qw( sendmail );
22
23 use FS::Record qw( qsearch qsearchs );
24
25 # needed to manage template_content objects
26 use FS::template_content;
27 use FS::UID qw( dbh );
28
29 # needed to manage prepared messages
30 use FS::cust_msg;
31
32 FS::UID->install_callback( sub { $conf = new FS::Conf; } );
33
34 our $DEBUG = 0;
35 our $me = '[FS::msg_template::email]';
36
37 =head1 NAME
38
39 FS::msg_template::email - Construct email notices with Text::Template.
40
41 =head1 DESCRIPTION
42
43 FS::msg_template::email is a message processor in which the template contains 
44 L<Text::Template> strings for the message subject line and body, and the 
45 message is delivered by email.
46
47 Currently the C<from_addr> and C<bcc_addr> fields used by this processor are
48 in the main msg_template table.
49
50 =head1 METHODS
51
52 =over 4
53
54 =item insert [ CONTENT ]
55
56 Adds this record to the database.  If there is an error, returns the error,
57 otherwise returns false.
58
59 A default (no locale) L<FS::template_content> object will be created.  CONTENT 
60 is an optional hash containing 'subject' and 'body' for this object.
61
62 =cut
63
64 sub insert {
65   my $self = shift;
66   my %content = @_;
67
68   my $oldAutoCommit = $FS::UID::AutoCommit;
69   local $FS::UID::AutoCommit = 0;
70   my $dbh = dbh;
71
72   my $error = $self->SUPER::insert;
73   if ( !$error ) {
74     $content{'msgnum'} = $self->msgnum;
75     $content{'subject'} ||= '';
76     $content{'body'} ||= '';
77     my $template_content = new FS::template_content (\%content);
78     $error = $template_content->insert;
79   }
80
81   if ( $error ) {
82     $dbh->rollback if $oldAutoCommit;
83     return $error;
84   }
85
86   $dbh->commit if $oldAutoCommit;
87   return;
88 }
89
90 =item replace [ OLD_RECORD ] [ CONTENT ]
91
92 Replaces the OLD_RECORD with this one in the database.  If there is an error,
93 returns the error, otherwise returns false.
94
95 CONTENT is an optional hash containing 'subject', 'body', and 'locale'.  If 
96 supplied, an L<FS::template_content> object will be created (or modified, if 
97 one already exists for this locale).
98
99 =cut
100
101 sub replace {
102   my $self = shift;
103   my $old = ( ref($_[0]) and $_[0]->isa('FS::Record') ) 
104               ? shift
105               : $self->replace_old;
106   my %content = @_;
107   
108   my $oldAutoCommit = $FS::UID::AutoCommit;
109   local $FS::UID::AutoCommit = 0;
110   my $dbh = dbh;
111
112   my $error = $self->SUPER::replace($old);
113
114   if ( !$error and %content ) {
115     $content{'locale'} ||= '';
116     my $new_content = qsearchs('template_content', {
117                         'msgnum' => $self->msgnum,
118                         'locale' => $content{'locale'},
119                       } );
120     if ( $new_content ) {
121       $new_content->subject($content{'subject'});
122       $new_content->body($content{'body'});
123       $error = $new_content->replace;
124     }
125     else {
126       $content{'msgnum'} = $self->msgnum;
127       $new_content = new FS::template_content \%content;
128       $error = $new_content->insert;
129     }
130   }
131
132   if ( $error ) {
133     $dbh->rollback if $oldAutoCommit;
134     return $error;
135   }
136
137   warn "committing FS::msg_template->replace\n" if $DEBUG and $oldAutoCommit;
138   $dbh->commit if $oldAutoCommit;
139   return;
140 }
141
142 =item content_locales
143
144 Returns a hashref of the L<FS::template_content> objects attached to 
145 this template, with the locale as key.
146
147 =cut
148
149 sub content_locales {
150   my $self = shift;
151   return $self->{'_content_locales'} ||= +{
152     map { $_->locale , $_ } 
153     qsearch('template_content', { 'msgnum' => $self->msgnum })
154   };
155 }
156
157 =item prepare OPTION => VALUE
158
159 Fills in the template and returns an L<FS::cust_msg> object.
160
161 Options are passed as a list of name/value pairs:
162
163 =over 4
164
165 =item cust_main
166
167 Customer object
168
169 =item object
170
171 Additional context object (currently, can be a cust_main, cust_pkg, 
172 cust_bill, cust_pay, cust_pay_pending, or svc_(acct, phone, broadband, 
173 domain) ).  If the object is a svc_*, its cust_pkg will be fetched and 
174 used for substitution.
175
176 As a special case, this may be an arrayref of two objects.  Both 
177 objects will be available for substitution, with their field names 
178 prefixed with 'new_' and 'old_' respectively.  This is used in the 
179 rt_ticket export when exporting "replace" events.
180
181 =item from_config
182
183 Configuration option to use as the source address, based on the customer's 
184 agentnum.  If unspecified (or the named option is empty), 'invoice_from' 
185 will be used.
186
187 The I<from_addr> field in the template takes precedence over this.
188
189 =item to
190
191 Destination address.  The default is to use the customer's 
192 invoicing_list addresses.  Multiple addresses may be comma-separated.
193
194 =item substitutions
195
196 A hash reference of additional substitutions
197
198 =item msgtype
199
200 A string identifying the kind of message this is. Currently can be "invoice", 
201 "receipt", "admin", or null. Expand this list as necessary.
202
203 =item override_content
204
205 A string to use as the HTML body; if specified, replaces the entire
206 body of the message. This should be used ONLY by L<FS::report_batch> and may
207 go away in the future.
208
209 =item attach
210
211 A L<MIME::Entity> (or arrayref of them) to attach to the message.
212
213 =cut
214
215 =back
216
217 =cut
218
219 sub prepare {
220
221   my( $self, %opt ) = @_;
222
223   my $cust_main = $opt{'cust_main'}; # or die 'cust_main required';
224   my $object = $opt{'object'}; # or die 'object required';
225
226   my $hashref = $self->prepare_substitutions(%opt);
227
228   # localization
229   my $locale = $cust_main && $cust_main->locale || '';
230   warn "no locale for cust#".$cust_main->custnum."; using default content\n"
231     if $DEBUG and $cust_main && !$locale;
232   my $content = $self->content($locale);
233
234   warn "preparing template '".$self->msgname."\n"
235     if $DEBUG;
236
237   $_ = encode_entities($_ || '') foreach values(%$hashref);
238
239   ###
240   # clean up template
241   ###
242   my $subject_tmpl = new Text::Template (
243     TYPE   => 'STRING',
244     SOURCE => $content->subject,
245   );
246
247   warn "$me filling in subject template\n" if $DEBUG;
248   my $subject = $subject_tmpl->fill_in( HASH => $hashref );
249
250   my $body = $content->body;
251   my ($skin, $guts) = eviscerate($body);
252   @$guts = map { 
253     $_ = decode_entities($_); # turn all punctuation back into itself
254     s/\r//gs;           # remove \r's
255     s/<br[^>]*>/\n/gsi; # and <br /> tags
256     s/<p>/\n/gsi;       # and <p>
257     s/<\/p>//gsi;       # and </p>
258     s/\240/ /gs;        # and &nbsp;
259     $_
260   } @$guts;
261   
262   $body = '{ use Date::Format qw(time2str); "" }';
263   while(@$skin || @$guts) {
264     $body .= shift(@$skin) || '';
265     $body .= shift(@$guts) || '';
266   }
267
268   ###
269   # fill-in
270   ###
271
272   my $body_tmpl = new Text::Template (
273     TYPE          => 'STRING',
274     SOURCE        => $body,
275   );
276   
277   warn "$me filling in body template\n" if $DEBUG;
278   $body = $body_tmpl->fill_in( HASH => $hashref );
279
280   # override $body if requested
281   if ( $opt{'override_content'} ) {
282     warn "$me overriding template body with requested content" if $DEBUG;
283     $body = $opt{'override_content'};
284   }
285
286   ###
287   # and email
288   ###
289
290   my @to;
291   if ( exists($opt{'to'}) ) {
292
293     @to = map { $_->format } Email::Address->parse($opt{'to'});
294
295   } elsif ( $cust_main ) {
296
297     my $classnum = $opt{'to_contact_classnum'} || '';
298     my @classes = ref($classnum) ? @$classnum : split(',', $classnum);
299     # traditional behavior: send to all invoice recipients
300     @classes = ('invoice') unless @classes;
301     @to = $cust_main->contact_list_email(@classes);
302     # not guaranteed to produce contacts, but then customers aren't
303     # guaranteed to have email addresses on file. in that case, env_to
304     # will be null and sending this message will fail.
305
306   } else {
307     die 'no To: address or cust_main object specified';
308   }
309
310   my $from_addr = $self->from_addr;
311
312   if ( !$from_addr ) {
313
314     my $agentnum = $cust_main ? $cust_main->agentnum : '';
315
316     if ( $opt{'from_config'} ) {
317       $from_addr = $conf->config($opt{'from_config'}, $agentnum);
318     }
319     $from_addr ||= $conf->invoice_from_full($agentnum);
320   }
321
322   my $text_body = encode('UTF-8',
323                   HTML::FormatText->new(leftmargin => 0, rightmargin => 70)
324                       ->format( HTML::TreeBuilder->new_from_content($body) )
325                   );
326
327   warn "$me constructing MIME entities\n" if $DEBUG;
328   my %email = generate_email(
329     'from'      => $from_addr,
330     'to'        => \@to,
331     'bcc'       => $self->bcc_addr || undef,
332     'subject'   => $subject,
333     'html_body' => $body,
334     'text_body' => $text_body,
335   );
336
337   warn "$me creating message headers\n" if $DEBUG;
338   # strip display-name from envelope addresses
339   # (use Email::Address for this? it chokes on non-ASCII characters in
340   # the display-name, which is not great for us)
341   my $env_from = $from_addr;
342   foreach ($env_from, @to) {
343     s/^\s*//;
344     s/\s*$//;
345     s/^(.*)\s*<(.*@.*)>$/$2/;
346   }
347
348   my $domain;
349   if ( $env_from =~ /\@([\w\.\-]+)/ ) {
350     $domain = $1;
351   } else {
352     warn 'no domain found in invoice from address '. $env_from .
353          '; constructing Message-ID (and saying HELO) @example.com'; 
354     $domain = 'example.com';
355   } 
356   my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
357
358   my $time = time;
359   my $message = MIME::Entity->build(
360     'From'        => $from_addr,
361     'To'          => join(', ', @to),
362     'Sender'      => $from_addr,
363     'Reply-To'    => $from_addr,
364     'Date'        => time2str("%a, %d %b %Y %X %z", $time),
365     'Subject'     => Encode::encode('MIME-Header', $subject),
366     'Message-ID'  => "<$message_id>",
367     'Encoding'    => '7bit',
368     'Type'        => 'multipart/related',
369   );
370
371   if ( $opt{'attach'} ) {
372     my @attach;
373     if (ref $opt{'attach'} eq 'ARRAY') {
374       @attach = @{ $opt{'attach'} };
375     } else {
376       @attach = $opt{'attach'};
377     }
378     foreach (@attach) {
379       $message->add_part($_);
380     }
381   }
382
383   #$message->head->replace('Content-type',
384   #  'multipart/related; '.
385   #  'boundary="' . $message->head->multipart_boundary . '"; ' .
386   #  'type=multipart/alternative'
387   #);
388
389   foreach my $part (@{ $email{mimeparts} }) {
390     warn "$me appending part ".$part->mime_type."\n" if $DEBUG;
391     $message->add_part( $part );
392   }
393
394   # effective To: address (not in headers)
395   push @to, $self->bcc_addr if $self->bcc_addr;
396   my @env_to;
397   foreach my $dest (@to) {
398     push @env_to, map { $_->address } Email::Address->parse($dest);
399   }
400
401   my $cust_msg = FS::cust_msg->new({
402       'custnum'   => $cust_main ? $cust_main->custnum : '',
403       'msgnum'    => $self->msgnum,
404       '_date'     => $time,
405       'env_from'  => $env_from,
406       'env_to'    => join(',', @env_to),
407       'header'    => $message->header_as_string,
408       'body'      => $message->body_as_string,
409       'error'     => '',
410       'status'    => 'prepared',
411       'msgtype'   => ($opt{'msgtype'} || ''),
412       'preview'   => $body, # html content only
413   });
414
415   return $cust_msg;
416 }
417
418 =item render OPTION => VALUE ...
419
420 Fills in the template and renders it to a PDF document.  Returns the 
421 name of the PDF file.
422
423 Options are as for 'prepare', but 'from' and 'to' are meaningless.
424
425 =cut
426
427 # will also have options to set paper size, margins, etc.
428
429 sub render {
430   my $self = shift;
431   eval "use PDF::WebKit";
432   die $@ if $@;
433   my %opt = @_;
434   my %hash = $self->prepare(%opt);
435   my $html = $hash{'html_body'};
436
437   # Graphics/stylesheets should probably go in /var/www on the Freeside 
438   # machine.
439   my $script_path = `/usr/bin/which freeside-wkhtmltopdf`;
440   chomp $script_path;
441   my $kit = PDF::WebKit->new(\$html); #%options
442   # hack to use our wrapper script
443   $kit->configure(sub { shift->wkhtmltopdf($script_path) });
444
445   $kit->to_pdf;
446 }
447
448 =item print OPTIONS
449
450 Render a PDF and send it to the printer.  OPTIONS are as for 'render'.
451
452 =cut
453
454 sub print {
455   my( $self, %opt ) = @_;
456   do_print( [ $self->render(%opt) ], agentnum=>$opt{cust_main}->agentnum );
457 }
458
459 # helper sub for package dates
460 my $ymd = sub { $_[0] ? time2str('%Y-%m-%d', $_[0]) : '' };
461
462 # helper sub for money amounts
463 my $money = sub { ($conf->money_char || '$') . sprintf('%.2f', $_[0] || 0) };
464
465 # helper sub for usage-related messages
466 my $usage_warning = sub {
467   my $svc = shift;
468   foreach my $col (qw(seconds upbytes downbytes totalbytes)) {
469     my $amount = $svc->$col; next if $amount eq '';
470     my $method = $col.'_threshold';
471     my $threshold = $svc->$method; next if $threshold eq '';
472     return [$col, $amount, $threshold] if $amount <= $threshold;
473     # this only returns the first one that's below threshold, if there are 
474     # several.
475   }
476   return ['', '', ''];
477 };
478
479 =item content LOCALE
480
481 Returns the L<FS::template_content> object appropriate to LOCALE, if there 
482 is one.  If not, returns the one with a NULL locale.
483
484 =cut
485
486 sub content {
487   my $self = shift;
488   my $locale = shift;
489   qsearchs('template_content', 
490             { 'msgnum' => $self->msgnum, 'locale' => $locale }) || 
491   qsearchs('template_content',
492             { 'msgnum' => $self->msgnum, 'locale' => '' });
493 }
494
495 =cut
496
497 =item send_prepared CUST_MSG
498
499 Takes the CUST_MSG object and sends it to its recipient. The "smtpmachine"
500 configuration option will be used to find the outgoing mail server.
501
502 =cut
503
504 sub send_prepared {
505   my $self = shift;
506   my $cust_msg = shift or die "cust_msg required";
507
508   if ( $FS::Misc::DISABLE_ALL_NOTICES ) {
509     warn 'send_prepared() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG;
510     return;
511   }
512
513   my $domain = 'example.com';
514   if ( $cust_msg->env_from =~ /\@([\w\.\-]+)/ ) {
515     $domain = $1;
516   }
517
518   # in principle should already be a list of bare addresses, but run it
519   # through Email::Address to make sure
520   my @env_to = map { $_->address } Email::Address->parse($cust_msg->env_to);
521
522   my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
523                    'helo' => $domain );
524
525   my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
526   $smtp_opt{'port'} = $port;
527   
528   my $transport;
529   if ( defined($enc) && $enc eq 'starttls' ) {
530     $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
531     $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
532   } else {
533     if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
534       $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);     
535     } 
536     $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
537     $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
538   }
539
540   warn "$me sending message\n" if $DEBUG;
541   my $message = join("\n\n", $cust_msg->header, $cust_msg->body);
542   local $@;
543   eval {
544     sendmail( $message, { transport => $transport,
545                           from      => $cust_msg->env_from,
546                           to        => \@env_to })
547   };
548   my $error = '';
549   if(ref($@) and $@->isa('Email::Sender::Failure')) {
550     $error = $@->code.' ' if $@->code;
551     $error .= $@->message;
552   }
553   else {
554     $error = $@;
555   }
556
557   $cust_msg->set('error', $error);
558   $cust_msg->set('status', $error ? 'failed' : 'sent');
559   if ( $cust_msg->custmsgnum ) {
560     $cust_msg->replace;
561   } else {
562     $cust_msg->insert;
563   }
564
565   $error;
566 }
567
568 =back
569
570 =cut
571
572 # internal use only
573
574 sub eviscerate {
575   # Every bit as pleasant as it sounds.
576   #
577   # We do this because Text::Template::Preprocess doesn't
578   # actually work.  It runs the entire template through 
579   # the preprocessor, instead of the code segments.  Which 
580   # is a shame, because Text::Template already contains
581   # the code to do this operation.
582   my $body = shift;
583   my (@outside, @inside);
584   my $depth = 0;
585   my $chunk = '';
586   while($body || $chunk) {
587     my ($first, $delim, $rest);
588     # put all leading non-delimiters into $first
589     ($first, $rest) =
590         ($body =~ /^((?:\\[{}]|[^{}])*)(.*)$/s);
591     $chunk .= $first;
592     # put a leading delimiter into $delim if there is one
593     ($delim, $rest) =
594       ($rest =~ /^([{}]?)(.*)$/s);
595
596     if( $delim eq '{' ) {
597       $chunk .= '{';
598       if( $depth == 0 ) {
599         push @outside, $chunk;
600         $chunk = '';
601       }
602       $depth++;
603     }
604     elsif( $delim eq '}' ) {
605       $depth--;
606       if( $depth == 0 ) {
607         push @inside, $chunk;
608         $chunk = '';
609       }
610       $chunk .= '}';
611     }
612     else {
613       # no more delimiters
614       if( $depth == 0 ) {
615         push @outside, $chunk . $rest;
616       } # else ? something wrong
617       last;
618     }
619     $body = $rest;
620   }
621   (\@outside, \@inside);
622 }
623
624 =head1 BUGS
625
626 =head1 SEE ALSO
627
628 L<FS::Record>, schema.html from the base documentation.
629
630 =cut
631
632 1;
633