Snippets

Filter:
How can I DKIM sign an e-mail? Authentication

To DKIM sign an email you can use the following code in your filter:

use Mail::MIMEDefang::DKIM;  
my ($header, $value) = md_dkim_sign('domain-dkim.key.pem', 'rsa-sha256', 'relaxed/simple', $sender_domain, 'dkim');  
action_insert_header($header, $value);
How can I verify a DKIM signature ? Authentication

To verify a DKIM signature you can use the following code in your filter:

use Mail::MIMEDefang::DKIM;  
my ($result, $domain, $keysize, $btag) = md_dkim_verify();  
if($result eq "pass") {  
  ...  
}
How can I convert winmail.dat TNEF attachments to their original content? Attachments

To convert all attached winmail.dat TNEF attachments to their original content you can use the following code in your filter:

sub filter {
  my($entity, $fname, $ext, $type) = @_;

  if (lc($type) eq "application/ms-tnef" or lc($fname) eq "winmail.dat" ) {
    use Convert::TNEF;
    use File::Type;
    use File::Temp qw(tempfile tempdir);

    my $tnefdir = tempdir(CLEANUP => 1, DIR => "/tmp");
    my $tnef = Convert::TNEF->read_ent($entity,{output_dir=>"$tnefdir"});

    my $ft = File::Type->new();

    for ($tnef->attachments) {
         my $mimetype = $ft->mime_type($_->data);

         my $tnef_entity = action_add_part($entity, "$mimetype", "base64", $_->data, $_->longname, "attachment");
         md_graphdefang_log('tnef_ext', "File: " . $_->longname . " Type: $mimetype");

         filter ($tnef_entity, $_->longname, "", "$mimetype");
    }
    $tnef->purge;
    return action_drop();
  }
  return action_accept();
}
How can I use rspamd to detect spam messages ? Antispam

To use rspamd to detect spam messages you can use the following code in your filter:

 my ($hits, $req, $names, $report, $action, $is_spam) = rspamd_check();
 md_syslog("Warning", "Action: $action, Spam: $is_spam, Names: $names");
 if ($is_spam eq "true") {
   action_change_header("X-Spam-Score", "$hits/$req $names");
   md_syslog("Warning", "Action: $action");
   md_graphdefang_log('spam', $hits, $RelayAddr);
 } else {
   # Delete any existing X-Spam-Score header?
   action_delete_header("X-Spam-Score");
 }
How can I implement greylisting ? Antispam

In order to use use greylisting you should add this code tou your filter:

my $dbh;
sub filter_initialize {
    my($entity) = @_;

    $dbh = DBI->connect($dsn, $username, $auth, {RaiseError => 1});
}

sub filter_recipient {
    my ($recipient, $sender, $ip, $hostname, $first, $helo,
        $rcpt_mailer, $rcpt_host, $rcpt_addr) = @_;

    my $ip_address = $ip;

    # Greylist all the /24 subnet
    #
    # my @ip = split(/\./, $ip);
    # $ip_address = $ip[0] . '.' . $ip[1] . '.' . $ip[2] . '.0';

    my $ret = Mail::MIMEDefang::Actions::action_greylist($dbh, $sender, $recipient, $ip_address);
    if($ret eq "tempfail") {
      return('TEMPFAIL', "Email greylisted, please come back later");
    } elsif($ret eq "reject") {
      return('REJECT', "Go away or deliver email faster");
    } else {
      return ('CONTINUE', "ok");
    }
}

 sub filter_cleanup {
   $dbh->disconnect();
 }

A sample database schema is available in the contrib/greylisting directory.

How can I sign a message with ARC? Authentication

ARC (Authenticated Received Chain) signing should be done in filter_end, after all message processing is complete. It preserves the authentication state of a message as it passes through intermediary mail servers.

use Mail::MIMEDefang::DKIM::ARC;

