Punk::Mailer is outbound mail for Perl - a hashref message, a MIME builder that streams attachments from disk, and the transports that carry the result - and Punk::Plugin::Mailer wires it into an application:
package MyApp;
use Punk;
host 'https://example.com';
plugin 'Mailer' => {
transport => 'smtp',
from => 'Example <ops@example.com>',
mail_dir => 'root/mail',
smtp => { host => 'mail.example.com',
username => 'ops@example.com', password => secret('smtp_password') },
};
post '/contact' => sub {
my ($c) = @_;
my $r = $c->mail(to => 'help@example.com', subject => 'Contact form',
text => $c->param('message'));
return $c->text($r->accepted ? 'sent' : 'not sent: ' . $r->message);
};
Every option of every layer is checked when plugin runs - a typo, a
missing credential, a password over plaintext - so a misconfigured
mailer stops the boot rather than the 3am signup.
The message
$c->mail(
to => [ 'Alice <a@example.com>', 'b@example.com' ],
cc => ..., bcc => ..., reply_to => ...,
subject => 'Your invoice',
text => "Attached.\n",
html => "<p>Attached.</p>",
attachments => [
{ path => '/var/store/inv-42.pdf', filename => 'invoice.pdf', type => 'application/pdf' },
{ content => $csv, filename => 'report.csv', type => 'text/csv' },
$c->upload('file'), # a Punk::Upload, as it is
],
);
Text and HTML become a multipart/alternative, attachments a
multipart/mixed around it, and the result is 7-bit clean whatever was
put in. A path attachment is read in chunks as the message is built -
a 100MB file costs a small buffer, not 100MB. An unknown key croaks.
Two rules hold everywhere: a header value with a line break in it is
refused, so a form field cannot become a second header; and a display
name or subject outside ASCII is encoded so that it survives. bcc
goes to the envelope and never to a header.
Templates
plugin 'Mailer' => { ..., mail_dir => 'root/mail', layout => 'layout' };
$c->mail(to => $user->{email}, subject => 'Welcome',
template => 'welcome', data => { name => $user->{name} });
root/mail/welcome.txt.tmpl and welcome.html.tmpl are
Stencil templates; the message gets whichever
exist. The HTML side renders with escaping on and the text side with it
off; layout.txt.tmpl / layout.html.tmpl wrap each with the part as
body. The data is yours plus base, subject, to and locale -
the language tag when I18n is registered.
Translated strings are the handler's to put in data, as they are for
a page.
The result
Delivery never dies. $c->mail returns a
Punk::Mailer::Result:
| status | meaning | retryable |
|---|---|---|
accepted |
the other side took the message - SMTP 250, a provider's 2xx, sendmail exit 0 |
no |
deferred |
temporarily refused - SMTP 4xx, a provider's 429 or 5xx |
yes |
rejected |
refused for good - SMTP 5xx, a provider's other 4xx |
no |
failed |
no verdict - connection refused or lost, TLS failed, command not run | yes |
unsent |
the log transport: recorded, not delivered | no |
code, enhanced, message and id carry the facts behind it, and
recipients the per-address verdicts. There is no boolean overload:
unless ($r) means "no result", never "rejected" - ask accepted.
Sending later
use Punk::Plugin::Queue;
plugin 'Queue' => { dsn => 'dbi:Pg:dbname=myapp' }; # first
plugin 'Mailer' => { ..., later => { queue => 'mail', attempts => 5 } };
my $job = $c->mail_later(to => ..., subject => ..., template => 'receipt');
later hands the message to the queue. It is
rendered now - the user, the language and the host live in the
request, and a job has none of them - and an attachment that is a
Punk::Upload is made durable first, since its temp file is gone when
the request ends: stored by contents with
Blob registered, otherwise read into the job up
to later_inline_max. The task body maps the result onto the job: an
accepted Result finishes it; deferred and failed die so the queue
retries; rejected notes final and dies, because no retry will change
a 5xx.
Tokens by mail
my ($r, $link) = $c->mail_token($user,
kind => 'verify', subject => 'Verify your address', template => 'verify');
One line for the verify, reset and invite mails: issues a single-use
token through auth's issue_token, builds the link
on base, renders the template with link, token and user in its
data, and sends. The link comes back too, so a development page can
show it when no mail is configured. Redeem it with $c->take_token.
Links are built on base, or the host keyword -
never the request's Host header, which is whatever the client sent.
Transports
capture |
tests and development: every message kept on messages, optionally written as .eml files; a scripted result for the error branch |
log |
the honest fallback: the message to STDERR and a Result of unsent |
sendmail |
a local MTA's command line, run without a shell; -f and the envelope recipients appended |
resend |
Resend's HTTP API; an attachment over max_attachment is refused locally |
smtp |
STARTTLS on 587, implicit TLS on 465, or plaintext; AUTH PLAIN and LOGIN; SIZE; streamed and dot-stuffed |
The SMTP client asks for STARTTLS only after the greeting, upgrades
only on a 220, and sends EHLO again afterwards - the capabilities a
server announced in plaintext are not trusted. A password over
plaintext croaks at new unless insecure_auth => 1 says so in
writing. TLS comes from Fetch's
client configuration, so nothing here links OpenSSL.
Outside a request - a queue job, a script - the engine is a plain object:
my $mailer = Punk::Plugin::Mailer->engine_for('MyApp');
my $result = $mailer->send({ to => ..., subject => ..., text => ... });