The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl

# 
# spfquery: Command-line tool for performing SPF queries
#
# (C) 2005-2012 Julian Mehnle <julian@mehnle.net>
#     2004      Wayne Schlitt <wayne@schlitt.net>
# $Id: spfquery 138 2006-01-22 18:00:34Z julian $
#
##############################################################################

=head1 NAME

spfquery - (Mail::SPF) - Checks if a given set of e-mail parameters matches a
domain's SPF policy

=head1 VERSION

2.501

=head1 SYNOPSIS

=over

=item B<Preferred usage:>

B<spfquery> [B<--versions>|B<-v> B<1>|B<2>|B<1,2>] [B<--scope>|B<-s> B<helo>|B<mfrom>|B<pra>]
B<--identity>|B<--id> I<identity> B<--ip-address>|B<--ip> I<ip-address>
[B<--helo-identity>|B<--helo-id> I<helo-identity>] [I<OPTIONS>]

B<spfquery> [B<--versions>|B<-v> B<1>|B<2>|B<1,2>] [B<--scope>|B<-s> B<helo>|B<mfrom>|B<pra>]
B<--file>|B<-f> I<filename>|B<-> [I<OPTIONS>]

=item B<Legacy usage:>

B<spfquery> B<--helo> I<helo-identity> B<--ip-address>|B<--ip> I<ip-address> [I<OPTIONS>]

B<spfquery> B<--mfrom> I<mfrom-identity> B<--ip-address>|B<--ip> I<ip-address>
[B<--helo> I<helo-identity>] [I<OPTIONS>]

B<spfquery> B<--pra> I<pra-identity> B<--ip-address>|B<--ip> I<ip-address> [I<OPTIONS>]

=item B<Other usage:>

B<spfquery> B<--version>|B<-V>

B<spfquery> B<--help>

=back

=head1 DESCRIPTION

B<spfquery> checks if a given set of e-mail parameters (e.g., the SMTP sender's
IP address) matches the responsible domain's Sender Policy Framework (SPF)
policy.  For more information on SPF see L<http://www.openspf.org>.

=head2 Preferred Usage

The following usage forms are preferred over the L<legacy forms|/Legacy usage>
used by older B<spfquery> versions:

The B<--identity> form checks if the given I<ip-address> is an authorized SMTP
sender for the given C<helo> hostname, C<mfrom> envelope sender e-mail address,
or C<pra> (so-called purported resonsible address) e-mail address, depending
on the value of the B<--scope> option (which defaults to B<mfrom> if omitted).

The B<--file> form reads "I<ip-address> I<identity> [I<helo-identity>]" tuples
from the file with the specified I<filename>, or from standard input if
I<filename> is B<->, and checks them against the specified scope (B<mfrom> by
default).

Both forms support an optional B<--versions> option, which specifies a
comma-separated list of the SPF version numbers of SPF records that may be
used.  B<1> means that C<v=spf1> records should be used.  B<2> means that
C<spf2.0> records should be used.  Defaults to B<1,2>, i.e., uses any SPF
records that are available.  Records of a higher version are preferred.

=head2 Legacy Usage

B<spfquery> versions before 2.500 featured the following usage forms, which are
discouraged but still supported for L<backwards compatibility|/COMPATIBILITY>:

The B<--helo> form checks if the given I<ip-address> is an authorized SMTP
sender for the C<HELO> hostname given as the I<identity> (so-called C<HELO>
check).

The B<--mfrom> form checks if the given I<ip-address> is an authorized SMTP
sender for the envelope sender email-address (or domain) given as the
I<identity> (so-called C<MAIL FROM> check).  If a domain is given instead of an
e-mail address, C<postmaster> will be substituted for the localpart.

The B<--pra> form checks if the given I<ip-address> is an authorized SMTP
sender for the PRA (Purported Responsible Address) e-mail address given as the
identity.

=head2 Other Usage

The B<--version> form prints version information of spfquery.  The B<--help>
form prints usage information for spfquery.

=head1 OPTIONS

=head2 Standard Options

The preferred and legacy forms optionally take any of the following
I<OPTIONS>:

=over

=item B<--default-explanation> I<string>

=item B<--def-exp> I<string>

Use the specified I<string> as the default explanation if the authority domain
does not specify an explanation string of its own.

=item B<--hostname> I<hostname>

Use I<hostname> as the host name of the local system instead of auto-detecting
it.

=item B<--keep-comments>

=item B<--no-keep-comments>

Do (not) print any comments found when reading from a file or from standard
input.

=item B<--sanitize> (currently ignored)