sub filter_end {
    my($entity) = @_;

    my $domain   = 'example.com';
    my $keyfile  = '/etc/mail/arc-private.key';
    my $selector = 'arc';

    my %arc_headers = md_arc_sign(
        $keyfile,      # path to private key
        'rsa-sha256',  # signing algorithm
        'ar',          # cv= value: 'ar' copies from Authentication-Results
        $domain,
        $domain,       # authserv-id (srvid), usually same as domain
        $selector,
    );

    if (exists $arc_headers{error}) {
        md_syslog('Warning', "ARC signing failed: $arc_headers{error}");
        return;
    }

    # Insert all ARC headers returned by md_arc_sign
    foreach my $hdr (sort keys %arc_headers) {
        action_insert_header($hdr, $arc_headers{$hdr});
    }
}
How can I check SPF records for incoming mail? Authentication

SPF verification should be done in filter_begin, before the message body is processed. The check uses the sender address, relay IP, and optional HELO name.

use Mail::MIMEDefang::SPF;

sub filter_begin {
    my($entity) = @_;

    my ($spf_result, $spf_explanation,
        $helo_result, $helo_explanation) =
            md_spf_verify($sender, $RelayAddr, $Helo);

    md_syslog('Info', "SPF result for $sender: $spf_result ($spf_explanation)");

    if ($spf_result eq 'fail') {
        # Hard SPF fail - reject the message
        return action_bounce("SPF check failed: $spf_explanation");
    }

    if ($spf_result eq 'softfail') {
        # Soft fail - tag but do not reject
        action_change_header('X-SPF-Result', "softfail ($spf_explanation)");
    }

    if ($spf_result eq 'pass') {
        action_change_header('X-SPF-Result', 'pass');
    }
}
How can I append a disclaimer to outgoing messages? Attachments

Disclaimers are best appended in filter_end, after all message parts have been processed. append_text_boilerplate handles plain-text parts and append_html_boilerplate handles HTML parts, inserting the HTML disclaimer before the closing </body> tag when possible.

use Mail::MIMEDefang::MIME;

sub filter_end {
    my($entity) = @_;

    # Only append disclaimer to outbound mail
    return unless $RelayAddr =~ /^(192\.168\.|10\.|172\.1[6-9]\.|172\.2\d\.|172\.3[01]\.)/;

    my $text_disclaimer = "\n--\nThis message and any attachments are confidential.\n";
    my $html_disclaimer = '<br><hr><p style="font-size:small;color:#666">'
        . 'This message and any attachments are confidential.</p>';

    append_text_boilerplate($entity, $text_disclaimer, 0);
    append_html_boilerplate($entity, $html_disclaimer, 0);
}
How can I verify BIMI records for incoming mail? Authentication

BIMI (Brand Indicators for Message Identification) should be checked in filter_begin. A BIMI record is only valid when the sending domain passes DMARC at enforcement level (p=quarantine or p=reject). Install the optional Mail::BIMI Perl module for full SVG logo and VMC certificate validation.

use Mail::MIMEDefang::BIMI;

sub filter_begin {
    my($entity) = @_;

    # Extract the From: domain
    my $from_domain = $sender;
    $from_domain =~ s/^.*\@//;

    # Check for a BIMI-Selector header and get the selector value
    my $selector = md_bimi_get_selector($entity);

    # These values should come from a prior DMARC check, e.g. via md_authres
    my $dmarc_result = 'pass';   # 'pass' or 'fail'
    my $dmarc_policy = 'reject'; # 'none', 'quarantine', or 'reject'

    my $bimi_result = md_bimi_verify(
        $from_domain,
        $dmarc_result,
        $dmarc_policy,
        $selector,
    );

    md_syslog('Info', "BIMI result for $from_domain (selector=$selector): $bimi_result");

    if ($bimi_result eq 'pass') {
        my $record = md_bimi_lookup($from_domain, $selector);
        if ($record) {
            action_add_header('BIMI-Indicator', $record->{l});
        }
    }
}
How can I add an Authentication-Results header? Authentication

The Authentication-Results header consolidates SPF, DKIM, DMARC, and BIMI results into a single header, making them available to downstream mail clients and filters. Add it in filter_begin so it is present before the message body is processed.

