File Coverage

blib/lib/Courriel/Builder.pm
Criterion Covered Total %
statement 46 48 95.8
branch n/a
condition n/a
subroutine 16 16 100.0
pod n/a
total 62 64 96.8


line stmt bran cond sub pod time code
1             package Courriel::Builder;
2              
3 4     4   313025 use strict;
  4         9  
  4         154  
4 4     4   22 use warnings;
  4         6  
  4         296  
5              
6             our $VERSION = '0.44';
7              
8 4     4   23 use Carp qw( croak );
  4         8  
  4         315  
9 4     4   1820 use Courriel;
  4         10  
  4         167  
10 4     4   30 use Courriel::Header::ContentType;
  4         6  
  4         102  
11 4     4   19 use Courriel::Header::Disposition;
  4         7  
  4         91  
12 4     4   16 use Courriel::Headers;
  4         6  
  4         91  
13 4     4   18 use Courriel::Helpers qw( parse_header_with_attributes );
  4         6  
  4         274  
14 4     4   21 use Courriel::Part::Multipart;
  4         7  
  4         87  
15 4     4   20 use Courriel::Part::Single;
  4         5  
  4         104  
16 4     4   16 use Courriel::Types qw( EmailAddressStr HashRef NonEmptyStr Str StringRef );
  4         8  
  4         36  
17 4     4   33431 use DateTime;
  4         8  
  4         95  
18 4     4   19 use DateTime::Format::Mail;
  4         6  
  4         100  
19 4     4   2935 use Devel::PartialDump;
  4         26134  
  4         19  
20 4     4   694 use File::Basename qw( basename );
  4         7  
  4         339  
21 4     4   8529 use File::LibMagic;
  0            
  0            