=item B<--no-sanitize> (currently ignored)

Do (not) sanitize the output by condensing consecutive white-space into a
single space and replacing non-printable characters with question marks.
Enabled by default.

=item B<--debug> (currently ignored)

Print out debug information.

=back

=head2 Black Magic Options

Several options that were supported by earlier versions of B<spfquery> are
considered black magic (i.e. potentially dangerous for the innocent user) and
are thus disabled by default.  If the L<B<Mail::SPF::BlackMagic>> Perl module
is installed, they may be enabled by specifying B<--enable-black-magic>.

=over

=item B<--max-dns-interactive-terms> I<n>

Evaluate a maximum of I<n> DNS-interactive mechanisms and modifiers per SPF
check.  Defaults to B<10>.  Do I<not> override the default unless you know what
you are doing!

=item B<--max-name-lookups-per-term> I<n>

Perform a maximum of I<n> DNS name look-ups per mechanism or modifier.
Defaults to B<10>.  Do I<not> override the default unless you know what you are
doing!

=item B<--authorize-mxes-for> I<email-address>|I<domain>B<,>...

Consider all the MXes of the comma-separated list of I<email-address>es and
I<domain>s as inherently authorized.

=item B<--tfwl>

Perform C<trusted-forwarder.org> accreditation checking.

=item B<--guess> I<spf-terms>

Use I<spf-terms> as a default record if no SPF record is found.

=item B<--local> I<spf-terms>

Process I<spf-terms> as local policy before resorting to a default result
(the implicit or explicit C<all> mechanism at the end of the domain's SPF
record).  For example, this could be used for white-listing one's secondary
MXes: C<mx:mydomain.example.org>.

=item B<--override> I<domain>B<=>I<spf-record>

=item B<--fallback> I<domain>B<=>I<spf-record>

Set overrides and fallbacks.  Each option can be specified multiple times.  For
example:

    --override example.org='v=spf1 -all'
    --override '*.example.net'='v=spf1 a mx -all'
    --fallback example.com='v=spf1 -all'

=back

=head1 RESULT CODES

=over 12

=item B<pass>

The specified IP address is an authorized SMTP sender for the identity.

=item B<fail>

The specified IP address is not an authorized SMTP sender for the identity.

=item B<softfail>

The specified IP address is not an authorized SMTP sender for the identity,
however the authority domain is still testing out its SPF policy.

=item B<neutral>

The identity's authority domain makes no assertion about the status of the IP
address.

=item B<permerror>

A permanent error occurred while evaluating the authority domain's policy
(e.g., a syntax error in the SPF record).  Manual intervention is required
from the authority domain.

=item B<temperror>

A temporary error occurred while evaluating the authority domain's policy
(e.g., a DNS error).  Try again later.

=item B<none>

There is no applicable SPF policy for the identity domain.

=back

=head1 EXIT CODES

  Result    | Exit code
 -----------+-----------
  pass      |     0
  fail      |     1
  softfail  |     2
  neutral   |     3
  permerror |     4
  temperror |     5
  none      |     6

=head1 EXAMPLES

    spfquery --scope mfrom --id user@example.com --ip 1.2.3.4
    spfquery --file test_data
    echo "127.0.0.1 user@example.com helohost.example.com" | spfquery -f -

=head1 COMPATIBILITY

B<spfquery> has undergone the following interface changes compared to earlier
versions:

=over

=item B<2.500>

=over

=item *

A new preferred usage style for performing individual SPF checks has been
introduced.  The new style accepts a unified B<--identity> option and an
optional B<--scope> option that specifies the type (scope) of the identity.  In
contrast, the legacy usage style requires a separate usage form for every
supported scope.  See L</Preferred usage> and L</Legacy usage> for details.

=item *

The former C<unknown> and C<error> result codes have been renamed to C<permerror>
and C<temperror>, respectively, in order to comply with RFC 4408 terminology.

=item *

SPF checks with an empty identity are no longer supported.  In the case of an
empty C<MAIL FROM> SMTP transaction parameter, perform a check with the C<helo>
scope directly.

=item *

The B<--debug> and B<--(no-)sanitize> options are currently ignored by this
version of B<spfquery>.  They will again be supported in the future.

=item *

Several features that were supported by earlier versions of B<spfquery> are
considered black magic and thus are now disabled by default.  See L</Black
Magic Options>.

=item *

Several option names have been deprecated.  This is a list of them and their
preferred synonyms:

  Deprecated options  | Preferred options
 ---------------------+-----------------------------
  --sender, -s        | --mfrom
  --ipv4, -i          | --ip-address, --ip
  --name              | --hostname
  --max-lookup-count, | --max-dns-interactive-terms
    --max-lookup      |
  --rcpt-to, -r       | --authorize-mxes-for
  --trusted           | --tfwl

