File Coverage

blib/lib/XAO/DO/Web/Mailer.pm
Criterion Covered Total %
statement 135 166 81.3
branch 80 124 64.5
condition 66 96 68.7
subroutine 7 10 70.0
pod 1 4 25.0
total 289 400 72.2


line stmt bran cond sub pod time code
1             =head1 NAME
2              
3             XAO::DO::Web::Mailer - executes given template and sends results via e-mail
4              
5             =head1 SYNOPSIS
6              
7             <%Mailer
8             to="foo@somehost.com"
9             from="bar@otherhost.com"
10             subject="Your order '<%ORDER_ID/f%>' has been shipped"
11             text.path="/bits/shipped-mail-text"
12             html.path="/bits/shipped-mail-html"
13             ORDER_ID="<%ORDER_ID/f%>"
14             %>
15              
16             =head1 DESCRIPTION
17              
18             Displays nothing, just sends message.
19              
20             Arguments are:
21              
22             to => e-mail address of the recepient; default is taken from
23             userdata->email if defined.
24             cc => optional e-mail addresses of secondary recepients
25             bcc => optional e-mail addresses of blind CC recepients
26             from => optional 'from' e-mail address, default is taken from
27             'from' site configuration parameter.
28             subject => message subject;
29             [text.]path => text-only template path
30             html.path => html template path
31             date => optional date header, passed as is;
32             pass => pass parameters of the calling template to the mail template;
33             ARG => VALUE - passed to Page when executing templates;
34              
35             If 'to', 'from' or 'subject' are not specified then get_to(), get_from()
36             or get_subject() methods are called first. Derived class may override
37             them. 'To', 'cc' and 'bcc' may be comma-separated addresses lists.
38              
39             To send text, html or both templates as-is, without parsing and variable
40             substitution, use 'html.unparsed', 'text.unparsed', or 'unparsed'
41             arguments.
42              
43             To send additional attachments along with the email pass the following
44             arguments (where N can be any alphanumeric tag):
45              
46             attachment.N.type => MIME type for attachment (image/gif, text/plain, etc)
47             attachment.N.filename => download filename for the attachment (optional)
48             attachment.N.disposition => attachment disposition (optional, 'attachment' by default)
49             attachment.N.path => path to a template for building the attachment
50             attachment.N.template => inline template for building the attachment
51             attachment.N.unparsed => use the template literally, without xao-parsing
52             attachment.N.pass => pass all arguments of the calling template
53             attachment.N.ARG => VALUE - passed literally as ARG=>VALUE to the template
54              
55             Additional headers can be passed like this (N being any alphanumeric tag):
56              
57             header.N.name => header name, eg 'X-Clacks-Overhead'
58             header.N.value => header content, eg 'GNU Terry Pratchett'
59              
60             For mass emails it is helpful to provide an RFC-8058 automatic
61             unsubscribe link. The link will be turned into two headers as per RFC,
62             'List-Unsubscribe' and 'List-Unsubscribe-Post'.
63              
64             one_click_unsubscribe => https://....
65              
66             The configuration for Web::Mailer is kept in a hash stored in the site
67             configuration under 'mailer' name. Normally it is not required, the
68             default is to use sendmail for delivery. The parameters are:
69              
70             method => either 'local' or 'smtp'
71             agent => server name for `smtp' or binary path for `local'
72             from => either a hash reference or a scalar with the default
73             `from' address.
74             override_from
75             => if set overrides the from address
76             override_to
77             => if set overrides all to addresses and always sends to
78             the given address. Useful for debugging.
79             override_except
80             => addresses listed here are OK to go through. Matching
81             is done on substrings ingoring case. This options makes
82             sense only in pair with override_to.
83             subject_prefix
84             => optional fixed prefix for all subjects
85             subject_suffix
86             => optional fixed suffix for all subjects
87              
88             If `from' is a hash reference then the content of `from' argument to the
89             object is looked in keys and the value is used as actual `from'
90             address. This can be used to set up rudimentary aliases:
91              
92             <%Mailer
93             ...
94             from="customer_support"
95             ...
96             %>
97              
98             mailer => {
99             from => {
100             customer_support => 'support@foo.com',
101             technical_support => 'tech@foo.com',
102             },
103             ...
104             }
105              
106             In that case actual from address will be `support@foo.com'. By default
107             if `from' in the configuration is a hash and there is no `from'
108             parameter for the object, `default' is used as the key.
109              
110             =cut
111              
112             ###############################################################################
113             package XAO::DO::Web::Mailer;
114 1     1   2194 use strict;
  1         1  
  1         39  
115 1     1   4 use Encode;
  1         1  
  1         109  
116 1     1   1502 use MIME::Lite;
  1         25470  
  1         40  
117 1     1   7 use XAO::Objects;
  1         1  
  1         19  
118 1     1   4 use XAO::Utils;
  1         2  
  1         71  
119 1     1   4 use base XAO::Objects->load(objname => 'Web::Page');
  1         1  
  1         6  
120              
121             our $VERSION='2.012';
122              
123             sub display ($;%) {
124 20     20 1 107 my $self=shift;
125 20         129 my $args=get_args(\@_);
126              
127 20   50     848 my $config=$self->siteconfig->get('/mailer') || {};
128              
129 20   33     8589 my $to=$args->{'to'} ||
130             $self->get_to($args) ||
131             throw $self "display - no 'to' given";
132              
133 20   100     254 my $cc=$args->{'cc'} || '';
134 20   100     121 my $bcc=$args->{'bcc'} || '';
135              
136 20         78 my @ovhdr;
137              
138             # Extra headers. Not sanitizing, assuming the caller knows what they
139             # are doing.
140             #
141 20         196 foreach my $k (sort keys %$args) {
142 133 100       444 next unless $k=~/^header\.(\w+)\.name$/;
143 3         20 my $id = $1;
144              
145 3         20 my $n = $args->{"header.$id.name"};
146 3         44 my $v = $args->{"header.$id.value"};
147              
148 3 50 33     32 next unless $n && $v;
149              
150 3 100       23 $n = $1 if $n =~/^(.*):$/;
151              
152 3 50       54 if($n =~ /^[a-z][a-z0-9-]*[a-z]$/i) {
153 3         17 push(@ovhdr, "$n:" => $v);
154             }
155             else {
156 0         0 eprint "Invalid extra header '$n', ignored";
157             }
158             }
159              
160             # Unsubscribe links (RFC-8058).
161             #
162 20 100       115 if(my $one_click_unsubscribe = $args->{'one_click_unsubscribe'}) {
163              
164             # In theory should support mailto: links, but in this day & age
165             # is it really needed?
166             #
167 1 50       18 if($one_click_unsubscribe =~ /^https:\/\//) {
168 1         15 push(@ovhdr,
169             'List-Unsubscribe:' => '<' . $one_click_unsubscribe . '>',
170             'List-Unsubscribe-Post:' => 'List-Unsubscribe=One-Click',
171             );
172             }
173             else {
174 0         0 eprint "Unsupported one click unsubscribe link '$one_click_unsubscribe', ignored";
175             }
176             }
177              
178             # Configured email overrides
179             #
180 20 100       86 if(my $override_to=$config->{'override_to'}) {
181 3         15 my $to_new;
182              
183 3 50       23 if(my $override_except=$config->{'override_except'}) {
184 0 0       0 $override_except=[ split(/\s*[,;]\s*/,$override_except) ] unless ref($override_except) eq 'ARRAY';
185 0         0 $override_except=[ map { lc } @$override_except ];
  0         0  
186              
187 0         0 my %pass;
188             my %override;
189 0         0 foreach my $email (split(/\s*[,;]+\s*/,"$to,$cc,$bcc")) {
190 0 0       0 if(grep { index(lc($email),$_)>=0 } @$override_except) {
  0         0  
191 0         0 $pass{$email}=1;
192             }
193             else {
194 0         0 $override{$email}=1;
195             }
196             }
197              
198 0 0       0 $to_new=join(', ',(keys %pass),(%override ? ($override_to) : ()));
199             }
200             else {
201 3         12 $to_new=$override_to;
202             }
203              
204 3         167 dprint ref($self)."::display - overriding to='$to', cc='$cc', bcc='$bcc' with to='$to_new', cc='', bcc=''";
205              
206 3 50       34 push(@ovhdr,('X-XAO-Web-Mailer-To' => $to)) if $to;
207 3 100       21 push(@ovhdr,('X-XAO-Web-Mailer-Cc' => $cc)) if $cc;
208 3 100       18 push(@ovhdr,('X-XAO-Web-Mailer-Bcc' => $bcc)) if $bcc;
209              
210 3         10 $to=$to_new;
211 3         17 $cc='';
212 3         14 $bcc='';
213             }
214              
215 20         65 my $from=$args->{'from'};
216 20 100       61 if(!$from) {
217 3         12 $from=$config->{'from'};
218 3 50       18 $from=$from->{'default'} if ref($from);
219             }
220             else {
221             $from=$config->{'from'}->{$from} if ref($config->{'from'}) &&
222 17 0 33     69 $config->{'from'}->{$from};
223             }
224 20 50       68 $from || throw $self "display - no 'from' given";
225              
226 20 50       78 if(my $override_from=$config->{'override_from'}) {
227 0 0       0 if($override_from ne $from) {
228 0         0 dprint ref($self)."::display - overriding from='$from' with '$override_from'";
229              
230 0         0 push(@ovhdr,('X-XAO-Web-Mailer-From' => $from));
231              
232 0         0 $from=$override_from;
233             }
234             }
235              
236 20         47 my $from_hdr=$from;
237 20 50       228 if($from =~ /^\s*.*\s+<(.*\@.*)>\s*$/) {
    50          
238 0         0 $from=$1;
239             }
240             elsif($from =~ /^\s*(.*\@.*)\s+\(.*\)\s*$/) {
241 0         0 $from=$1;
242             }
243             else {
244 20         275 $from=~s/^\s*(.*?)\s*$/$1/;
245             }
246              
247 20   50     128 my $subject=$args->{'subject'} || $self->get_subject() || 'No subject';
248              
249 20 100       70 if(my $subject_prefix=$config->{'subject_prefix'}) {
250 1 50       23 $subject=$subject_prefix.($subject_prefix=~/\s$/ ? '':' ').$subject;
251             }
252              
253 20 50       97 if(my $subject_suffix=$config->{'subject_suffix'}) {
254 0 0       0 $subject=$subject.($subject_suffix=~/\s$/ ? '':' ').$subject_suffix;
255             }
256              
257             # Charset for outgoing mail. Either /mailer/charset or /charset
258             #
259 20   100     193 my $charset=$config->{'charset'} || $self->siteconfig->get('charset') || undef;
260             ### dprint "...mailer charset=",$charset;
261              
262             # Subject might contain 8-bit characters, but being a header it
263             # needs to be 7-bit. MIME::Lite does not do that.
264             #
265 20 100       1266 if(Encode::is_utf8($subject)) {
266 3         64 $subject=Encode::encode('MIME-Q',$subject);
267              
268             # The output from MIME-Q is a multi-line string separated by \r\n
269             # and that \r appears to be duplicated by some MTA implementations.
270             # The rest of MIME::Lite headers are output with \n, so sticking to
271             # that.
272             #
273 3         14824 $subject=~s/\r//sg;
274             }
275              
276             # Encoding by default in MIME::Lite is "binary", which means no
277             # processing at all. That might break on some gateway and MIME
278             # validator at https://tools.ietf.org/tools/msglint/ balks
279             # at it. Keeping "binary" here for compatibility with older
280             # deployments, but allowing to override it.
281             #
282 20   100     196 my $transfer_encoding=$config->{'transfer_encoding'} || 'binary';
283             ### dprint "...mailer transfer_encoding=",$transfer_encoding;
284              
285             # Getting common args from the parent template if needed.
286             #
287 20         536 my $common=$self->pass_args($args->{'pass'});
288              
289             # Parsing text template
290             #
291 20         154 my $page=$self->object;
292 20         2365 my $text;
293 20 100 33     412 if($args->{'text.path'} || $args->{'path'} || $args->{'text.template'} || $args->{'template'}) {
      66        
      100        
294             $text=$page->expand($args,$common,{
295             path => $args->{'text.path'} || $args->{'path'},
296             template => $args->{'text.template'} || $args->{'template'},
297 13   33     299 unparsed => $args->{'text.unparsed'} || $args->{'unparsed'},
      66        
      100        
298             });
299             }
300              
301             # Parsing HTML template
302             #
303 20         144 my $html;
304 20 100 100     418 if($args->{'html.path'} || $args->{'html.template'}) {
305             $html=$page->expand($args,$common,{
306             path => $args->{'html.path'},
307             template => $args->{'html.template'},
308 15   100     1054 unparsed => $args->{'html.unparsed'} || $args->{'unparsed'},
309             });
310             }
311              
312 20 50 66     126 defined $text || defined $html ||
313             throw $self "- no text for either html or text part";
314              
315             # Preparing attachments if any
316             #
317 20         106 my @attachments;
318 20         153 foreach my $k (sort keys %$args) {
319 133 100       379 next unless $k=~/^attachment\.(\w+)\.type$/;
320 9         39 my $id=$1;
321              
322             my %data=(
323             Type => $args->{$k},
324             Filename => $args->{'attachment.'.$id.'.filename'} || '',
325 9   100     145 Disposition => $args->{'attachment.'.$id.'.disposition'} || 'attachment',
      50        
326             );
327              
328 9 50 66     70 if($args->{'attachment.'.$id.'.template'} || $args->{'attachment.'.$id.'.path'}) {
    0          
329 9         46 my $objargs={ };
330 9         43 foreach my $kk (keys %$args) {
331 112 100       755 next unless $kk =~ /^attachment\.$id\.(.*)$/;
332 35         172 $objargs->{$1}=$args->{$kk};
333             }
334              
335 9         27 my $content;
336 9 50       47 if($args->{'attachment.'.$id.'.unparsed'}) {
337 9 100       34 if(defined $args->{'attachment.'.$id.'.template'}) {
    50          
338 7         35 $content=$args->{'attachment.'.$id.'.template'};
339             }
340             elsif(defined $args->{'attachment.'.$id.'.path'}) {
341             $content=$self->object->expand(
342 2         29 path => $args->{'attachment.'.$id.'.path'},
343             unparsed => 1,
344             );
345             }
346             }
347             else {
348 0   0     0 my $obj=$self->object(objname => ($objargs->{'objname'} || 'Page'));
349 0         0 delete $objargs->{'objname'};
350              
351 0 0       0 if($args->{'attachment.'.$id.'.pass'}) {
352 0         0 $objargs=$self->pass_args($args->{'attachment.'.$id.'.pass'},$objargs);
353             }
354              
355 0         0 $content=$obj->expand($objargs);
356             }
357              
358             # The content should be bytes, but if it is in
359             # characters it needs to be converted or MIME::Lite will
360             # croak.
361             #
362 9 100 50     68 $content=Encode::encode($charset || 'utf8',$content) if utf8::is_utf8($content);
363              
364 9         125 $data{'Data'}=$content;
365             }
366             elsif($args->{'attachment.'.$id.'.file'}) {
367 0         0 throw $self "- attaching files not implemented";
368             }
369             else {
370 0         0 throw $self "- no path/template/file given for attachment '$id'";
371             }
372              
373 9         24 push(@attachments,\%data);
374             }
375              
376             # Preparing mailer and storing content in.
377             #
378             # MIME::Lite does not do anything with encoding and does not really
379             # support perl unicode. It does not apply any filtering to its
380             # output streams. With that in mind, we need to supply it with
381             # bytes, so doing our own encoding.
382             #
383             # Thanks Brian Mielke for catching this!
384             #
385             # TODO: Switch to Email::MIME instead of MIME::Lite!
386             #
387 20 100 100     335 my @stdhdr=(
388             From => $from_hdr,
389             FromSender => $from,
390             To => $to,
391             Subject => $charset && utf8::is_utf8($subject) ? Encode::encode($charset,$subject) : $subject,
392             );
393              
394 20         251 push(@stdhdr,@ovhdr);
395              
396 20         54 my $mailer;
397              
398             # Simple case, HTML only, no attachments
399             #
400 20 100 100     305 if(defined $html && !defined $text && !@attachments) {
    100 100        
      100        
      100        
401 6 50 33     150 $mailer=MIME::Lite->new(
402             @stdhdr,
403             Type => 'text/html',
404             Data => $charset && utf8::is_utf8($html) ? Encode::encode($charset,$html) : $html,
405             Datestamp => 0,
406             Encoding => $transfer_encoding,
407             );
408 6 50       2960 $mailer->attr('content-type.charset' => $charset) if $charset;
409             }
410              
411             # TEXT only, no attachments
412             #
413             elsif(defined $text && !defined $html && !@attachments) {
414 4 50 66     78 $mailer=MIME::Lite->new(
415             @stdhdr,
416             Type => 'text/plain',
417             Data => $charset && utf8::is_utf8($text) ? Encode::encode($charset,$text) : $text,
418             Datestamp => 0,
419             Encoding => $transfer_encoding,
420             );
421 4 100       1917 $mailer->attr('content-type.charset' => $charset) if $charset;
422             }
423              
424             # HTML, TEXT, and/or attachments
425             #
426             else {
427 10         30 my $text_part;
428 10 100       1470 if(defined $text) {
429 9 50 33     919 $text_part=MIME::Lite->new(
430             Type => 'text/plain',
431             Data => $charset && utf8::is_utf8($text) ? Encode::encode($charset,$text) : $text,
432             Encoding => $transfer_encoding,
433             );
434              
435 9         14476 $text_part->delete('X-Mailer');
436 9         220 $text_part->delete('Date');
437              
438 9 50       253 $text_part->attr('content-type.charset' => $charset) if $charset;
439             }
440              
441 10         380 my $html_part;
442 10 100       43 if(defined $html) {
443 9 50 33     151 $html_part=MIME::Lite->new(
444             Type => 'text/html',
445             Data => $charset && utf8::is_utf8($html) ? Encode::encode($charset,$html) : $html,
446             Encoding => $transfer_encoding,
447             );
448              
449 9         7273 $html_part->delete('X-Mailer');
450 9         187 $html_part->delete('Date');
451              
452 9 50       151 $html_part->attr('content-type.charset' => $charset) if $charset;
453             }
454              
455 10 100       300 $mailer=MIME::Lite->new(
456             @stdhdr,
457             Type => @attachments ? 'multipart/mixed' : 'multipart/alternative',
458             Datestamp => 0,
459             );
460              
461 10 100 100     75309 if($text_part && $html_part && @attachments) {
      100        
462 3         26 my $alt_part=MIME::Lite->new(
463             Type => 'multipart/alternative',
464             Datestamp => 0,
465             );
466              
467 3         821 $alt_part->delete('X-Mailer');
468 3         63 $alt_part->delete('Date');
469              
470 3         44 $alt_part->attach($text_part);
471 3         87 $alt_part->attach($html_part);
472              
473 3         51 $mailer->attach($alt_part);
474             }
475             else {
476 7 100       40 $mailer->attach($text_part) if $text_part;
477 7 100       1224 $mailer->attach($html_part) if $html_part;
478             }
479              
480             # Adding attachments if any
481             #
482 10         1096 foreach my $adata (@attachments) {
483 9         1813 $mailer->attach(%$adata);
484             }
485             }
486              
487 20 50       2261 $mailer->add(Date => $args->{'date'}) if $args->{'date'};
488 20 50       61 $mailer->add(Cc => $cc) if $cc;
489 20 50       56 $mailer->add(Bcc => $bcc) if $bcc;
490 20 100       85 $mailer->add('Reply-To' => $args->{'replyto'}) if $args->{'replyto'};
491              
492             # Sending
493             #
494             ### dprint $mailer->as_string;
495 20   50     119 my $method=$config->{'method'} || 'local';
496 20         146 my $agent=$config->{'agent'};
497 20 50       283 if(lc($method) eq 'local') {
498 20 50       181 if($agent) {
499 20         380 $mailer->send('sendmail',$agent);
500             }
501             else {
502 0           $mailer->send('sendmail');
503             }
504             }
505             else {
506 0   0       $mailer->send('smtp',$agent || 'localhost');
507             }
508             }
509              
510             ###############################################################################
511              
512             sub get_to ($%) {
513 0     0 0   return '';
514             }
515              
516             ###############################################################################
517              
518             sub get_from ($%) {
519 0     0 0   return '';
520             }
521              
522             ###############################################################################
523              
524             sub get_subject ($%) {
525 0     0 0   return '';
526             }
527              
528             ###############################################################################
529             1;
530             __END__