File Coverage

lib/Mail/DMARC/PurePerl.pm
Criterion Covered Total %
statement 267 296 90.2
branch 143 208 68.7
condition 33 59 55.9
subroutine 21 21 100.0
pod 13 17 76.4
total 477 601 79.3


line stmt bran cond sub pod time code
1             package Mail::DMARC::PurePerl;
2             our $VERSION = '1.20210927';
3 6     6   401026 use strict;
  6         27  
  6         188  
4 6     6   31 use warnings;
  6         12  
  6         171  
5              
6 6     6   30 use Carp;
  6         11  
  6         414  
7              
8 6     6   907 use parent 'Mail::DMARC';
  6         611  
  6         39  
9              
10             sub init {
11 28     28 1 9090 my $self = shift;
12 28         155 $self->is_subdomain(0);
13 28         73 $self->{header_from} = undef;
14 28         65 $self->{header_from_raw} = undef;
15 28         58 $self->{envelope_to} = undef;
16 28         99 $self->{envelope_from} = undef;
17 28         55 $self->{source_ip} = undef;
18 28         101 $self->{policy} = undef;
19 28         118 $self->{result} = undef;
20 28         110 $self->{report} = undef;
21 28         195 $self->{spf} = undef;
22 28         86 $self->{dkim} = undef;
23 28         66 return;
24             }
25              
26             sub validate {
27 15     15 1 734 my $self = shift;
28 15         28 my $policy = shift;
29              
30 15         58 $self->result->result('fail'); # set a couple
31 15         55 $self->result->disposition('none'); # defaults
32              
33             # 11.2.1 Extract RFC5322.From domain
34 15 50       64 my $from_dom = $self->get_from_dom() or return $self->result;
35             # 9.6. reject email if the domain appears to not exist
36 15 100       64 $self->exists_in_dns() or return $self->result;
37 14   100     85 $policy ||= $self->discover_policy(); # 11.2.2 Query DNS for DMARC policy
38 14 100       68 $policy or return $self->result;
39              
40             # 3.5 Out of Scope DMARC has no "short-circuit" provision, such as
41             # specifying that a pass from one authentication test allows one
42             # to skip the other(s). All are required for reporting.
43              
44 13         28 eval { $self->is_dkim_aligned; }; # 11.2.3. DKIM signature verification checks
  13         80  
45 13         51 eval { $self->is_spf_aligned; }; # 11.2.4. SPF validation checks
  13         185  
46 13         58 my $aligned = $self->is_aligned(); # 11.2.5. identifier alignment checks
47              
48 13 50       53 if ($self->config->{report_store}{auto_save}) {
49 0         0 eval { $self->save_aggregate(); };
  0         0  
50             }
51              
52 13 100       54 return $self->result if $aligned;
53              
54 8 50 66     36 my $effective_p
55             = $self->is_subdomain && defined $policy->sp
56             ? $policy->sp
57             : $policy->p;
58              
59             # 11.2.6 Apply policy. Emails that fail the DMARC mechanism check are
60             # disposed of in accordance with the discovered DMARC policy of the
61             # Domain Owner. See Section 6.2 for details.
62 8 100       35 if ( lc $effective_p eq 'none' ) {
63 1         3 return $self->result;
64             }
65              
66 7 50       30 return $self->result if $self->is_whitelisted;
67              
68             # 7.1. Policy Fallback Mechanism
69             # If the "pct" tag is present in a policy record, application of policy
70             # is done on a selective basis.
71 7 100       37 if ( !defined $policy->pct ) {
72 6         21 $self->result->disposition($effective_p);
73 6         20 return $self->result;
74             }
75              
76             # The stated percentage of messages that fail the DMARC test MUST be
77             # subjected to whatever policy is selected by the "p" or "sp" tag
78 1 50       6 if ( int( rand(100) ) < $policy->pct ) {
79 0         0 $self->result->disposition($effective_p);
80 0         0 return $self->result;
81             }
82              
83 1         6 $self->result->reason( type => 'sampled_out' );
84              
85             # Those that are not thus selected MUST instead be subjected to the next
86             # policy lower in terms of severity. In decreasing order of severity,
87             # the policies are "reject", "quarantine", and "none".
88 1 50       4 $self->result->disposition(
89             ( $effective_p eq 'reject' ) ? 'quarantine' : 'none' );
90 1         5 return $self->result;
91             }
92              
93             sub save_aggregate {
94 5     5 0 34 my ( $self ) = @_;
95              
96 5         15 my $pol;
97 5         8 eval { $pol = $self->result->published; };
  5         21  
98 5 50 33     60 if ( $pol && $self->has_valid_reporting_uri($pol->rua) ) {
99 5         30 my @valid_report_uris = $self->get_valid_reporting_uri($pol->rua);
100              
101             my $filtered_report_uris = join( ',',
102 5 50       19 map { $_->{'uri'} . ( ( $_->{'max_bytes'} > 0 ) ? ( '!' . $_->{'max_bytes'} ) : q{} ) }
  5         35  
103             @valid_report_uris
104             );
105              
106 5         50 $self->result->published->rua( $filtered_report_uris );
107              
108 5         40 return $self->SUPER::save_aggregate();
109             }
110 0         0 return;
111             }
112              
113             sub discover_policy {
114 11     11 1 59 my $self = shift;
115 11 50 33     73 my $from_dom = shift || $self->header_from or croak;
116 11 50       54 print "Header From: $from_dom\n" if $self->verbose;
117 11         55 my $org_dom = $self->get_organizational_domain($from_dom);
118              
119             # 9.1 Mail Receivers MUST query the DNS for a DMARC TXT record
120 11         72 my ($matches, $at_dom) = $self->fetch_dmarc_record( $from_dom, $org_dom );
121 11 100       72 if (0 == scalar @$matches ) {
122 1         7 $self->result->result('none');
123 1         3 $self->result->reason( type => 'other', comment => 'no policy' );
124 1         6 return;
125             };
126              
127             # 9.5. If the remaining set contains multiple records, processing
128             # terminates and the Mail Receiver takes no action.
129 10 50       79 if ( scalar @$matches > 1 ) {
130 0         0 $self->result->reason( type => 'other', comment => "too many policies" );
131 0 0       0 print "Too many DMARC records\n" if $self->verbose;
132 0         0 return;
133             }
134              
135 10         25 my $policy;
136 10 50       45 if (!$at_dom) { $at_dom = $from_dom; }
  0         0  
137 10         57 my $policy_str = "domain=$at_dom;" . $matches->[0]; # prefix with domain
138 10 50       31 eval { $policy = $self->policy( $policy_str ) } or return;
  10         104  
139 10 50       44 if ($@) {
140 0         0 $self->result->reason( type => 'other', comment => "policy parse error: $@" );
141 0         0 return;
142             };
143 10         58 $self->result->published($policy);
144              
145             # 9.6 If a retrieved policy record does not contain a valid "p" tag, or
146             # contains an "sp" tag that is not valid, then:
147 10 50 33     67 if ( !$policy->p
      33        
      33        
148             || !$policy->is_valid_p( $policy->p )
149             || ( defined $policy->sp && !$policy->is_valid_p( $policy->sp ) ) )
150             {
151              
152             # A. if an "rua" tag is present and contains at least one
153             # syntactically valid reporting URI, the Mail Receiver SHOULD
154             # act as if a record containing a valid "v" tag and "p=none"
155             # was retrieved, and continue processing;
156             # B. otherwise, the Mail Receiver SHOULD take no action.
157 0 0 0     0 if ( !$policy->rua
158             || !$self->has_valid_reporting_uri( $policy->rua ) )
159             {
160 0         0 $self->result->reason( type => 'other', comment => "no valid rua" );
161 0         0 return;
162             }
163 0         0 $policy->v('DMARC1');
164 0         0 $policy->p('none');
165             }
166              
167 10         68 return $policy;
168             }
169              
170             sub is_aligned {
171 21     21 1 48 my $self = shift;
172              
173             # 11.2.5 Conduct identifier alignment checks. With authentication checks
174             # and policy discovery performed, the Mail Receiver checks if
175             # Authenticated Identifiers fall into alignment as decribed in
176             # Section 4. If one or more of the Authenticated Identifiers align
177             # with the RFC5322.From domain, the message is considered to pass
178             # the DMARC mechanism check. All other conditions (authentication
179             # failures, identifier mismatches) are considered to be DMARC
180             # mechanism check failures.
181              
182 21 100 100     66 if ( 'pass' eq $self->result->spf
183             || 'pass' eq $self->result->dkim )
184             {
185 8         25 $self->result->result('pass');
186 8         24 $self->result->disposition('none');
187 8         30 return 1;
188             }
189 13         51 return 0;
190             }
191              
192             sub is_dkim_aligned {
193 24     24 1 42 my $self = shift;
194              
195 24         92 $self->result->dkim('fail'); # our 'default' result
196 24 100       102 $self->get_dkim_pass_sigs() or return;
197              
198             # 11.2.3 Perform DKIM signature verification checks. A single email may
199             # contain multiple DKIM signatures. The results MUST include the
200             # value of the "d=" tag from all DKIM signatures that validated.
201              
202 17 50       136 my $from_dom = $self->header_from or croak "header_from not set!";
203 17 50       55 my $policy = $self->policy or croak "no policy!?";
204 17         48 my $from_org = $self->get_organizational_domain();
205              
206             # Required in report: DKIM-Domain, DKIM-Identity, DKIM-Selector
207 17         55 foreach my $dkim_ref ( $self->get_dkim_pass_sigs() ) {
208 19         57 my $dkim_dom = lc $dkim_ref->{domain};
209              
210             my $dkmeta = {
211             domain => $dkim_ref->{domain},
212             selector => $dkim_ref->{selector},
213 19         80 identity => '', # TODO, what is this?
214             };
215              
216 19 100       56 if ( $dkim_dom eq $from_dom ) { # strict alignment requires exact match
217 4         17 $self->result->dkim('pass');
218 4         43 $self->result->dkim_align('strict');
219 4         16 $self->result->dkim_meta($dkmeta);
220 4         11 last;
221             }
222              
223             # don't try relaxed if policy specifies strict
224 15 100 66     89 next if $policy->adkim && 's' eq lc $policy->adkim;
225              
226             # don't try relaxed if we already got a strict match
227 7 50       20 next if 'pass' eq $self->result->dkim;
228              
229             # relaxed policy (default): Org. Dom must match a DKIM sig
230 7         22 my $dkim_org = $self->get_organizational_domain($dkim_dom);
231 7 100       32 if ( $dkim_org eq $from_org ) {
232 2         13 $self->result->dkim('pass');
233 2         17 $self->result->dkim_align('relaxed');
234 2         11 $self->result->dkim_meta($dkmeta);
235             }
236             }
237 17 100       54 return 1 if 'pass' eq lc $self->result->dkim;
238 11         92 return;
239             }
240              
241             sub is_spf_aligned {
242 21     21 1 51 my $self = shift;
243 21         38 my $spf_dom = shift;
244              
245 21 50 33     127 if ( !$spf_dom && !$self->spf ) { croak "missing SPF!"; }
  0         0  
246 21 50       76 if ( !$spf_dom ) {
247 21 50       42 my @passes = grep { $_->{result} && $_->{result} =~ /pass/i } @{ $self->spf };
  29         275  
  21         51  
248 21 50       89 if (scalar @passes == 0) {
249 0         0 $self->result->spf('fail');
250 0         0 return 0;
251             };
252 21 50       48 my ($ref) = grep { $_->{scope} && $_->{scope} eq 'mfrom' } @passes;
  24         154  
253 21 50       94 if (!$ref) {
254 0 0       0 ($ref) = grep { $_->{scope} && $_->{scope} eq 'helo' } @passes;
  0         0  
255             }
256 21 50       61 if (!$ref) { ($ref) = $passes[0]; };
  0         0  
257 21         71 $spf_dom = $ref->{domain};
258             };
259              
260             # 11.2.4 Perform SPF validation checks. The results of this step
261             # MUST include the domain name from the RFC5321.MailFrom if SPF
262             # evaluation returned a "pass" result.
263              
264 21         69 $self->result->spf('fail');
265 21 50       67 return 0 if !$spf_dom;
266              
267 21 50       77 my $from_dom = $self->header_from or croak "header_from not set!";
268              
269 21 100       72 if ( $spf_dom eq $from_dom ) {
270 5         17 $self->result->spf('pass');
271 5         17 $self->result->spf_align('strict');
272 5         20 return 1;
273             }
274              
275             # don't try relaxed match if strict policy requested
276 16 100 100     60 if ( $self->policy->aspf && 's' eq lc $self->policy->aspf ) {
277 8         28 return 0;
278             }
279              
280 8 100       40 if ( $self->get_organizational_domain($spf_dom) eq
281             $self->get_organizational_domain($from_dom) )
282             {
283 3         14 $self->result->spf('pass');
284 3         18 $self->result->spf_align('relaxed');
285 3         18 return 1;
286             }
287 5         25 return 0;
288             }
289              
290             sub is_whitelisted {
291 11     11 0 1968 my $self = shift;
292 11   100     67 my $s_ip = shift || $self->source_ip;
293 11 100       76 return if ! defined $s_ip;
294 8 100       29 if ( ! $self->{_whitelist} ) {
295 5 50       18 my $white_file = $self->config->{smtp}{whitelist} or return;
296 5 50 33     253 return if ! -f $white_file || ! -r $white_file;
297 5         57 foreach my $line ( split /\n/, $self->slurp($white_file) ) {
298 30 100       105 next if $line =~ /^#/; # ignore comments
299 10         62 my ($lip,$reason) = split /\s+/, $line, 2;
300 10         50 $self->{_whitelist}{$lip} = $reason;
301             };
302             };
303 8 100       55 return if ! $self->{_whitelist}{$s_ip};
304              
305 2         9 my ($type, $comment) = split /\s+/, $self->{_whitelist}{$s_ip}, 2;
306 2         9 $self->result->disposition('none');
307 2 100 66     4 $self->result->reason(
308             type => $type,
309             ($comment && $comment =~ /\S/ ? ('comment' => $comment) : () ),
310             );
311 2         14 return $type;
312             }
313              
314             sub has_valid_reporting_uri {
315 13     13 1 3094 my ( $self, $rua ) = @_;
316 13         63 my @valid_reporting_uris = $self->get_valid_reporting_uri( $rua );
317 13         96 return scalar @valid_reporting_uris;
318             }
319              
320             sub get_valid_reporting_uri {
321 18     18 0 39 my ( $self, $rua ) = @_;
322 18 50       56 return unless $rua;
323 18         97 my $recips_ref = $self->report->uri->parse($rua);
324 18         34 my @has_permission;
325 18         39 foreach my $uri_ref (@$recips_ref) {
326 16 100       80 if ( !$self->external_report( $uri_ref->{uri} ) ) {
327 13         29 push @has_permission, $uri_ref;
328 13         86 next;
329             }
330 3         15 my $ext = $self->verify_external_reporting($uri_ref);
331 3 100       18 push @has_permission, $uri_ref if $ext;
332             }
333 18         70 return @has_permission;
334             }
335              
336             sub get_dkim_pass_sigs {
337 41     41 0 68 my $self = shift;
338              
339 41 50       130 my $dkim_sigs = $self->dkim or return (); # message not signed
340              
341 41 50       164 if ( 'ARRAY' ne ref $dkim_sigs ) {
342 0         0 croak "dkim needs to be an array reference!";
343             }
344              
345 41         77 return grep { 'pass' eq lc $_->{result} } @$dkim_sigs;
  57         234  
346             }
347              
348             sub get_organizational_domain {
349 108     108 1 5594 my $self = shift;
350 108 50 66     385 my $from_dom = shift || $self->header_from
351             or croak "missing header_from!";
352              
353             # 4.1 Acquire a "public suffix" list, i.e., a list of DNS domain
354             # names reserved for registrations. http://publicsuffix.org/list/
355              
356             # 4.2 Break the subject DNS domain name into a set of "n" ordered
357             # labels. Number these labels from right-to-left; e.g. for
358             # "example.com", "com" would be label 1 and "example" would be
359             # label 2.;
360 108         563 my @labels = reverse split /\./, lc $from_dom;
361              
362             # 4.3 Search the public suffix list for the name that matches the
363             # largest number of labels found in the subject DNS domain. Let
364             # that number be "x".
365 108         228 my $greatest = 0;
366 108         393 for ( my $i = 0; $i <= scalar @labels; $i++ ) {
367 363 100       869 next if !$labels[$i];
368 255         737 my $tld = join '.', reverse( (@labels)[ 0 .. $i ] );
369              
370 255 100       697 if ( $self->is_public_suffix($tld) ) {
371 110         379 $greatest = $i + 1;
372             }
373             }
374              
375 108 100       272 if ( $greatest == scalar @labels ) { # same
376 2         5 return $from_dom;
377             }
378              
379             # 4.4 Construct a new DNS domain name using the name that matched
380             # from the public suffix list and prefixing to it the "x+1"th
381             # label from the subject domain. This new name is the
382             # Organizational Domain.
383 106         336 my $org_dom = join '.', reverse( (@labels)[ 0 .. $greatest ] );
384 106 50       305 print "Organizational Domain: $org_dom\n" if $self->verbose;
385 106         423 return $org_dom;
386             }
387              
388             sub exists_in_dns {
389 18     18 1 56 my $self = shift;
390 18 50 66     93 my $from_dom = shift || $self->header_from or croak "no header_from!";
391              
392             # rfc7489 6.6.3
393             # If the set produced by the mechanism above contains no DMARC policy
394             # record (i.e., any indication that there is no such record as opposed
395             # to a transient DNS error), Mail Receivers SHOULD NOT apply the DMARC
396             # mechanism to the message.
397              
398 18         73 my $org_dom = $self->get_organizational_domain($from_dom);
399 18         67 my @todo = $from_dom;
400 18 100       72 if ( $from_dom ne $org_dom ) {
401 9         21 push @todo, $org_dom;
402 9         44 $self->is_subdomain(1);
403             }
404 18         45 my $matched = 0;
405 18         62 foreach (@todo) {
406 27 100       106 last if $matched;
407 22 100 50     129 $matched++ and next if $self->has_dns_rr( 'MX', $_ );
408 22 100 50     118 $matched++ and next if $self->has_dns_rr( 'NS', $_ );
409 12 100 50     65 $matched++ and next if $self->has_dns_rr( 'A', $_ );
410 12 100 50     124 $matched++ and next if $self->has_dns_rr( 'AAAA', $_ );
411             }
412 18 100       95 if ( !$matched ) {
413 2         30 $self->result->result('none');
414 2         13 $self->result->disposition('none');
415 2         22 $self->result->reason(
416             type => 'other',
417             comment => "$from_dom not in DNS"
418             );
419             }
420 18         100 return $matched;
421             }
422              
423             sub fetch_dmarc_record {
424 15     15 1 2944 my ( $self, $zone, $org_dom ) = @_;
425              
426             # 1. Mail Receivers MUST query the DNS for a DMARC TXT record at the
427             # DNS domain matching the one found in the RFC5322.From domain in
428             # the message. A possibly empty set of records is returned.
429 15 100       112 $self->is_subdomain( defined $org_dom ? 0 : 1 );
430 15         39 my @matches = ();
431 15 50       83 my $query = $self->get_resolver->send( "_dmarc.$zone", 'TXT' )
432             or return (\@matches, $zone);
433 15         783044 for my $rr ( $query->answer ) {
434 12 100       201 next if $rr->type ne 'TXT';
435              
436             # 2. Records that do not start with a "v=" tag that identifies the
437             # current version of DMARC are discarded.
438 11 50       279 next if 'v=dmarc1' ne lc substr( $rr->txtdata, 0, 8 );
439 11 50       860 print "\n" . $rr->txtdata . "\n\n" if $self->verbose;
440 11         67 push @matches, join( '', $rr->txtdata ); # join long records
441             }
442 15 100       534 if (scalar @matches) {
443 11         219 return \@matches, $zone; # found one! (at least)
444             }
445              
446             # 3. If the set is now empty, the Mail Receiver MUST query the DNS for
447             # a DMARC TXT record at the DNS domain matching the Organizational
448             # Domain in place of the RFC5322.From domain in the message (if
449             # different). This record can contain policy to be asserted for
450             # subdomains of the Organizational Domain.
451 4 100       20 if ( defined $org_dom ) { # <- recursion break
452 2 100       9 if ( $org_dom ne $zone ) {
453 1         7 return $self->fetch_dmarc_record($org_dom); # <- recursion
454             }
455             }
456              
457 3         100 return \@matches, $zone;
458             }
459              
460             sub get_from_dom {
461 25     25 1 103 my ($self) = @_;
462 25 100       70 return $self->header_from if $self->header_from;
463              
464 10 50       21 my $header = $self->header_from_raw or do {
465 0         0 $self->result->reason( type => 'other', comment => "no header_from" );
466 0         0 return;
467             };
468              
469             # TODO: the From header can contain multiple addresses and should be
470             # parsed as described in RFC 2822. If From has multiple-addresses,
471             # then parse and use the domain in the Sender header.
472              
473             # This returns only the domain in the last email address.
474             # Caller can pass in pre-parsed from_dom if this doesn't suit them.
475             #
476             # I care only about the domain. This is way faster than RFC2822 parsing
477              
478 10         34 my ($from_dom) = ( split /@/, $header )[-1]; # grab everything after the @
479 10         61 ($from_dom) = split /(\s+|>)/, lc $from_dom; # remove trailing cruft
480 10 50       23 if ( !$from_dom ) {
481 0         0 $self->result->reason(
482             type => 'other',
483             comment => "invalid header_from: ($header)"
484             );
485 0         0 return;
486             }
487 10         20 return $self->header_from($from_dom);
488             }
489              
490             sub external_report {
491 20     20 1 1619 my ( $self, $uri ) = @_;
492 20 50       75 my $dmarc_dom = $self->result->published->domain
493             or croak "published policy not tagged!";
494              
495 20 100       70 if ( 'mailto' eq $uri->scheme ) {
496 17         337 my $dest_email = lc $uri->path;
497 17         446 my ($dest_host) = ( split /@/, $dest_email )[-1];
498 17 100       62 if ( $self->get_organizational_domain( $dest_host )
499             eq
500             $self->get_organizational_domain( $dmarc_dom )
501             ) {
502 13 50       41 print "$dest_host not external for $dmarc_dom\n" if $self->verbose;
503 13         50 return 0;
504             };
505 4 50       15 print "$dest_host is external for $dmarc_dom\n" if $self->verbose;
506             }
507              
508 7 100       75 if ( 'http' eq $uri->scheme ) {
509 3 100       70 if ($uri->host eq $dmarc_dom ) {
510 2 50       1653 print $uri->host ." not external for $dmarc_dom\n" if $self->verbose;
511 2         11 return 0;
512             };
513 1 50       52 print $uri->host ." is external for $dmarc_dom\n" if $self->verbose;
514             }
515              
516 5         106 return 1;
517             }
518              
519             sub verify_external_reporting {
520 6     6 1 254 my $self = shift;
521 6 50       21 my $uri_ref = shift or croak "missing URI";
522              
523             # 1. Extract the host portion of the authority component of the URI.
524             # Call this the "destination host".
525 6 50       25 my $dmarc_dom = $self->result->published->domain
526             or croak "published policy not tagged!";
527              
528 6 50       36 my $dest_email = $uri_ref->{uri}->path or croak("invalid URI");
529 6         157 my ($dest_host) = ( split /@/, $dest_email )[-1];
530              
531             # 2. Prepend the string "_report._dmarc".
532             # 3. Prepend the domain name from which the policy was retrieved,
533             # after conversion to an A-label if needed.
534 6         32 my $dest = join '.', $dmarc_dom, '_report._dmarc', $dest_host;
535              
536             # 4. Query the DNS for a TXT record at the constructed name.
537 6 50       42 my $query = $self->get_resolver->send( $dest, 'TXT' ) or do {
538 0 0       0 print "\tquery for $dest failed\n" if $self->verbose;
539 0         0 return;
540             };
541              
542             # 5. For each record, parse the result...same overall format:
543             # "v=DMARC1" tag is mandatory and MUST appear first in the list.
544 6         697663 my @matches;
545 6         39 for my $rr ( $query->answer ) {
546 5 50       83 next if $rr->type ne 'TXT';
547              
548 5 50       139 next if 'v=dmarc1' ne lc substr( $rr->txtdata, 0, 8 );
549 5         459 my $policy = undef;
550 5         27 my $dmarc_str = join( '', $rr->txtdata ); # join parts
551 5         168 eval { $policy = $self->policy->parse($dmarc_str) }; ## no critic (Eval)
  5         49  
552 5 50       34 push @matches, $policy ? $policy : $dmarc_str;
553             }
554              
555             # 6. If the result includes no TXT resource records...stop
556 6 100       38 if ( !scalar @matches ) {
557 1 50       9 print "\tno TXT match for $dest\n" if $self->verbose;
558 1         11 return;
559             };
560              
561             # 7. If > 1 TXT resource record remains, external reporting authorized
562             # 8. If a "rua" or "ruf" tag is discovered, replace the
563             # corresponding value with the one found in this record.
564 5 50       17 my @overrides = grep { ref $_ && $_->{rua} } @matches;
  5         48  
565 5         19 foreach my $or (@overrides) {
566 3 50       22 my $recips_ref = $self->report->uri->parse( $or->{rua} ) or next;
567 3 50       17 if ( ( split /@/, $recips_ref->[0]{uri} )[-1] eq
568             ( split /@/, $uri_ref->{uri} )[-1] )
569             {
570             # the overriding URI MUST use the same destination host from the first step.
571 3 50       73 print "found override RUA: $or->{rua}\n" if $self->verbose;
572 3         18 $self->result->published->rua( $or->{rua} );
573             }
574             }
575              
576 5         103 return @matches;
577             }
578              
579             1;
580              
581             __END__