=back

=back

=head1 SEE ALSO

L<Mail::SPF>, L<spfd(8)>

L<http://tools.ietf.org/html/rfc4408>

=head1 AUTHORS

This version of B<spfquery> is a complete rewrite by Julian Mehnle
<julian@mehnle.net>, based on an earlier version written by Meng Weng Wong
<mengwong+spf@pobox.com> and Wayne Schlitt <wayne@schlitt.net>.

=cut

our $VERSION = '2.501';

use warnings;
use strict;

use IO::File;
use Getopt::Long qw(:config gnu_compat no_ignore_case);
use Error ':try';
use Mail::SPF;

use constant TRUE   => (0 == 0);
use constant FALSE  => not TRUE;

use constant exit_codes_by_result_code => {
    pass        => 0,
    fail        => 1,
    softfail    => 2,
    neutral     => 3,
    permerror   => 4,
    temperror   => 5,
    none        => 6
};

# Helper Functions
##############################################################################

sub usage {
    STDERR->printf(<<'EOT');
Preferred Usage:
    spfquery [--versions|-v 1|2|1,2] [--scope|-s helo|mfrom|pra]
        --identity|--id <identity> --ip-address|--ip <ip-address>
        [--helo-identity|--helo-id <helo-identity>] [OPTIONS]
    spfquery [--versions|-v 1|2|1,2] [--scope|-s helo|mfrom|pra]
        --file|-f <filename>|- [OPTIONS]

Legacy Usage:
    spfquery --helo <helo-identity> --ip-address|--ip <ip-address> [OPTIONS]
    spfquery --mfrom <mfrom-identity> --ip-address|--ip <ip-address>
        [--helo <helo-identity>] [OPTIONS]
    spfquery --pra <pra-identity> --ip-address|--ip <ip-address> [OPTIONS]

Other Usage:
    spfquery --version|-V

See `spfquery --help` for more information.
EOT
    return;
}

