File Coverage

blib/lib/Email/Sender/Transport/Mailgun.pm
Criterion Covered Total %
statement 65 66 98.4
branch 13 14 92.8
condition 6 6 100.0
subroutine 16 17 94.1
pod 0 3 0.0
total 100 106 94.3


line stmt bran cond sub pod time code
1             package Email::Sender::Transport::Mailgun;
2             our $VERSION = "0.05";
3              
4 7     7   2096918 use Moo;
  7         56846  
  7         44  
5             with 'Email::Sender::Transport';
6              
7 7     7   17649 use HTTP::Tiny qw( );
  7         463300  
  7         340  
8 7     7   4194 use HTTP::Tiny::Multipart qw( );
  7         18306  
  7         214  
9 7     7   3498 use JSON::MaybeXS qw( );
  7         84919  
  7         371  
10 7     7   3980 use MooX::Types::MooseLike::Base qw( ArrayRef Enum Str);
  7         65536  
  7         1019  
11              
12             {
13             package
14             Email::Sender::Success::MailgunSuccess;
15 7     7   65 use Moo;
  7         21  
  7         57  
16             extends 'Email::Sender::Success';
17             has id => (
18             is => 'ro',
19             required => 1,
20             );
21 7     7   3591 no Moo;
  7         17  
  7         35  
22             }
23              
24             has [qw( api_key domain )] => (
25             is => 'ro',
26             required => 1,
27             isa => Str,
28             );
29              
30             has [qw( tag )] => (
31             is => 'ro',
32             predicate => 1,
33             isa => ArrayRef[Str],
34             coerce => sub { ref $_[0] ? $_[0] : [ split(/,\s*/, $_[0]) ] },
35             );
36              
37             has deliverytime => (
38             is => 'ro',
39             predicate => 1,
40             isa => Str,
41             coerce => sub {
42             ref $_[0] eq 'DateTime'
43             ? $_[0]->strftime('%a, %d %b %Y %H:%M:%S %z') : $_[0]
44             },
45             );
46              
47             has [qw( dkim testmode tracking tracking_opens )] => (
48             is => 'ro',
49             predicate => 1,
50             isa => Enum[qw( yes no )],
51             );
52              
53             has tracking_clicks => (
54             is => 'ro',
55             predicate => 1,
56             isa => Enum[qw( yes no htmlonly )],
57             );
58              
59             has region => (
60             is => 'ro',
61             predicate => 1,
62             isa => Enum[qw( us eu )],
63             );
64              
65             has retry_count => (
66             is => 'lazy',
67 1     1   276089 builder => sub { 3 }, # set to 0 to disable retries
68             );
69              
70             has retry_delay_seconds => (
71             is => 'lazy',
72 0     0   0 builder => sub { 1 },
73             );
74              
75             has base_uri => (
76             is => 'lazy',
77 6     6   148 builder => sub { 'https://api.mailgun.net/v3' },
78             );
79              
80             has uri => (
81             is => 'lazy',
82             );
83              
84             has ua => (
85             is => 'lazy',
86 3     3   54 builder => sub { HTTP::Tiny->new(verify_SSL => 1) },
87             );
88              
89             has json => (
90             is => 'lazy',
91 3     3   64 builder => sub { JSON::MaybeXS->new },
92             );
93              
94             # https://documentation.mailgun.com/en/latest/api-sending.html#sending
95             sub send_email {
96 10     10 0 222279 my ($self, $email, $env) = @_;
97              
98             my $content = {
99 10         72 to => ref $env->{to} ? join(',', @{ $env->{to} }) : $env->{to},
100 10 50       48 message => {
101             filename => 'message.mime',
102             content => $email->as_string,
103             },
104             };
105              
106 10         1059 my @options = qw(
107             deliverytime dkim tag testmode tracking tracking_clicks tracking_opens
108             );
109              
110 10         35 for my $option (@options) {
111 70         100 my $has_option = "has_$option";
112 70 100       212 if ($self->$has_option) {
113 3         6 my $key = "o:$option";
114 3         10 $key =~ tr/_/-/;
115 3         15 $content->{$key} = $self->$option;
116             }
117             }
118              
119 10         296 my $uri = $self->uri . '/messages.mime';
120              
121             # If we get a 5xx error, retry a few times.
122             # Been seeing "Socket closed by remote server: Broken pipe" errors (EPIPE).
123 10         64 my $retries = 0;
124              
125 10         30 my $response;
126 10         15 while (1) {
127 22         3898 $response = $self->ua->post_multipart($uri, $content);
128 22 100       4184 last if $response->{status} !~ /^5/;
129 14 100       216 last if $retries++ >= $self->retry_count;
130 12         183 sleep $self->retry_delay_seconds;
131             }
132              
133             $self->failure($response, $env->{to})
134 10 100       61 unless $response->{success};
135              
136 5         18 return $self->success($response);
137             }
138              
139             sub success {
140 5     5 0 10 my ($self, $response) = @_;
141              
142 5         122 my $content = $self->json->decode($response->{content});
143 5         159 return Email::Sender::Success::MailgunSuccess->new(id => $content->{id});
144             }
145              
146             sub failure {
147 5     5 0 14 my ($self, $response, $recipients) = @_;
148              
149             # Most errors have { message => $message } in the content, some, such as
150             # an auth error, have just a plain string.
151 5         11 my $content = eval { $self->json->decode($response->{content}) };
  5         129  
152             my $message = $content && $content->{message}
153 5 100 100     141 ? $content->{message} : $response->{content};
154              
155 5         73 Email::Sender::Failure->throw({
156             message => $message,
157             recipients => $recipients,
158             });
159             }
160              
161             sub _build_uri {
162 8     8   562568 my $self = shift;
163              
164 8         194 my ($proto, $rest) = split('://', $self->base_uri);
165 8         74 my $domain = $self->domain;
166              
167             # Percent-escape anything other than alphanumeric and - _ . ~
168             # https://github.com/sdt/Email-Sender-Transport-Mailgun/issues/4
169 8         40 my $api_key = $self->api_key;
170 8         38 $api_key =~ s/[^-_.~0-9a-zA-Z]/sprintf('%%%02x',ord($&))/eg;
  5         23  
171              
172             # adapt endpoint based on region setting.
173 8 100 100     67 $rest =~ s/(\.mailgun)/sprintf('.%s%s', $self->region, $1)/e
  1         9  
174             if defined $self->region && $self->region ne 'us';
175              
176 8         55 return "$proto://api:$api_key\@$rest/$domain";
177             }
178              
179 7     7   10602 no Moo;
  7         24  
  7         45  