When the optional Mail::BIMI module is installed and $bimi_domain is supplied, md_authres will append a bimi=pass (or bimi=fail) result automatically.

use Mail::MIMEDefang::Authres;

sub filter_begin {
    my($entity) = @_;

    # Extract the From: domain for BIMI lookup (optional)
    my $from_domain = $sender;
    $from_domain =~ s/^.*\@//;

    my $authres = md_authres(
        $sender,       # envelope sender (MAIL FROM)
        $RelayAddr,    # connecting IP address
        $MyHostName,   # this server's hostname
        $Helo,         # EHLO/HELO name (optional)
        $from_domain,  # From: domain for BIMI (optional)
    );

    action_insert_header('Authentication-Results', $authres);
}
How can I use SpamAssassin to detect spam? Antispam

SpamAssassin scanning should be done in filter_end, after the full message is assembled. spam_assassin_check returns hits, required threshold, triggered rule names, and a report. Use md_spamc_init / md_spamc_check instead when using SpamAssassin 4.0.1+ in client mode.

use Mail::MIMEDefang::Antispam;

sub filter_end {
    my($entity) = @_;

    my ($hits, $required, $names, $report) = spam_assassin_check();

    md_syslog('Info', "SpamAssassin: score=$hits required=$required rules=$names");

    if ($hits >= $required) {
        # Tag the message as spam and add score header
        action_change_header('X-Spam-Flag',   'YES');
        action_change_header('X-Spam-Score',  "$hits / $required");
        action_change_header('X-Spam-Report', $names);
        md_graphdefang_log('spam', $hits, $RelayAddr);

        if ($hits >= 10) {
            # Reject very high-scoring messages outright
            return action_bounce("Message rejected as spam (score $hits)");
        }
    } else {
        action_change_header('X-Spam-Flag',  'NO');
        action_change_header('X-Spam-Score', "$hits / $required");
    }
}
How can I scan messages for viruses? Antivirus

Virus scanning should be done in filter_end to inspect the fully assembled message. message_contains_virus calls every installed virus scanner (e.g. ClamAV) and returns the scanner name, the virus name, and a code ('ok', 'virus', or 'error').

use Mail::MIMEDefang::Antivirus;

sub filter_end {
    my($entity) = @_;

    my ($code, $category, $action) = message_contains_virus();

    if ($code eq 'virus') {
        md_syslog('Warning',
            "Virus detected in message from $sender ($RelayAddr): $category");
        md_graphdefang_log('virus', $category, $RelayAddr);
        return action_bounce("Message rejected: virus detected ($category)");
    }

    if ($code eq 'error') {
        md_syslog('Warning',
            "Virus scanner error for message from $sender: $category");
        # Tempfail on scanner error to avoid letting infected mail through
        return action_tempfail("Temporary error during virus scan, please try again");
    }
}
How can I check if a relay is blacklisted (RBL)? Antispam

RBL (Real-time Blackhole List) checks should be done in filter_begin or filter_sender to reject blocked senders as early as possible. Use relay_is_blacklisted for a single RBL or relay_is_blacklisted_multi_count to query several RBLs simultaneously and reject based on a hit count threshold.

use Mail::MIMEDefang::Net;

sub filter_begin {
    my($entity) = @_;

    # Single RBL check
    my $result = relay_is_blacklisted($RelayAddr, 'zen.spamhaus.org');
    if ($result) {
        md_syslog('Warning', "Relay $RelayAddr listed in zen.spamhaus.org ($result)");
        return action_bounce("Mail rejected: $RelayAddr is blacklisted");
    }

    # Multi-RBL check: reject if listed in 2 or more RBLs
    my @rbls = qw(zen.spamhaus.org bl.spamcop.net dnsbl.sorbs.net);
    my $hit_count = relay_is_blacklisted_multi_count($RelayAddr, @rbls);
    if ($hit_count >= 2) {
        md_syslog('Warning',
            "Relay $RelayAddr listed in $hit_count RBLs - rejecting");
        return action_bounce(
            "Mail rejected: sender IP listed in multiple blacklists");
    }
}
How can I block dangerous file attachments? Attachments

