NAME

Mail::DKIM2::DSN - DKIM2-signed Delivery Status Notifications

SYNOPSIS

use Mail::DKIM2::DSN;

# A DSN for a message we are bouncing, signed with MAIL FROM <>.
my $out = Mail::DKIM2::DSN->generate(
    Message      => $inbound_bytes,
    Signer       => Mail::DKIM2::Signer->new(..., MailFrom => '<>'),
    ReportingMTA => 'mx.example.com',
    Status       => '5.7.1',
    Reason       => 'rejected by policy',
);
# $out->{raw}, $out->{send_to}

# Is this DSN about a message we forwarded, and genuine?
my $auth = Mail::DKIM2::DSN->authenticate(
    Message        => $dsn_bytes,
    PubkeyCallback => \&lookup,      # or omit to use DNS
);
# $auth->{ok}, $auth->{top} (our signature), $auth->{alignment}

# Send it on towards the original sender.
my $out = Mail::DKIM2::DSN->propagate(
    Message         => $dsn_bytes,
    ForwarderDomain => 'fwd.example',
    Signer          => $signer,       # MailFrom => '<>'
    PubkeyCallback  => \&lookup,
);
# $out->{raw}, $out->{upstream_mailfrom}

DESCRIPTION

-07 section 16 describes how a DSN (RFC 3464, structured per RFC 6522) is signed, checked and passed back along the chain. A forwarder that receives a DSN for a message it forwarded rebuilds the enclosed original to the state it was in when it went out (undoing its own Message-Instance and removing the signature and instance it added), then re-signs the whole DSN as a new message with MAIL FROM <>, so it carries exactly one Message-Instance and one DKIM2-Signature.

This module implements draft-ietf-dkim-dkim2-spec-07; see "STATUS" in Mail::DKIM2 for what that means for the wire format and the API, and "CONVENTIONS" in Mail::DKIM2 for the option, input and error conventions every module here follows.

CLASS METHODS

Each takes named arguments and returns a hashref. Each croaks on a message that is not an RFC 6522 DSN or on a missing required argument.

generate(%args)

Builds a three-part multipart/report DSN returning Message, signed by Signer (which must have MailFrom => '<>'), From postmaster@ the signer's Domain (-07 section 11). To is the envelope sender to bounce to; if omitted it is the top DKIM2-Signature's mf= (never the From: header field, as before 0.23). ReportingMTA, Status (default 5.7.1) and Reason fill the delivery-status part. Returns { raw => $bytes, send_to => $address }.

Croaks, sending nothing, unless may_generate allows it: never for a message whose top mf= is <>, nor for one that is not Provisionally Verified (-07 section 16), checked with PubkeyCallback or Resolver (DNS when neither is given) and SkipTimestampCheck, which are new in 0.23.

may_generate(%args)

Whether a DSN may be sent for Message at all: its top DKIM2-Signature's mf= is not <>, and it is Provisionally Verified (see "provisionally_verified()" in Mail::DKIM2::Verifier), with keys from PubkeyCallback or Resolver and SkipTimestampCheck as above. Returns (1, undef), or (0, $why). New in 0.23.

authenticate(%args)

Checks Message, a DSN, per -07 section 16.3: the returned original's chain verifies (from its headers alone if that is all the DSN carries), the DSN's own signature verifies, and the DSN's d= is aligned with the returned original's top rt=. Keys come from PubkeyCallback if given, else from DNS through Resolver (a Net::DNS::Resolver is made if neither is given); SkipTimestampCheck is passed to the verifiers too. Returns a hashref with ok; top, the returned original's highest signature, for the caller to recognise as its own by d= and mf=; result, details, dsn_result, dsn_details, dsn_sig; alignment (pass, fail or none) and alignment_detail; headers_only; and embedded, the returned original as an Email::MIME.

What ok covers: the returned original's chain verified, the DSN's own signature did not fail, and alignment did not fail. A DSN that carries no DKIM2-Signature of its own (dsn_result none, as from an MTA that does not sign its bounces) is still ok when the returned original verifies -- so ok alone shows the bounce is about a genuine message, not that the bounce itself is signed. A caller that requires a signed bounce checks dsn_result eq 'pass' as well. Neither says the message was one this host sent: compare top's d= and mf= with your own.

propagate(%args)

Authenticates Message as above (pass SkipAuthentication => 1 if the caller already has), then rebuilds and re-signs it as described. Croaks rather than propagate a DSN that does not authenticate: -07 section 16.3 says such a DSN MUST NOT be propagated; PubkeyCallback and Resolver are passed to authenticate. ForwarderDomain is the d= of the hop to strip, and must be the d= of the returned original's top DKIM2-Signature: propagate croaks rather than strip another domain's hop. Croaks rather than propagate towards a null mf= (<>, -07 section 16). The propagated DSN is From postmaster@ the signer's Domain. Returns { raw => $bytes, upstream_mailfrom => $address }.

AUTHOR

Bron Gondwana <brong@fastmailteam.com>

COPYRIGHT AND LICENSE

Copyright (c) 2025-2026 Fastmail Pty Ltd. This is free software; you can redistribute it and/or modify it under the same terms as Perl itself.