180             1;
181             __END__
182              
183             =encoding utf-8
184              
185             =for stopwords deliverytime dkim hardcode mailouts prepend templated testmode
186              
187             =head1 NAME
188              
189             Email::Sender::Transport::Mailgun - Mailgun transport for Email::Sender
190              
191             =head1 SYNOPSIS
192              
193             use Email::Sender::Simple qw( sendmail );
194             use Email::Sender::Transport::Mailgun qw( );
195              
196             my $transport = Email::Sender::Transport::Mailgun->new(
197             api_key => '...',
198             domain => '...',
199             );
200              
201             my $message = ...;
202              
203             sendmail($message, { transport => $transport });
204              
205             =head1 DESCRIPTION
206              
207             This transport delivers mail via Mailgun's messages.mime API.
208              
209             =head2 Why use this module?
210              
211             The SMTP transport can also be used to send messages through Mailgun. In this
212             case, Mailgun options must be specified with Mailgun-specific MIME headers.
213              
214             This module exposes those options as attributes, which can be set in code, or
215             via C<EMAIL_SENDER_TRANSPORT_> environment variables.
216              
217             =head2 Why not use this module?
218              
219             This module uses Mailgun's messages.mime API, not the full-blown messages API.
220              
221             If you want to use advanced Mailgun features such as templated batch mailouts
222             or mailing lists, you're better off using something like L<WebService::Mailgun>
223             or L<WWW::Mailgun>.
224              
225             =head1 REQUIRED ATTRIBUTES
226              
227             The attributes all correspond directly to Mailgun parameters.
228              
229             =head2 api_key
230              
231             Mailgun API key. See L<https://documentation.mailgun.com/en/latest/api-intro.html#authentication-1>
232              
233             =head2 domain
234              
235             Mailgun domain. See L<https://documentation.mailgun.com/en/latest/api-intro.html#base-url-1>
236              
237             =head1 OPTIONAL ATTRIBUTES
238              
239             These (except region) correspond to the C<o:> options in the C<messages.mime>
240             section of L<https://documentation.mailgun.com/en/latest/api-sending.html#sending>
241              
242             =head2 deliverytime
243              
244             Desired time of delivery. String or DateTime object.
245              
246             =head2 dkim
247              
248             Enables/disables DKIM signatures. C<'yes'> or C<'no'>.
249              
250             =head2 region
251              
252             Defines used Mailgun region. C<'us'> (default) or C<'eu'>.
253              
254             See L<https://documentation.mailgun.com/en/latest/api-intro.html#mailgun-regions-1>
255              
256             =head2 retry_count
257              
258             =head2 retry_delay_seconds
259              
260             If the Mailgun API request fails with a 5xx response, the request will be retried C<retry_count> times, with a delay of C<retry_delay_seconds> between each attempt.
261              
262             Defaults to three retries with a one second delay.
263              
264             =head2 tag
265              
266             Tag string. Comma-separated string list or arrayref of strings.
267              
268             =head2 testmode
269              
270             Enables sending in test mode. C<'yes'> or C<'no'>.
271              
272             =head2 tracking
273              
274             Toggles tracking. C<'yes'> or C<'no'>.
275              
276             =head2 tracking_clicks
277              
278             Toggles clicks tracking. C<'yes'>, C<'no'> or C<'html_only'>.
279              
280             =head2 tracking_opens
281              
282             Toggles open tracking. C<'yes'> or C<'no'>.
283              
284             =head1 MIME HEADERS
285              
286             The C<o:> options above can also be specified using the C<X-Mailgun-> headers
287             listed here L<https://documentation.mailgun.com/en/latest/user_manual.html#sending-via-smtp>
288              
289             If a single-valued option is specified in both the options and the headers,
290             experimentation shows the header takes precedence. This doesn't seem to be
291             documented, so don't rely on this behaviour.
292              
293             Multi-valued options use both the options and the headers.
294              
295             =head1 ENVIRONMENT
296              
297             The great strength of Email::Sender is that you don't need to hardcode your
298             transport, nor any of the options relating to that transport. They can all be
299             specified via environment variables.
300              
301             To select the Mailgun transport, use C<EMAIL_SENDER_TRANSPORT=Mailgun>.
302              
303             To specify any of the attributes above, prepend the attribute name with
304             C<EMAIL_SENDER_TRANSPORT_>.
305              
306             =over
307              
308             =item EMAIL_SENDER_TRANSPORT_api_key
309              
310             =item EMAIL_SENDER_TRANSPORT_domain
311              
312             =item EMAIL_SENDER_TRANSPORT_deliverytime
313              
314             =item EMAIL_SENDER_TRANSPORT_dkim
315              
316             =item EMAIL_SENDER_TRANSPORT_region
317              
318             =item EMAIL_SENDER_TRANSPORT_retry_count
319              
320             =item EMAIL_SENDER_TRANSPORT_retry_delay_seconds
321              
322             =item EMAIL_SENDER_TRANSPORT_tag
323              
324             =item EMAIL_SENDER_TRANSPORT_testmode
325              
326             =item EMAIL_SENDER_TRANSPORT_tracking
327              
328             =item EMAIL_SENDER_TRANSPORT_tracking_clicks
329              
330             =item EMAIL_SENDER_TRANSPORT_tracking_opens
331              
332             =back
333              
334             =head1 LICENSE
335              
336             Copyright (C) Stephen Thirlwall.
337              
338             This library is free software; you can redistribute it and/or modify
339             it under the same terms as Perl itself.
340              
341             =head1 AUTHOR
342              
343             Stephen Thirlwall E<lt>sdt@cpan.orgE<gt>
344              
345             =cut