Attachment filtering is done in filter, which is called once per MIME part. The example below drops executables, scripts, and macro-enabled Office documents. Use action_drop_with_warning to notify the recipient, or action_defang to rename the attachment and neutralise it rather than removing it entirely.

sub filter {
    my($entity, $fname, $ext, $type) = @_;

    # Extensions considered dangerous
    my @blocked_extensions = qw(
        exe com bat cmd scr pif vbs vbe js jse
        ps1 ps2 jar docm xlsm pptm
    );

    my $lext = lc($ext);
    $lext =~ s/^\.//;  # strip leading dot if present

    if (grep { $_ eq $lext } @blocked_extensions) {
        md_syslog('Warning',
            "Blocked attachment '$fname' (.$lext) from $sender");
        md_graphdefang_log('attachment_blocked', $fname, $RelayAddr);
        return action_drop_with_warning(
            "An attachment named '$fname' was removed because its file type "
            . "(.$lext) is not permitted.");
    }

    return action_accept();
}
How can I retrieve and act on DMARC records? Authentication

DMARC record lookups are best done in filter_begin so the policy is available for downstream checks (e.g. BIMI verification or per-policy actions). md_get_dmarc_record returns a string with the raw DMARC TXT record, or undef if no record exists.

use Mail::MIMEDefang::Net;

sub filter_begin {
    my($entity) = @_;

    my $from_domain = $sender;
    $from_domain =~ s/^.*\@//;

    my $dmarc_record = md_get_dmarc_record($from_domain);

    unless (defined $dmarc_record) {
        md_syslog('Info', "No DMARC record found for $from_domain");
        return;
    }

    md_syslog('Info', "DMARC record for $from_domain: $dmarc_record");

    # Parse the policy tag from the record
    my ($policy) = ($dmarc_record =~ /\bp=(\w+)/i);
    $policy //= 'none';

    md_syslog('Info', "DMARC policy for $from_domain: $policy");

    if ($policy eq 'reject') {
        # Domain publishes p=reject - be stricter on authentication failures
        action_change_header('X-DMARC-Policy', "reject ($from_domain)");
    } elsif ($policy eq 'quarantine') {
        action_change_header('X-DMARC-Policy', "quarantine ($from_domain)");
    } else {
        action_change_header('X-DMARC-Policy', "none ($from_domain)");
    }
}
How do I initialise the async engine? Async

Call md_async_init once at package scope (outside any sub), after the use statements at the top of your filter file. It sets up the AnyEvent event loop that all async checks share. Requires the optional CPAN modules AnyEvent, AnyEvent::DNS, AnyEvent::Socket, and AnyEvent::Util.

use Mail::MIMEDefang::Async;
use Mail::MIMEDefang::Async::Checks qw(
    md_async_check_dnsbl
    md_async_check_rdns
    md_async_check_spf_record
    md_async_check_dmarc_record
    md_async_check_mx_exists
);
use Mail::MIMEDefang::Async::Results qw(
    md_async_interpret_dnsbl
    md_async_interpret_rdns
    md_async_interpret_spf_txt
    md_async_interpret_dmarc
    md_async_score_results
);

md_async_init(
    max_concurrency => 8,   # max parallel checks in flight
    global_timeout  => 10,  # hard wall-clock limit for the whole batch (seconds)
    dns_timeout     => 5,   # per-DNS-query timeout (seconds)
    socket_timeout  => 5,   # per-socket-connection timeout (seconds)
);

These settings apply to every call to md_async_run_checks() in the filter. Tune global_timeout to stay within your MTA's milter socket timeout.

How do I use async drop-in replacements for common checks? Async

Mail::MIMEDefang::Async provides async drop-in replacements for the most common blocking network calls. After calling md_async_init at package scope, you can swap these in place of their synchronous counterparts in filter_begin or filter_sender with no other changes to your filter logic.

use Mail::MIMEDefang::Async;

