File Coverage

blib/lib/Mail/BIMI.pm
Criterion Covered Total %
statement 130 142 91.5
branch 42 58 72.4
condition 12 24 50.0
subroutine 17 17 100.0
pod 2 2 100.0
total 203 243 83.5


line stmt bran cond sub pod time code
1             package Mail::BIMI;
2             # ABSTRACT: BIMI object
3             our $VERSION = '3.20210225'; # VERSION
4 30     30   3218434 use 5.20.0;
  30         130  
5 30     30   16787 use Moose;
  30         14108259  
  30         234  
6 30     30   220384 use Moose::Util::TypeConstraints;
  30         73  
  30         784  
7 30     30   66665 use Mail::BIMI::Prelude;
  30         72  
  30         257  
8 30     30   29339 use Mail::BIMI::Options;
  30         125  
  30         1844  
9 30     30   20724 use Mail::BIMI::Record;
  30         281  
  30         3462  
10 30     30   20095 use Mail::BIMI::Result;
  30         153  
  30         1567  
11 30     30   304 use Mail::DMARC::PurePerl;
  30         87  
  30         882  
12 30     30   181 use Net::DNS::Resolver;
  30         104  
  30         64522  
13              
14             with 'Mail::BIMI::Role::HasError';
15              
16             subtype 'MaybeDMARC'
17             => as 'Any'
18             => where {
19             !defined $_
20             || ref $_ eq 'Mail::DMARC::PurePerl'
21             || ref $_ eq 'Mail::DMARC::Result'
22             }
23             => message {"dmarc_object Must be a Mail::DMARC::PurePerl, Mail::DMARC::Result, or Undefined"};
24              
25             coerce 'Mail::BIMI::Options'
26             => from 'HashRef'
27             => via {
28             my $args = $_;
29             my $options = Mail::BIMI::Options->new;
30             foreach my $option ( sort keys $args->%* ) {
31             $options->$option($args->{$option});
32             }
33             return $options;
34             };
35              
36             has domain => ( is => 'rw', isa => 'Str', required => 0,
37             documentation => 'inputs: Domain to lookup/domain record was retrieved from', );
38             has selector => ( is => 'rw', isa => 'Str', lazy => 1, default => sub{ return 'default' },
39             documentation => 'inputs: Selector to lookup/selector record was retrieved from', );
40             has dmarc_object => ( is => 'rw', isa => 'MaybeDMARC',
41             documentation => 'inputs: Validated Mail::DMARC::PurePerl object from parsed message', );
42             has spf_object => ( is => 'rw', isa => 'Mail::SPF::Result',
43             documentation => 'inputs: Mail::SPF::Result object from parsed message', );
44             has dmarc_result_object => ( is => 'rw', isa => 'Maybe[Mail::DMARC::Result]', lazy => 1, builder => '_build_dmarc_result_object',
45             documentation => 'Relevant Mail::DMARC::Result object' );
46             has dmarc_pp_object => ( is => 'rw', isa => 'Maybe[Mail::DMARC::PurePerl]', lazy => 1, builder => '_build_dmarc_pp_object',
47             documentation => 'Relevant Mail::DMARC::PurePerl object' );
48             has record => ( is => 'rw', lazy => 1, builder => '_build_record',
49             documentation => 'Mail::BIMI::Record object' );
50             has resolver => ( is => 'rw', lazy => 1, builder => '_build_resolver',
51             documentation => 'inputs: Net::DNS::Resolver object to use for DNS lookups; default used if not set', );
52             has result => ( is => 'rw', lazy => 1, builder => '_build_result',
53             documentation => 'Mail::BIMI::Result object' );
54             has time => ( is => 'ro', lazy => 1, default => sub{return time},
55             documentation => 'time of retrieval - useful in testing' );
56             has options => ( is => 'rw', isa => 'Mail::BIMI::Options', default => sub{Mail::BIMI::Options->new}, coerce => 1,
57             documentation => 'Options class' );
58              
59              
60 7     7   17 sub _build_resolver($self) {
  7         15  
  7         17  
61 7 100       26 if (defined $Mail::BIMI::TestSuite::Resolver) {
62 6         145 return $Mail::BIMI::TestSuite::Resolver;
63             }
64 1         27 my $resolver = Net::DNS::Resolver->new(dnsrch => 0);
65 1         207 $resolver->tcp_timeout( $self->options->dns_client_timeout );
66 1         56 $resolver->udp_timeout( $self->options->dns_client_timeout );
67 1         51 return $resolver;
68             }
69              
70 22     22   50 sub _build_dmarc_result_object($self) {
  22         53  
  22         164  
71 22 100       634 return $self->dmarc_object->result if ref $self->dmarc_object eq 'Mail::DMARC::PurePerl';
72 15 100       382 return $self->dmarc_object if ref $self->dmarc_object eq 'Mail::DMARC::Result';
73 1         27 return;
74             }
75              
76 20     20   44 sub _build_dmarc_pp_object($self) {
  20         41  
  20         34  
77 20 100       546 return $self->dmarc_object if ref $self->dmarc_object eq 'Mail::DMARC::PurePerl';
78 13         69 $self->log_verbose('Building our own Mail::DMARC::PurePerl object');
79 13         125 my $dmarc = Mail::DMARC::PurePerl->new;
80 13         586 $dmarc->set_resolver($self->resolver);
81 13         422 $dmarc->header_from($self->domain);
82 13         423143 $dmarc->validate;
83 13         144373 return $dmarc;
84             }
85              
86 41     41   100 sub _build_record($self) {
  41         114  
  41         84  
87 41 100       1046 croak 'Domain required' if ! $self->domain;
88 40         924 return Mail::BIMI::Record->new( domain => $self->domain, selector => $self->selector, bimi_object => $self );
89             }
90              
91 20     20   71 sub _check_dmarc_enforcement_status($self,$dmarc,$result) {
  20         50  
  20         45  
  20         38  
  20         38  
92             # Set result and return true if there are any DMARC enforcement issues, Return false if there are none
93 20 50       77 if (exists $dmarc->result->{published}){
94 20   50     179 my $published_policy = $dmarc->result->published->p // '';
95 20   100     478 my $published_subdomain_policy = $dmarc->result->published->sp // '';
96 20   50     350 my $published_policy_pct = $dmarc->result->published->pct // 100;
97 20 100 66     409 my $effective_published_policy = ( $dmarc->is_subdomain && $published_subdomain_policy ) ? lc $published_subdomain_policy : lc $published_policy;
98 20 50 33     216 if ( $effective_published_policy eq 'quarantine' && $published_policy_pct ne '100' ) {
99 0         0 $result->set_result( Mail::BIMI::Error->new(code=>'DMARC_NOT_ENFORCING'));
100 0         0 return 1;
101             }
102 20 50 33     139 if ( $effective_published_policy ne 'quarantine' && $effective_published_policy ne 'reject' ) {
103 0         0 $result->set_result( Mail::BIMI::Error->new(code=>'DMARC_NOT_ENFORCING'));
104 0         0 return 1;
105             }
106 20 50 66     125 if ( $published_subdomain_policy && $published_subdomain_policy eq 'none' ) {
107 0         0 $result->set_result( Mail::BIMI::Error->new(code=>'DMARC_NOT_ENFORCING'));
108 0         0 return 1;
109             }
110             }
111             else {
112 0         0 $result->set_result( Mail::BIMI::Error->new(code=>'NO_DMARC'));
113 0         0 return 1;
114             }
115 20         110 return 0;
116             }
117              
118 21     21   51 sub _build_result($self) {
  21         229  
  21         44  
119 21 100       638 croak 'Domain required' if ! $self->domain;
120              
121 20         330 my $result = Mail::BIMI::Result->new(
122             bimi_object => $self,
123             headers => {},
124             );
125              
126             # does DMARC pass
127 20 100       31380 if ( ! $self->dmarc_result_object ) {
128 1         12 $result->set_result( Mail::BIMI::Error->new(code=>'NO_DMARC'));
129 1         7 return $result;
130             }
131 19 100       537 if ( $self->dmarc_result_object->result ne 'pass' ) {
132 1         42 $result->set_result( Mail::BIMI::Error->new(code=>'DMARC_NOT_PASS',detail=>$self->dmarc_result_object->result));
133 1         5 return $result;
134             }
135              
136             # Is DMARC enforcing?
137 18         667 my $dmarc = $self->dmarc_pp_object;
138 18 50       148 return $result if $self->_check_dmarc_enforcement_status($dmarc,$result);
139              
140             # Is Org DMARC Enforcing?
141 18         111 my $org_domain = Mail::DMARC::PurePerl->new->get_organizational_domain($self->domain);
142 18 100       1988 if ( lc $org_domain ne lc $self->domain ) {
143 2         13 my $org_dmarc = Mail::DMARC::PurePerl->new;
144 2         87 $org_dmarc->set_resolver($self->resolver);
145 2         18 $org_dmarc->header_from($org_domain);
146 2         629 $org_dmarc->validate;
147 2 50       18458 return $result if $self->_check_dmarc_enforcement_status($org_dmarc,$result);
148             }
149              
150             # Optionally check Author Domain SPF
151 18 100       510 if ( $self->options->strict_spf ) {
152 1 50       29 if ( $self->spf_object ) {
153 1         29 my $spf_request = $self->spf_object->request;
154 1 50       139 if ( $spf_request ) {
155 1         5 my $spf_record = $spf_request->record;
156 1 50       83 if ( $spf_record ) {
157 1         626 my @spf_terms = $spf_record->terms;
158 1 50       9 if ( @spf_terms ) {
159 1         3 my $last_term = pop @spf_terms;
160 1 50 33     9 if ( $last_term->name eq 'all' && $last_term->qualifier eq '+') {
161 1         23 $result->set_result( Mail::BIMI::Error->new(code=>'SPF_PLUS_ALL'));
162 1         8 return $result;
163             }
164             }
165             }
166             }
167             }
168             }
169              
170 17 100       531 if ( ! $self->record->is_valid ) {
171 10         28 my $has_error;
172             # Known errors, in order of importance
173 10         110 my @known_errors = qw{
174             NO_BIMI_RECORD
175             DNS_ERROR
176             NO_DMARC
177             MULTI_BIMI_RECORD
178             DUPLICATE_KEY
179             EMPTY_L_TAG
180             EMPTY_V_TAG
181             INVALID_V_TAG
182             MISSING_L_TAG
183             MISSING_V_TAG
184             MULTIPLE_AUTHORITIES
185             MULTIPLE_LOCATIONS
186             INVALID_TRANSPORT_A
187             INVALID_TRANSPORT_L
188             SPF_PLUS_ALL
189             SVG_FETCH_ERROR
190             VMC_FETCH_ERROR
191             VMC_EXPIRED
192             VMC_PARSE_ERROR
193             VMC_VALIDATION_ERROR
194             SVG_GET_ERROR
195             SVG_SIZE
196             SVG_UNZIP_ERROR
197             SVG_INVALID_XML
198             SVG_VALIDATION_ERROR
199             SVG_MISMATCH
200             VMC_REQUIRED
201             };
202 10         28 my $found_error = 0;
203              
204 10         37 foreach my $known_error (@known_errors) {
205 48 100       1109 if ( my ($error) = $self->record->filter_errors( $known_error ) ) {
206 10         34 $found_error = 1;
207 10         79 $result->set_result( $error );
208 10         27 last;
209             }
210             }
211 10 50       56 if ( !$found_error ) {
212 0         0 $result->set_result( Mail::BIMI::Error->new(code=>'BIMI_INVALID'));
213             }
214 10         276 return $result;
215             }
216              
217 7         58 $result->set_result( 'pass' );
218              
219 7         15 my @bimi_location;
220 7 50 33     182 if ( $self->record->authority && $self->record->authority->is_relevant ) {
221 0 0       0 push @bimi_location, ' l='.$self->record->location->uri if $self->record->location_is_relevant;
222 0         0 push @bimi_location, ' a='.$self->record->authority->uri;
223 0         0 $result->headers->{'BIMI-Indicator'} = $self->record->authority->vmc->indicator->header;
224             }
225             else {
226 7         244 push @bimi_location, ' l='.$self->record->location->uri;
227 7         174 $result->headers->{'BIMI-Indicator'} = $self->record->location->indicator->header;
228             }
229              
230 7         202 $result->headers->{'BIMI-Location'} = join( "\n",
231             'v=BIMI1;',
232             @bimi_location,
233             );
234              
235 7         173 return $result;
236             }
237              
238              
239 14     14 1 39 sub finish($self) {
  14         36  
  14         26  
240 14 50       534 $self->record->finish if $self->record;
241             }
242              
243              
244 283     283 1 4260 sub log_verbose($self,$text) {
  283         835  
  283         487  
  283         444  
245 283 100       6556 return unless $self->options->verbose;
246 1         132 warn "$text\n";
247             }
248              
249             1;
250              
251             __END__
252              
253             =pod
254              
255             =encoding UTF-8
256              
257             =head1 NAME
258              
259             Mail::BIMI - BIMI object
260              
261             =head1 VERSION
262              
263             version 3.20210225
264              
265             =head1 DESCRIPTION
266              
267             Brand Indicators for Message Identification (BIMI) retrieval, validation, and processing
268              
269             =head1 SYNOPSIS
270              
271             # Assuming we have a message, and have verified it has exactly one From Header domain, and passes
272             # any other BIMI and local site requirements not related to BIMI record validation...
273             # For example, relevant DKIM coverage of any BIMI-Selector header
274             my $message = ...Specifics of adding headers and Authentication-Results is left to the reader...
275              
276             my $domain = "example.com"; # domain from From header
277             my $selector = "default"; # selector from From header
278             my $spf = Mail::SPF->new( ...See Mail::SPF POD for options... );
279             my $dmarc = Mail::DMARC::PurePerl->new( ...See Mail::DMARC POD for options... );
280             $dmarc->validate;
281              
282             my $bimi = Mail::BIMI->new(
283             dmarc_object => $dmarc,
284             spf_object => $spf,
285             domain => $domain,
286             selector => $selector,
287             );
288              
289             my $auth_results = $bimi->get_authentication_results_object;
290             my $bimi_result = $bimi->result;
291              
292             $message->add_auth_results($auth_results); # See Mail::AuthenticationResults POD for usage
293              
294             if ( $bimi_result->result eq 'pass' ) {
295             my $headers = $result->headers;
296             if ($headers) {
297             $message->add_header( 'BIMI-Location', $headers->{'BIMI-Location'} if exists $headers->{'BIMI-Location'};
298             $message->add_header( 'BIMI-Indicator', $headers->{'BIMI-Indicator'} if exists $headers->{'BIMI-Indicator'};
299             }
300             }
301              
302             =head1 INPUTS
303              
304             These values are used as inputs for lookups and verifications, they are typically set by the caller based on values found in the message being processed
305              
306             =head2 dmarc_object
307              
308             is=rw
309              
310             Validated Mail::DMARC::PurePerl object from parsed message
311              
312             =head2 domain
313              
314             is=rw
315              
316             Domain to lookup/domain record was retrieved from
317              
318             =head2 resolver
319              
320             is=rw
321              
322             Net::DNS::Resolver object to use for DNS lookups; default used if not set
323              
324             =head2 selector
325              
326             is=rw
327              
328             Selector to lookup/selector record was retrieved from
329              
330             =head2 spf_object
331              
332             is=rw
333              
334             Mail::SPF::Result object from parsed message
335              
336             =head1 ATTRIBUTES
337              
338             These values are derived from lookups and verifications made based upon the input values, it is however possible to override these with other values should you wish to, for example, validate a record before it is published in DNS, or validate an Indicator which is only available locally
339              
340             =head2 dmarc_pp_object
341              
342             is=rw
343              
344             Relevant Mail::DMARC::PurePerl object
345              
346             =head2 dmarc_result_object
347              
348             is=rw
349              
350             Relevant Mail::DMARC::Result object
351              
352             =head2 errors
353              
354             is=rw
355              
356             =head2 options
357              
358             is=rw
359              
360             Options class
361              
362             =head2 record
363              
364             is=rw
365              
366             Mail::BIMI::Record object
367              
368             =head2 result
369              
370             is=rw
371              
372             Mail::BIMI::Result object
373              
374             =head2 time
375              
376             is=ro
377              
378             time of retrieval - useful in testing
379              
380             =head2 warnings
381              
382             is=rw
383              
384             =head1 CONSUMES
385              
386             =over 4
387              
388             =item * L<Mail::BIMI::Role::HasError>
389              
390             =back
391              
392             =head1 EXTENDS
393              
394             =over 4
395              
396             =item * L<Moose::Object>
397              
398             =back
399              
400             =head1 METHODS
401              
402             =head2 I<finish()>
403              
404             Finish and clean up, write cache if enabled.
405              
406             =head2 I<log_verbose()>
407              
408             Output given text if in verbose mode.
409              
410             =head1 REQUIRES
411              
412             =over 4
413              
414             =item * L<Mail::BIMI::Options|Mail::BIMI::Options>
415              
416             =item * L<Mail::BIMI::Prelude|Mail::BIMI::Prelude>
417              
418             =item * L<Mail::BIMI::Record|Mail::BIMI::Record>
419              
420             =item * L<Mail::BIMI::Result|Mail::BIMI::Result>
421              
422             =item * L<Mail::DMARC::PurePerl|Mail::DMARC::PurePerl>
423              
424             =item * L<Moose|Moose>
425              
426             =item * L<Moose::Util::TypeConstraints|Moose::Util::TypeConstraints>
427              
428             =item * L<Net::DNS::Resolver|Net::DNS::Resolver>
429              
430             =back
431              
432             =head1 AUTHOR
433              
434             Marc Bradshaw <marc@marcbradshaw.net>
435              
436             =head1 COPYRIGHT AND LICENSE
437              
438             This software is copyright (c) 2020 by Marc Bradshaw.
439              
440             This is free software; you can redistribute it and/or modify it under
441             the same terms as the Perl 5 programming language system itself.
442              
443             =cut