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