NAME

Punk::Mailer - outbound mail: messages, MIME, and the transports that carry them

VERSION

Version 0.01

SYNOPSIS

use Punk::Mailer;

my $bytes = Punk::Mailer->build({
    from    => 'Example <ops@example.com>',
    to      => [ 'alice@example.com', 'Bob <bob@example.com>' ],
    subject => 'Your invoice',
    text    => "Attached.\n",
    html    => "<p>Attached.</p>",
    attachments => [
        { path => '/var/store/inv-42.pdf', filename => 'invoice.pdf',
          type => 'application/pdf' },
    ],
});

DESCRIPTION

A message is a plain hashref, and build turns it into RFC 5322 bytes: the headers, a multipart/alternative when there is both text and HTML, a multipart/mixed around that when there are attachments, each part encoded as its content requires. The result is 7-bit clean whatever was put in, so the same bytes can go to any server.

The rules that matter are enforced here and nowhere else: a header value may not contain a line break, so a caller cannot inject one; an address must be an address; a display name or subject with characters outside ASCII is encoded so that it survives; and an attachment named by path is read in chunks and never held in memory.

Sending is Punk::Mailer::Transport territory; Punk::Plugin::Mailer wires both into a Punk application.

THE MESSAGE

{
    from     => 'Name <addr>',                  # required; exactly one
    to       => 'addr' | 'Name <addr>' | [ ... ],
    cc       => ...,                            # at least one of to, cc, bcc
    bcc      => ...,                            # envelope only; never a header
    reply_to => ...,
    subject  => 'any text',                     # required
    text     => "a plain body",                 # text and/or html
    html     => "<p>an HTML body</p>",
    headers  => { 'List-Unsubscribe' => '<...>' },
    attachments => [
        { path => '/file', filename => 'name.pdf', type => 'application/pdf' },
        { content => $bytes, filename => 'name.csv', type => 'text/csv' },
        $upload,        # anything with path, filename and type methods
    ],
    message_id        => '<...>',     # generated when absent
    message_id_domain => 'example.com',   # the From domain when absent
    date              => $epoch,      # now when absent
}

A key not in that list is an error: a misspelt option that silently did nothing is worse than one that stops the program.

Addresses

addr, Name <addr> and "Quoted Name" <addr>, one or a list of them. The address itself must be ASCII with exactly one @ and something on each side of it; the display name may be anything and is encoded (RFC 2047) when it needs to be, or quoted when it contains characters that would otherwise be read as structure.

Headers

Every header value is refused if it contains a carriage return, a line feed or a NUL. That is the rule that stops a form field from becoming a second header, and it applies to addresses, the subject, and the headers hash alike. Long headers fold at whitespace to 78 columns. Date, Message-ID, MIME-Version, the Content-* headers and the address headers are generated and may not be supplied through headers.

Encoding

A text part is sent as 7bit when it is ASCII with lines under 998 bytes, quoted-printable when it is mostly ASCII, and base64 otherwise. Attachments are always base64, in 76-column lines. The charset is always utf-8; a string with perl's UTF-8 flag off is treated as perl would treat it.

Attachments

path is read in chunks as the message is built; a 100MB attachment costs a small buffer, not 100MB. content is bytes already in memory. An object with path, filename and type methods - a Punk::Upload - is accepted directly. filename is required and is only ever a header value: nothing here opens it.

SENDING

my $mailer = Punk::Mailer->new(
    transport => 'smtp',                   # smtp, resend, sendmail, capture, log
    from      => 'Example <ops@example.com>',
    reply_to  => 'help@example.com',
    message_id_domain => 'example.com',
    smtp      => { host => 'mail.example.com', username => ..., password => ... },
);

my $result = $mailer->send(\%message);
$result->accepted or warn $result->message;

new(%options)

Builds the transport and checks every option of every layer, so a typo, a missing credential or a command given as a string is a croak here rather than a surprise at the first send. Options:

transport

Required. One of smtp, resend, sendmail, capture, log, or a class name containing :: - see Punk::Mailer::Transport.

from, reply_to, message_id_domain

Defaults for messages that leave them out. from and reply_to must be addresses.

smtp, resend, sendmail, capture, log

A hashref of options for the transport of that name; options for a class. An option the transport does not know croaks.

send(\%message)

Fills in the defaults, validates the message - a malformed one croaks, because it is a programming error - and hands it to the transport. Returns a Punk::Mailer::Result, whatever happened on the wire: delivery never throws.

transport

The transport object new built.

transport_name

The name it was asked for by.

from

reply_to

message_id_domain

The defaults new was given, or undef.

METHODS

build(\%message)

The message as bytes: headers, a blank line, the body, CRLF throughout.

build_to(\%message, sub { ... })

The same bytes handed to a callback in chunks as they are produced, for a caller that wants to stream them somewhere rather than hold them.

envelope(\%message)

{ from => 'ops@example.com', to => [ 'alice@example.com', ... ] }

The bare addresses a transport needs: the sender, and every recipient across to, cc and bcc, in that order, each once.

SEE ALSO

Punk::Plugin::Mailer, Punk.

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under:

The Artistic License 2.0 (GPL Compatible)