Mail

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 => ... });