sub filter_begin {
    my($entity) = @_;

    # Drop-in for relay_is_blacklisted() from Mail::MIMEDefang::Net
    my $listed = md_async_relay_is_blacklisted($RelayAddr, 'zen.spamhaus.org');
    if ($listed) {
        md_syslog('Warning', "Relay $RelayAddr listed in zen.spamhaus.org");
        return action_bounce("Mail rejected: $RelayAddr is blacklisted");
    }

    # Drop-in for md_spf_verify() from Mail::MIMEDefang::SPF
    my ($spf_result, $spf_exp) = md_async_spf_verify($sender, $RelayAddr, $Helo);
    if ($spf_result eq 'fail') {
        return action_bounce("SPF check failed: $spf_exp");
    }

    # Drop-in for md_get_dmarc_record() from Mail::MIMEDefang::Net
    my $from_domain = $sender;
    $from_domain =~ s/^.*\@//;
    my $dmarc_raw = md_async_dmarc_verify($from_domain);
    if (defined $dmarc_raw) {
        my ($policy) = ($dmarc_raw =~ /\bp=(\w+)/i);
        md_syslog('Info', "DMARC policy for $from_domain: " . ($policy // 'none'));
    }
}

sub filter_end {
    my($entity) = @_;

    # Drop-in for md_spamc_check() from Mail::MIMEDefang::Antispam (needs spamd)
    my ($score, $threshold, $report, $isspam) = md_async_spamc_check(
        host => '127.0.0.1',
        port => 783,
    );
    if (defined $score && $isspam) {
        action_change_header('X-Spam-Flag',  'YES');
        action_change_header('X-Spam-Score', "$score / $threshold");
    }

    # Drop-in for rspamd_check() from Mail::MIMEDefang::Antispam
    my ($hits, $req, $tests, $rpt, $action, $is_spam) = md_async_rspamd_check();
    md_syslog('Info', "Rspamd: action=$action spam=$is_spam score=$hits/$req");
}
How do I run multiple DNS checks in parallel with scoring? Async

md_async_run_checks fires all checks concurrently and blocks until every one completes or the global_timeout fires. Use md_async_score_results to tally the interpreted results into a weighted spam score and get a single PASS / REJECT / TEMPFAIL decision. This belongs in filter_begin.

use Mail::MIMEDefang::Async;
use Mail::MIMEDefang::Async::Checks qw(
    md_async_check_dnsbl
    md_async_check_rdns
    md_async_check_spf_record
    md_async_check_dmarc_record
    md_async_check_mx_exists
);
use Mail::MIMEDefang::Async::Results qw(
    md_async_interpret_dnsbl
    md_async_interpret_rdns
    md_async_interpret_spf_txt
    md_async_interpret_dmarc
    md_async_score_results
);

sub filter_begin {
    my($entity) = @_;

    my $from_domain = $sender;
    $from_domain =~ s/^.*\@//;

    # All checks fire at once; wall-clock cost = slowest single check
    my $result = md_async_run_checks([
        md_async_check_dnsbl(name => 'zen',
            ip => $RelayAddr, zone => 'zen.spamhaus.org'),
        md_async_check_dnsbl(name => 'spamcop',
            ip => $RelayAddr, zone => 'bl.spamcop.net'),
        md_async_check_rdns(ip => $RelayAddr),
        md_async_check_spf_record(domain => $from_domain),
        md_async_check_dmarc_record(domain => $from_domain),
        md_async_check_mx_exists(domain => $from_domain),
    ]);

    my %interp = (
        zen     => md_async_interpret_dnsbl(
            records => $result->{results}{zen},
            zone    => 'zen.spamhaus.org',
            error   => $result->{errors}{zen}),
        spamcop => md_async_interpret_dnsbl(
            records => $result->{results}{spamcop},
            zone    => 'bl.spamcop.net',
            error   => $result->{errors}{spamcop}),
        rdns    => md_async_interpret_rdns(
            records => $result->{results}{rdns},
            error   => $result->{errors}{rdns},
            ip      => $RelayAddr),
        spf     => md_async_interpret_spf_txt(
            records => $result->{results}{spf},
            error   => $result->{errors}{spf},
            domain  => $from_domain),
        dmarc   => md_async_interpret_dmarc(
            $result->{results}{dmarc}),
    );

    my $score = md_async_score_results(
        interpreted => \%interp,
        reject_at   => 5.0,
        tempfail_at => 12.0,
    );

    md_syslog('Info',
        "Async batch score=$score->{score} action=$score->{action} "
        . "reasons=" . join(', ', @{$score->{reasons}}));

    if ($score->{action} eq 'REJECT') {
        return action_bounce("550 5.7.1 $score->{reasons}[0]");
    }
    if ($score->{action} eq 'TEMPFAIL') {
        return action_tempfail("451 4.7.1 Temporary check failure");
    }
}
How do I scan for viruses using the async ClamAV interface? Async

Two async ClamAV variants are available, both used in filter_end:

  • md_async_message_contains_virus_clamd - sends a SCAN command directly to the clamd socket. Fastest, but clamd must run on the same host as MIMEDefang and be able to open the spool path itself.
  • md_async_message_contains_virus_clamdscan - spawns clamdscan --stream, streaming file data over the INSTREAM protocol. Works with both local Unix-socket clamd and remote TCP clamd.

Both return the standard triplet ($code, $category, $action).

use Mail::MIMEDefang::Async;

sub filter_end {
    my($entity) = @_;

    # Option A: direct clamd socket (local clamd only)
    my ($code, $category, $action) =
        md_async_message_contains_virus_clamd('/var/run/clamav/clamd.sock');

    # Option B: clamdscan --stream (local or remote clamd)
    # my ($code, $category, $action) =
    #     md_async_message_contains_virus_clamdscan('/etc/clamav/clamd.conf');

    if ($code == 1 && $category eq 'virus') {
        md_syslog('Warning',
            "Virus detected in message from $sender ($RelayAddr): $category");
        md_graphdefang_log('virus', $category, $RelayAddr);
        return action_bounce("Message rejected: virus detected ($category)");
    }

    if ($code == 999) {
        md_syslog('Warning',
            "ClamAV error for message from $sender: $category");
        return action_tempfail("Temporary error during virus scan, please retry");
    }
}
How do I check for spam using the async SpamAssassin and Rspamd interfaces? Async

Two async spam-checking variants are available, both used in filter_end:

  • md_async_spamc_check - sends the message to spamd using the raw SPAMC wire protocol over an async socket. Does not require Mail::SpamAssassin::Client.
  • md_async_rspamd_check - POSTs the message to the Rspamd HTTP API over an async TCP socket. Does not require LWP::UserAgent.
  • md_async_spam_assassin_check - runs SpamAssassin in-process (no spamd), reading ./INPUTMSG. Use when spamd is not available.

Each returns the same values as its synchronous counterpart.

use Mail::MIMEDefang::Async;

sub filter_end {
    my($entity) = @_;

    # --- Option A: async spamc (needs a running spamd) ---
    my ($score, $threshold, $report, $isspam) = md_async_spamc_check(
        host    => '127.0.0.1',
        port    => 783,
        user    => 'defang',
        timeout => 30,
    );

    if (defined $score) {
        action_change_header('X-Spam-Flag',  $isspam  ? 'YES' : 'NO');
        action_change_header('X-Spam-Score', "$score / $threshold");
        if ($isspam && $score >= 10) {
            return action_bounce("Message rejected as spam (score $score)");
        }
    }

    # --- Option B: async Rspamd ---
    my ($hits, $req, $tests, $rpt, $action, $is_spam) =
        md_async_rspamd_check('http://127.0.0.1:11333');

    md_syslog('Info',
        "Rspamd: score=$hits/$req action=$action spam=$is_spam tests=$tests");

    if ($action eq 'reject') {
        return action_bounce("Message rejected by Rspamd (score $hits)");
    }
    if ($action eq 'add header' || $is_spam eq 'true') {
        action_change_header('X-Spam-Score', "$hits/$req $tests");
    }

    # --- Option C: in-process SpamAssassin (no spamd required) ---
    # my ($hits, $required, $names, $full_report) =
    #     md_async_spam_assassin_check();
}