22             use File::Slurp::Tiny qw( read_file );
23             use List::AllUtils qw( first );
24             use Params::ValidationCompiler qw( validation_for );
25             use Scalar::Util qw( blessed reftype );
26              
27             our @CARP_NOT = __PACKAGE__;
28              
29             my @exports;
30              
31             BEGIN {
32             @exports = qw(
33             build_email
34             subject
35             from
36             to
37             cc
38             header
39             plain_body
40             html_body
41             attach
42             );
43             }
44              
45             use Sub::Exporter -setup => {
46             exports => \@exports,
47             groups => { default => \@exports },
48             };
49              
50             {
51             my $validator = validation_for(
52             params => [ { type => HashRef } ],
53             slurpy => HashRef,
54             );
55              
56             sub build_email {
57             my @p = $validator->(@_);
58              
59             my @headers;
60             my $plain_body;
61             my $html_body;
62             my @attachments;
63              
64             for my $p (@p) {
65             ## no critic (ControlStructures::ProhibitCascadingIfElse)
66             if ( $p->{header} ) {
67             push @headers, @{ $p->{header} };
68             }
69             elsif ( $p->{plain_body} ) {
70             $plain_body = $p->{plain_body};
71             }
72             elsif ( $p->{html_body} ) {
73             $html_body = $p->{html_body};
74             }
75             elsif ( $p->{attachment} ) {
76             push @attachments, $p->{attachment};
77             }
78             else {
79             _bad_value($p);
80             }
81             }
82              
83             my $body_part;
84             if ( $plain_body && $html_body ) {
85             my $ct = Courriel::Header::ContentType->new(
86             mime_type => 'multipart/alternative',
87             );
88              
89             $body_part = Courriel::Part::Multipart->new(
90             headers => Courriel::Headers->new,
91             content_type => $ct,
92             parts => [ $plain_body, $html_body ],
93             );
94             }
95             else {
96             $body_part = first {defined} $plain_body, $html_body;
97              
98             croak 'Cannot call build_email without a plain or html body'
99             unless $body_part;
100             }
101              
102             if (@attachments) {
103             my $ct = Courriel::Header::ContentType->new(
104             mime_type => 'multipart/mixed' );
105              
106             $body_part = Courriel::Part::Multipart->new(
107             headers => Courriel::Headers->new,
108             content_type => $ct,
109             parts => [
110             $body_part,
111             @attachments,
112             ],
113             );
114             }
115              
116             _add_required_headers( \@headers );
117              
118             # XXX - a little incestuous but I don't really want to make this method
119             # public, and delaying building the body part would make all the code more
120             # complicated than it needs to be.
121             $body_part->_set_headers(
122             Courriel::Headers->new( headers => [@headers] ) );
123              
124             return Courriel->new( part => $body_part );
125             }
126             }
127              
128             sub _bad_value {
129             croak 'A weird value was passed to build_email: '
130             . Devel::PartialDump->new->dump( $_[0] );
131             }
132              
133             sub _add_required_headers {
134             my $headers = shift;
135              
136             my %keys = map {lc} @{$headers};
137              
138             unless ( $keys{date} ) {
139             push @{$headers},
140             ( Date =>
141             DateTime::Format::Mail->format_datetime( DateTime->now ) );
142             }
143              
144             unless ( $keys{'message-id'} ) {
145             push @{$headers},
146             ( 'Message-Id' => Email::MessageID->new->in_brackets );
147             }
148              
149             unless ( $keys{'mime-version'} ) {
150             push @{$headers}, ( 'MIME-Version' => '1.0' );
151             }
152              
153             return;
154             }
155              
156             {
157             my $validator = validation_for(
158             params => [ { type => Str } ],
159             );
160              
161             sub subject {
162             my ($subject) = $validator->(@_);
163              
164             return { header => [ Subject => $subject ] };
165             }
166             }
167              
168             {
169             my $validator = validation_for(
170             params => [ { type => EmailAddressStr } ],
171             );
172              
173             sub from {
174             my ($from) = $validator->(@_);
175              
176             if ( blessed $from ) {
177             $from = $from->format;
178             }
179              
180             return { header => [ From => $from ] };
181             }
182             }
183              
184             {
185             my $validator = validation_for(
186             params => [ { type => EmailAddressStr } ],
187             slurpy => EmailAddressStr,
188             );
189              
190             sub to {
191             my (@to) = $validator->(@_);
192              
193             @to = map { blessed($_) ? $_->format : $_ } @to;
194              
195             return { header => [ To => join ', ', @to ] };
196             }
197             }
198              
199             {
200             my $validator = validation_for(
201             params => [ { type => EmailAddressStr } ],
202             slurpy => EmailAddressStr,
203             );
204              
205             sub cc {
206             my (@cc) = $validator->(@_);
207              
208             @cc = map { blessed($_) ? $_->format : $_ } @cc;
209              
210             return { header => [ Cc => join ', ', @cc ] };
211             }
212             }
213              
214             {
215             my $validator = validation_for(
216             params => [
217             { type => NonEmptyStr },
218             { type => Str },
219             ],
220             );
221              
222             sub header {
223             my ( $name, $value ) = $validator->(@_);
224              
225             return { header => [ $name => $value ] };
226             }
227             }
228              
229             sub plain_body {
230             my %p
231             = @_ == 1
232             ? ( content => shift )
233             : @_;
234              
235             return {
236             plain_body => _body_part(
237             %p,
238             mime_type => 'text/plain',
239             )
240             };
241             }
242              
243             sub html_body {
244             my @attachments;
245              
246             for my $i ( reverse 0 .. $#_ ) {
247             if ( ref $_[$i]
248             && reftype( $_[$i] ) eq 'HASH'
249             && $_[$i]->{attachment} ) {
250              
251             push @attachments, splice @_, $i, 1;
252             }
253             }
254              
255             my %p
256             = @_ == 1
257             ? ( content => shift )
258             : @_;
259              
260             my $body = _body_part(
261             %p,
262             mime_type => 'text/html',
263             );
264              
265             if (@attachments) {
266             $body = Courriel::Part::Multipart->new(
267             headers => Courriel::Headers->new,
268             content_type => Courriel::Header::ContentType->new(
269             mime_type => 'multipart/related'
270             ),
271             parts => [
272             $body,
273             map { $_->{attachment} } @attachments,
274             ],
275             );
276             }
277              
278             return { html_body => $body };
279             }
280              
281             {
282             my $validator = validation_for(
283             params => [
284             mime_type => { type => NonEmptyStr },
285             charset => {
286             type => NonEmptyStr,
287             default => 'UTF-8',
288             },
289             encoding => {
290             type => NonEmptyStr,
291             default => 'base64',
292             },
293             content => {
294             type => StringRef,
295             },
296             ],
297             named_to_list => 1,
298             );
299              
300             sub _body_part {
301             my ( $mime_type, $charset, $encoding, $content ) = $validator->(@_);
302              
303             my $ct = Courriel::Header::ContentType->new(
304             mime_type => $mime_type,
305             attributes => { charset => $charset },
306             );
307              
308             my $body = Courriel::Part::Single->new(
309             headers => Courriel::Headers->new,
310             content_type => $ct,
311             encoding => $encoding,
312             content => $content,
313             );
314              
315             return $body;
316             }
317             }
318              
319             sub attach {
320             my %p
321             = @_ == 1
322             ? ( file => shift )
323             : @_;
324              
325             return {
326             attachment => $p{file} ? _part_for_file(%p) : _part_for_content(%p)
327             };
328             }
329              
330             my $flm = File::LibMagic->new;
331              
332             {
333             my $validator = validation_for(
334             params => [
335             file => { type => NonEmptyStr },
336             mime_type => { type => NonEmptyStr, optional => 1 },
337             filename => { type => NonEmptyStr, optional => 1 },
338             content_id => { type => NonEmptyStr, optional => 1 },
339             ],
340             named_to_list => 1,
341             );
342              
343             sub _part_for_file {
344             my ( $file, $mime_type, $filename, $content_id ) = $validator->(@_);
345              
346             my $ct
347             = _content_type( $mime_type // $flm->checktype_filename($file) );
348              
349             my $content = read_file($file);
350              
351             return Courriel::Part::Single->new(
352             headers => _attachment_headers($content_id),
353             content_type => $ct,
354             disposition => _attachment_disposition( $filename // $file ),
355             encoding => 'base64',
356             content => \$content,
357             );
358             }
359             }
360              
361             {
362             my $validator = validation_for(
363             params => [
364             content => { type => StringRef },
365             mime_type => { type => NonEmptyStr, optional => 1 },
366             filename => { type => NonEmptyStr, optional => 1 },
367             content_id => { type => NonEmptyStr, optional => 1 },
368             ],
369             named_to_list => 1,
370             );
371              
372             sub _part_for_content {
373             my ( $content, $mime_type, $filename, $content_id )
374             = $validator->(@_);
375              
376             my $ct = _content_type( $mime_type
377             // $flm->checktype_contents( ${$content} ) );
378              
379             my $disp = Courriel::Header::Disposition->new(
380             disposition => 'attachment',
381             attributes => {
382             defined $filename ? ( filename => basename($filename) ) : ()
383             }
384             );
385              
386             return Courriel::Part::Single->new(
387             headers => _attachment_headers($content_id),
388             content_type => $ct,
389             disposition => _attachment_disposition($filename),
390             encoding => 'base64',
391             content => $content,
392             );
393             }
394             }
395              
396             sub _content_type {
397             my $type = shift;
398              
399             return Courriel::Header::ContentType->new(
400             mime_type => 'application/unknown' )
401             unless defined $type;
402              
403             my ( $mime_type, $attr ) = parse_header_with_attributes($type);
404              
405             return Courriel::Header::ContentType->new(
406             mime_type => 'application/unknown' )
407             unless defined $mime_type && length $mime_type;
408              
409             return Courriel::Header::ContentType->new(
410             mime_type => $mime_type,
411             attributes => $attr,
412             );
413             }
414              
415             sub _attachment_headers {
416             my $content_id = shift;
417              
418             my @headers;
419              
420             if ( defined $content_id ) {
421             $content_id = "<$content_id>"
422             unless $content_id =~ /^<[^>]+>$/;
423              
424             push @headers, ( 'Content-ID' => $content_id );
425             }
426              
427             return Courriel::Headers->new( headers => \@headers );
428             }
429              
430             sub _attachment_disposition {
431             my $file = shift;
432              
433             return Courriel::Header::Disposition->new(
434             disposition => 'attachment',
435             attributes => { defined $file ? ( filename => basename($file) ) : () }
436             );
437             }
438              
439             1;
440              
441             # ABSTRACT: Build emails with sugar
442              
443             __END__
444              
445             =pod
446              
447             =encoding UTF-8
448              
449             =head1 NAME
450              
451             Courriel::Builder - Build emails with sugar
452              
453             =head1 VERSION
454              
455             version 0.44
456              
457             =head1 SYNOPSIS
458              
459             use Courriel::Builder;
460              
461             my $email = build_email(
462             subject('An email for you'),
463             from('joe@example.com'),
464             to( 'jane@example.com', 'alice@example.com' ),
465             header( 'X-Generator' => 'MyApp' ),
466             plain_body($plain_text),
467             html_body(
468             $html,
469             attach('path/to/image.jpg'),
470             attach('path/to/other-image.jpg'),
471             ),
472             attach('path/to/spreadsheet.xls'),
473             attach( content => $file_content ),
474             );
475              
476             =head1 DESCRIPTION
477              
478             This module provides some sugar syntax for emails of all shapes sizes, from
479             simple emails with a plain text body to emails with both plain and html
480             bodies, html with attached images, etc.
481              
482             =head1 API
483              
484             This module exports all of the following functions by default. It uses
485             L<Sub::Exporter> under the hood, which means you can easily import the
486             functions with different names. See L<Sub::Exporter> for details.
487              
488             =head2 build_email( ... )
489              
490             This function returns a new L<Courriel> object. It takes the results of all
491             the other functions you call as input.
492              
493             It expects you to pass in a body of some sort, whether text, html, or both,
494             and will throw an error if you don't.
495              
496             It will add Date and Message-ID headers to your email if you don't provide
497             them, ensuring that the email is RFC-compliant.
498              
499             =head2 subject($subject)
500              
501             This sets the subject of the email. It expects a single string. You can pass
502             an empty string, but not C<undef>.
503              
504             =head2 from($from)
505              
506             This sets the From header of the email. It expects a single string or
507             L<Email::Address> object.
508              
509             =head2 to($from)
510              
511             This sets the To header of the email. It expects a list of string and/or
512             L<Email::Address> objects.
513              
514             =head2 cc($from)
515              
516             This sets the Cc header of the email. It expects a list of string and/or
517             L<Email::Address> objects.
518              
519             =head2 header( $name => $value )
520              
521             This sets a header's value. You can call it as many times as you want, and you
522             can call it more than once with the same header name to set multiple values
523             for that header.
524              
525             =head2 plain_body( ... )
526              
527             This defines a plain text body for the email. You can call it with a single
528             argument, a scalar or reference to a scalar. This creates a text/plain part
529             based on the content you provide in that argument. By default, the charset for
530             the body is UTF-8 and the encoding is base64.
531              
532             You can also call this function with a hash of options. It accepts the
533             following options:
534              
535             =over 4
536              
537             =item * content
538              
539             The content of the body. This can be a string or scalar reference.
540              
541             =item * charset
542              
543             The charset for the body. This defaults to UTF-8.
544              
545             =item * encoding
546              
547             The encoding for the body. This defaults to base64. Other valid values are
548             quoted-printable, 7bit, and 8bit.
549              
550             It is strongly recommended that you let Courriel handle the transfer encoding
551             for you.
552              
553             =back
554              
555             =head2 html_body( ... )
556              
557             This accepts the same arguments as the C<plain_body()> function.
558              
559             You can I<also> pass in the results of one or more calls to the C<attach()>
560             function. If you pass in attachments, it creates a multipart/related email
561             part, which lets you refer to images by the Content-ID using the "cid:" URL
562             scheme.
563              
564             =head2 attach( ... )
565              
566             This function creates an attachment for the email. In the simplest form, you
567             can pass it a single argument, which should be a path to a file on disk. This
568             file will be attached to the email.
569              
570             You can also pass a hash of options. The valid keys are:
571              
572             =over 4
573              
574             =item * file
575              
576             The file to attach to the email. You can also pass the content explicitly.
577              
578             =item * content
579              
580             The content of the attachment. This can be a string or scalar reference.
581              
582             =item * filename
583              
584             You can set the filename that will be used in the attachment's
585             Content-Disposition header. If you pass a C<file> parameter, that will be used
586             when this isn't provided. If you pass as C<content> parameter, then there will
587             be no filename set for the attachment unless you pass a C<filename> parameter
588             as well.
589              
590             =item * mime_type
591              
592             You can explicitly set the mime type for the attachment. If you don't, this
593             function will use L<File::LibMagic> to try to figure out the mime type for the
594             attachment.
595              
596             =item * content_id
597              
598             This will set the Content-ID header for the attachment. If you're creating a
599             HTML body with "cid:" scheme URLs, you'll need to set this for each attachment
600             that the HTML body refers to.
601              
602             The id will be wrapped in angle brackets ("<id-goes-here>") when set as a
603             header.
604              
605             =back
606              
607             =head1 COOKBOOK
608              
609             Some examples of how to build different types of emails.
610              
611             =head2 Simple Email With Plain Text Body
612              
613             my $email = build_email(
614             subject('An email for you'),
615             from('joe@example.com'),
616             to( 'jane@example.com', 'alice@example.com' ),
617             plain_body($plain_text),
618             );
619              
620             This creates an email with a single text/plain part.
621              
622             =head2 Simple Email With HTML Body
623              
624             my $email = build_email(
625             subject('An email for you'),
626             from('joe@example.com'),
627             to( 'jane@example.com', 'alice@example.com' ),
628             html_body($html_text),
629             );
630              
631             This creates an email with a single text/html part.
632              
633             =head2 Email With Both Plain and HTML Bodies
634              
635             my $email = build_email(
636             subject('An email for you'),
637             from('joe@example.com'),
638             to( 'jane@example.com', 'alice@example.com' ),
639             plain_body($plain_text),
640             html_body($html_text),
641             );
642              
643             This creates an email with this structure:
644              
645             multipart/alternative
646             |
647             |-- text/plain (disposition = inline)
648             |-- text/html (disposition = inline)
649              
650             =head2 Email With Both Plain and HTML Bodies and Inline Images
651              
652             my $email = build_email(
653             subject('An email for you'),
654             from('joe@example.com'),
655             to( 'jane@example.com', 'alice@example.com' ),
656             plain_body($plain_text),
657             html_body(
658             $html_text,
659             attach(
660             file => 'path/to/image1.jpg',
661             content_id => 'image1',
662             ),
663             attach(
664             file => 'path/to/image2.jpg',
665             content_id => 'image2',
666             ),
667             ),
668             );
669              
670             This creates an email with this structure:
671              
672             multipart/alternative
673             |
674             |-- text/plain (disposition = inline)
675             |-- multipart/related
676             |
677             |-- text/html (disposition = inline)
678             |-- image/jpeg (disposition = attachment, Content-ID = image1)
679             |-- image/jpeg (disposition = attachment, Content-ID = image2)
680              
681             =head2 Email With Both Plain and HTML Bodies and Attachments
682              
683             my $email = build_email(
684             subject('An email for you'),
685             from('joe@example.com'),
686             to( 'jane@example.com', 'alice@example.com' ),
687             plain_body($plain_text),
688             html_body(
689             $html_text,
690             ),
691             attach('path/to/spreadsheet.xls'),
692             attach( content => \$png_image_content ),
693             );
694              
695             This creates an email with this structure:
696              
697             multipart/mixed
698             |
699             |-- multipart/alternative
700             | |
701             | |-- text/plain (disposition = inline)
702             | |-- text/html (disposition = inline)
703             |
704             |-- application/vnd.ms-excel (disposition = attachment)
705             |-- image/png (disposition = attachment)
706              
707             =head1 SUPPORT
708              
709             Bugs may be submitted through L<the RT bug tracker|http://rt.cpan.org/Public/Dist/Display.html?Name=Courriel>
710             (or L<bug-courriel@rt.cpan.org|mailto:bug-courriel@rt.cpan.org>).
711              
712             I am also usually active on IRC as 'autarch' on C<irc://irc.perl.org>.
713              
714             =head1 AUTHOR
715              
716             Dave Rolsky <autarch@urth.org>
717              
718             =head1 COPYRIGHT AND LICENSE
719              
720             This software is Copyright (c) 2016 by Dave Rolsky.
721              
722             This is free software, licensed under:
723              
724             The Artistic License 2.0 (GPL Compatible)
725              
726             =cut