sub help {
    print(<<'EOT');
Preferred Usage:
    spfquery [--versions|-v 1|2|1,2] [--scope|-s helo|mfrom|pra]
        --identity|--id <identity> --ip-address|--ip <ip-address>
        [--helo-identity|--helo-id <helo-identity>] [OPTIONS]
    spfquery [--versions|-v 1|2|1,2] [--scope|-s helo|mfrom|pra]
        --file|-f <filename>|- [OPTIONS]

Legacy Usage:
    spfquery --helo <helo-identity> --ip-address|--ip <ip-address> [OPTIONS]
    spfquery --mfrom <mfrom-identity> --ip-address|--ip <ip-address>
        [--helo <helo-identity>] [OPTIONS]
    spfquery --pra <pra-identity> --ip-address|--ip <ip-address> [OPTIONS]

Other Usage:
    spfquery --version|-V

spfquery performs SPF checks based on the command-line arguments or data given
in a file or on standard input.

Only the preferred and other usage forms are explained here.  See the
spfquery(1) man-page for an explanation of the legacy usage forms.

The "--identity" form checks if the given <ip-address> is an authorized SMTP
sender for the given "helo" hostname, "mfrom" envelope sender e-mail address,
or "pra" (purported resonsible address) e-mail address, depending on the value
of the "--scope" option (which defaults to "mfrom" if omitted).

The "--file" form reads "<ip-address> <identity> [<helo-identity>]" tuples from
the file with the specified <filename>, or from standard input if <filename> is
"-", and checks them against the specified scope ("mfrom" by default).

The "--version" form prints version information of spfquery.

Valid OPTIONS (and their defaults) are:
    --default-explanation <string>
                        Default explanation string to use (sensible default).
    --hostname <hostname>
                        The name of the system doing the SPF checking (local
                        system's configured hostname).
    --keep-comments     Print comments found when reading from a file.
    --no-sanitize       Do not clean up invalid characters in output.
    --debug             Output debugging information.

Black-magic OPTIONS are:
    --max-dns-interactive-terms <n>
                        Maximum number of DNS-interactive mechanisms and
                        modifiers (10).
    --max-name-lookups-per-term <n>
                        Maximum number of DNS name look-ups per mechanism or
                        modifier (10).
    --authorize-mxes-for <email-address>|<domain>,...
                        A comma-separated list of e-mail addresses and domains
                        whose MXes will be considered inherently authorized.
    --tfwl              Check trusted-forwarder.org white-list.
    --guess <spf-terms> Default checks if no SPF record is found.
    --local <spf-terms> Local policy to process before default result.
    --override <domain>=<spf-record>
    --fallback <domain>=<spf-record>
                        Set override and fallback SPF records for domains.

Examples:
    spfquery --scope mfrom --id user@example.com --ip 1.2.3.4
    spfquery --file test_data
    echo "127.0.0.1 user@example.com helohost.example.com" | spfquery -f -
EOT
    return;
}

sub deprecated_option {
    my ($old_option, $new_option, $options) = @_;
    return FALSE if not exists($options->{$old_option});
    STDERR->print(
        "Warning: '$old_option' option is deprecated" .
        ($new_option ? "; use '$new_option' instead" : '') .
        ".\n"
    );
    $options->{$new_option} = delete($options->{$old_option});
    return TRUE;
}

sub unsupported_option {
    my ($option_name, $options) = @_;
    return FALSE if not exists($options->{$option_name});
    STDERR->print("Error: '$option_name' option is no longer supported.\n");
    return TRUE;
}

sub black_magic_option {
    my ($option_name, $options) = @_;
    return FALSE if not exists($options->{$option_name});
    STDERR->print("Error: '$option_name' option is black magic! Do not use it!\n");
    return TRUE;
}

# Command-line Option Handling
##############################################################################

my $options = {};
my $getopt_result = GetOptions(
    $options,

    'file|f=s',

    'versions|v=s',
    'scope=s',
    's=s',  # Special handling for ambiguous 's' option (formerly a synonym
            # for 'sender', now preferredly a synonym for 'scope').
    'identity|id=s',
    'ip-address|ip=s',
    'helo-identity|helo-id=s',

    # Legacy/shortcut options:
    'mfrom|mail-from|m=s',
    'helo|h=s',

    'default-explanation|def-exp=s',
    'hostname=s',

    'keep-comments!',
    'debug!',       # TODO Implement!
    'sanitize!',    # TODO Implement!

    # Black Magic options:
    'enable-black-magic!',
    'max-dns-interactive-terms=i',
    'max-name-lookups-per-term=i',
    'authorize-mxes-for=s',
                    # TODO implement!
    'tfwl!',        # TODO Implement!
    'guess=s',      # TODO Implement!
    'local=s',      # TODO Implement!
    'override=s%',  # TODO Implement!
    'fallback=s%',  # TODO Implement!

    # Meta actions:
    'version|V!',
    'help!',

    # Deprecated options:
    'sender=s',     # Now 'scope'/'identity' or 'mfrom'
    'ipv4=s',       # Now 'ip-address'
    'i=s',          # Now 'ip-address'
    'name=s',       # Now 'hostname'
    'max-lookup-count=i',
    'max-lookup=i', # Now 'max-dns-interactive-terms'
    'rcpt-to=s',    # Now 'authorize-mxes-for'
    'r=s',          # Now 'authorize-mxes-for'
    'trusted!'      # Now 'tfwl'
);

if (not $getopt_result) {
    usage();
    exit(255);
}

if ($options->{help}) {
    help();
    exit(0);
}

if ($options->{version}) {
    print("spfquery version $VERSION (using Mail::SPF)\n");
    exit(0);
}

deprecated_option('sender',           'mfrom',                     $options);
deprecated_option('ipv4',             'ip-address',                $options);
deprecated_option('i',                'ip-address',                $options);
deprecated_option('name',             'hostname',                  $options);
deprecated_option('max-lookup-count', 'max-dns-interactive-terms', $options);
deprecated_option('max-lookup',       'max-dns-interactive-terms', $options);
deprecated_option('rcpt-to',          'authorize-mxes-for',        $options);
deprecated_option('r',                'authorize-mxes-for',        $options);
deprecated_option('trusted',          'tfwl',                      $options);

if ($options->{'enable-black-magic'}) {
    if (not defined(eval('require Mail::SPF::BlackMagic'))) {
        STDERR->print("Error: Cannot enable black magic. Unable to load Mail::SPF::BlackMagic.\n");
        exit(255);
    }
    # else: Black magic enabled!
}
elsif (
    black_magic_option('max-dns-interactive-terms', $options) or
    black_magic_option('max-name-lookups-per-term', $options) or
    black_magic_option('rcpt-to',                   $options) or
    black_magic_option('trusted',                   $options) or
    black_magic_option('guess',                     $options) or
    black_magic_option('local',                     $options) or
    black_magic_option('override',                  $options) or
    black_magic_option('fallback',                  $options)
) {
    exit(255);
}

my @versions            = split(',', $options->{versions} || '');
my $scope               = $options->{scope};
my $identity            = $options->{identity};
my $ip_address          = $options->{'ip-address'};
my $helo_identity       = $options->{'helo-identity'};

# Heuristic for distinguishing between 's(cope)' and 's(ender)':
if (defined(my $s = $options->{s})) {
    if (
        not defined($scope) and  # No explicit 'scope' option has been specified, and
        $s !~ /[@.]/             # 's' option contains neither an '@' nor a dot,
                                 # so it cannot be an e-mail address or a domain.
    ) {
        # Thus it must be meant as the 'scope' option:
        $scope = $s;
    }
    else {
        # Else, it must be meant as the deprecated 'sender' option:
        $options->{mfrom} = $s;
    }
}

# Heuristic for when explicit 'scope'/'s(cope)' option is absent:
if (not defined($scope)) {
    if (defined($identity) or defined($options->{file})) {
        # Identity has been specified, or input will be read from file:
        # apply the 'scope' option default:
        $scope    = 'mfrom';
    }
    elsif (defined($options->{helo})) {
        $scope    = 'helo';
        $identity = $options->{helo};
    }
    elsif (defined($options->{mfrom})) {
        $scope    = 'mfrom';
        $identity = $options->{mfrom};
        $helo_identity ||= $options->{helo};
    }
    elsif (defined($options->{pra})) {
        $scope    = 'pra';
        $identity = $options->{pra};
    }
}

my $default_explanation = $options->{'default-explanation'};
my $hostname            = $options->{hostname};

if (
    not defined($scope) or
    not (defined($identity) xor defined($options->{file}))
) {
    usage();
    exit(255);
}

if (defined($identity) and $identity eq '') {
    STDERR->print("Error: Empty identities are not supported. See spfquery(1).\n");
    exit(255);
}

# Process the SPF Request(s)
##############################################################################

try {
    my $spf_server = Mail::SPF::Server->new(
        default_authority_explanation
                        => $default_explanation,
        hostname        => $hostname,
    #    debug           => $options->{debug},
    #    sanitize        => $options->{sanitize},

        # Black Magic:
        (
            exists($options->{'max-dns-interactive-terms'}) ?
                (max_dns_interactive_terms  => $options->{'max-dns-interactive-terms'} || undef)
            :   ()
        ),
        (
            exists($options->{'max-name-lookups-per-term'}) ?
                (max_name_lookups_per_term  => $options->{'max-name-lookups-per-term'} || undef)
            :   ()
        )
    #    rcpt_to         => $options->{'rcpt-to'},
    #    trusted         => $options->{trusted},
    #    guess           => $options->{guess},
    #    local           => $options->{local},
    #    override        => $options->{override},
    #    fallback        => $options->{fallback},
    );

    my $exit_code;

    if (not defined($options->{file})) {
        # Single request:
        my $result_code = do_process(
            $spf_server,
            versions        => @versions ? [@versions] : undef,
            scope           => $scope,
            identity        => $identity,
            ip_address      => $ip_address,
            helo_identity   => $helo_identity
        );
        $exit_code = exit_codes_by_result_code->{$result_code};
    }
    else {
        # File request:
        my $file = $options->{file} eq '-' ? \*STDIN : IO::File->new($options->{file})
            or die("Could not open: $options->{file}\n");
        while (<$file>) {
            chomp;
            s/^\s*//;
            next if /^$/;
            if (/^#/) {
                print("$_\n") if $options->{'keep-comments'};
                next;
            }
            ($ip_address, $identity, $helo_identity) = split;
            my $result_code = do_process(
                $spf_server,
                versions        => @versions ? [@versions] : undef,
                scope           => $scope,
                identity        => $identity,
                ip_address      => $ip_address,
                helo_identity   => $helo_identity
            );
            $exit_code ||= exit_codes_by_result_code->{$result_code};
        }
    }

    exit($exit_code);
}
catch Mail::SPF::Exception with {
    my ($e) = @_;
    STDERR->printf("Error: %s.\n", $e->text);
    exit(255);
};


# Helper Function
##############################################################################

sub do_process {
    my ($spf_server, %request_options) = @_;
    my $request = Mail::SPF::Request->new(%request_options);
    my $result  = $spf_server->process($request);
    printf(
        "%s\n%s\n%s\n%s\n",
        $result->code,
        (
            $result->can('authority_explanation') ?
                $result->authority_explanation
            :   $result->local_explanation
        ),
        $result->local_explanation,
        $result->received_spf_header
    );
    return $result->code;
}