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.20211209';
3 6     6   431613 use strict;
  6         28  
  6         204  
4 6     6   33 use warnings;
  6         10  
  6         176  
5              
6 6     6   33 use Carp;
  6         9  
  6         422  
7              
8 6     6   959 use parent 'Mail::DMARC';
  6         733  
  6         40  
9              
10             sub init {
11 28     28 1 8447 my $self = shift;
12 28         163 $self->is_subdomain(0);
13 28         79 $self->{header_from} = undef;
14 28         65 $self->{header_from_raw} = undef;
15 28         61 $self->{envelope_to} = undef;
16 28         104 $self->{envelope_from} = undef;
17 28         61 $self->{source_ip} = undef;
18 28         85 $self->{policy} = undef;
19 28         122 $self->{result} = undef;
20 28         134 $self->{report} = undef;
21 28         227 $self->{spf} = undef;
22 28         105 $self->{dkim} = undef;
23 28         71 return;
24             }
25              
26             sub validate {
27 15     15 1 463 my $self = shift;
28 15         38 my $policy = shift;
29              
30 15         62 $self->result->result('fail'); # set a couple
31 15         64 $self->result->disposition('none'); # defaults
32              
33             # 11.2.1 Extract RFC5322.From domain
34 15 50       78 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       66 $self->exists_in_dns() or return $self->result;
37 14   100     109 $policy ||= $self->discover_policy(); # 11.2.2 Query DNS for DMARC policy
38 14 100       62 $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         36 eval { $self->is_dkim_aligned; }; # 11.2.3. DKIM signature verification checks
  13         83  
45 13         38 eval { $self->is_spf_aligned; }; # 11.2.4. SPF validation checks
  13         385  
46 13         71 my $aligned = $self->is_aligned(); # 11.2.5. identifier alignment checks
47              
48 13 50       214 if ($self->config->{report_store}{auto_save}) {
49 0         0 eval { $self->save_aggregate(); };
  0         0  
50             }
51              
52 13 100       72 return $self->result if $aligned;
53              
54 8 50 66     57 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       66 if ( lc $effective_p eq 'none' ) {
63 1         5 return $self->result;
64             }
65              
66 7 50       37 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       62 if ( !defined $policy->pct ) {
72 6         39 $self->result->disposition($effective_p);
73 6         28 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       21 if ( int( rand(100) ) < $policy->pct ) {
79 0         0 $self->result->disposition($effective_p);
80 0         0 return $self->result;
81             }
82              
83 1         8 $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       7 $self->result->disposition(
89             ( $effective_p eq 'reject' ) ? 'quarantine' : 'none' );
90 1         11 return $self->result;
91             }
92              
93             sub save_aggregate {
94 5     5 0 38 my ( $self ) = @_;
95              
96 5         11 my $pol;
97 5         12 eval { $pol = $self->result->published; };
  5         20  
98 5 50 33     53 if ( $pol && $self->has_valid_reporting_uri($pol->rua) ) {
99 5         27 my @valid_report_uris = $self->get_valid_reporting_uri($pol->rua);
100              
101             my $filtered_report_uris = join( ',',
102 5 50       22 map { $_->{'uri'} . ( ( $_->{'max_bytes'} > 0 ) ? ( '!' . $_->{'max_bytes'} ) : q{} ) }
  5         53  
103             @valid_report_uris
104             );
105              
106 5         61 $self->result->published->rua( $filtered_report_uris );
107              
108 5         57 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     85 my $from_dom = shift || $self->header_from or croak;
116 11 50       62 print "Header From: $from_dom\n" if $self->verbose;
117 11         72 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         64 my ($matches, $at_dom) = $self->fetch_dmarc_record( $from_dom, $org_dom );
121 11 100       65 if (0 == scalar @$matches ) {
122 1         12 $self->result->result('none');
123 1         4 $self->result->reason( type => 'other', comment => 'no policy' );
124 1         9 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       68 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         27 my $policy;
136 10 50       37 if (!$at_dom) { $at_dom = $from_dom; }
  0         0  
137 10         52 my $policy_str = "domain=$at_dom;" . $matches->[0]; # prefix with domain
138 10 50       27 eval { $policy = $self->policy( $policy_str ) } or return;
  10         95  
139 10 50       43 if ($@) {
140 0         0 $self->result->reason( type => 'other', comment => "policy parse error: $@" );
141 0         0 return;
142             };
143 10         65 $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     58 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         70 return $policy;
168             }
169              
170             sub is_aligned {
171 21     21 1 67 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     84 if ( 'pass' eq $self->result->spf
183             || 'pass' eq $self->result->dkim )
184             {
185 8         29 $self->result->result('pass');
186 8         22 $self->result->disposition('none');
187 8         33 return 1;
188             }
189 13         106 return 0;
190             }
191              
192             sub is_dkim_aligned {
193 24     24 1 65 my $self = shift;
194              
195 24         117 $self->result->dkim('fail'); # our 'default' result
196 24 100       106 $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       119 my $from_dom = $self->header_from or croak "header_from not set!";
203 17 50       73 my $policy = $self->policy or croak "no policy!?";
204 17         67 my $from_org = $self->get_organizational_domain();
205              
206             # Required in report: DKIM-Domain, DKIM-Identity, DKIM-Selector
207 17         64 foreach my $dkim_ref ( $self->get_dkim_pass_sigs() ) {
208 19         75 my $dkim_dom = lc $dkim_ref->{domain};
209              
210             my $dkmeta = {
211             domain => $dkim_ref->{domain},
212             selector => $dkim_ref->{selector},
213 19         121 identity => '', # TODO, what is this?
214             };
215              
216 19 100       67 if ( $dkim_dom eq $from_dom ) { # strict alignment requires exact match
217 4         18 $self->result->dkim('pass');
218 4         20 $self->result->dkim_align('strict');
219 4         16 $self->result->dkim_meta($dkmeta);
220 4         10 last;
221             }
222              
223             # don't try relaxed if policy specifies strict
224 15 100 66     92 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       22 next if 'pass' eq $self->result->dkim;
228              
229             # relaxed policy (default): Org. Dom must match a DKIM sig
230 7         19 my $dkim_org = $self->get_organizational_domain($dkim_dom);
231 7 100       28 if ( $dkim_org eq $from_org ) {
232 2         14 $self->result->dkim('pass');
233 2         13 $self->result->dkim_align('relaxed');
234 2         10 $self->result->dkim_meta($dkmeta);
235             }
236             }
237 17 100       70 return 1 if 'pass' eq lc $self->result->dkim;
238 11         141 return;
239             }
240              
241             sub is_spf_aligned {
242 21     21 1 74 my $self = shift;
243 21         48 my $spf_dom = shift;
244              
245 21 50 33     149 if ( !$spf_dom && !$self->spf ) { croak "missing SPF!"; }
  0         0  
246 21 50       79 if ( !$spf_dom ) {
247 21 50       46 my @passes = grep { $_->{result} && $_->{result} =~ /pass/i } @{ $self->spf };
  29         317  
  21         80  
248 21 50       106 if (scalar @passes == 0) {
249 0         0 $self->result->spf('fail');
250 0         0 return 0;
251             };
252 21 50       57 my ($ref) = grep { $_->{scope} && $_->{scope} eq 'mfrom' } @passes;
  24         156  
253 21 50       79 if (!$ref) {
254 0 0       0 ($ref) = grep { $_->{scope} && $_->{scope} eq 'helo' } @passes;
  0         0  
255             }
256 21 50       89 if (!$ref) { ($ref) = $passes[0]; };
  0         0  
257 21         68 $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         89 $self->result->spf('fail');
265 21 50       72 return 0 if !$spf_dom;
266              
267 21 50       96 my $from_dom = $self->header_from or croak "header_from not set!";
268              
269 21 100       79 if ( $spf_dom eq $from_dom ) {
270 5         16 $self->result->spf('pass');
271 5         19 $self->result->spf_align('strict');
272 5         22 return 1;
273             }
274              
275             # don't try relaxed match if strict policy requested
276 16 100 100     71 if ( $self->policy->aspf && 's' eq lc $self->policy->aspf ) {
277 8         53 return 0;
278             }
279              
280 8 100       61 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         11 $self->result->spf_align('relaxed');
285 3         13 return 1;
286             }
287 5         18 return 0;
288             }
289              
290             sub is_whitelisted {
291 11     11 0 1819 my $self = shift;
292 11   100     86 my $s_ip = shift || $self->source_ip;
293 11 100       55 return if ! defined $s_ip;
294 8 100       30 if ( ! $self->{_whitelist} ) {
295 5 50       22 my $white_file = $self->config->{smtp}{whitelist} or return;
296 5 50 33     347 return if ! -f $white_file || ! -r $white_file;
297 5         61 foreach my $line ( split /\n/, $self->slurp($white_file) ) {
298 30 100       89 next if $line =~ /^#/; # ignore comments
299 10         53 my ($lip,$reason) = split /\s+/, $line, 2;
300 10         43 $self->{_whitelist}{$lip} = $reason;
301             };
302             };
303 8 100       48 return if ! $self->{_whitelist}{$s_ip};
304              
305 2         9 my ($type, $comment) = split /\s+/, $self->{_whitelist}{$s_ip}, 2;
306 2         10 $self->result->disposition('none');
307 2 100 66     13 $self->result->reason(
308             type => $type,
309             ($comment && $comment =~ /\S/ ? ('comment' => $comment) : () ),
310             );
311 2         12 return $type;
312             }
313              
314             sub has_valid_reporting_uri {
315 13     13 1 2257 my ( $self, $rua ) = @_;
316 13         48 my @valid_reporting_uris = $self->get_valid_reporting_uri( $rua );
317 13         84 return scalar @valid_reporting_uris;
318             }
319              
320             sub get_valid_reporting_uri {
321 18     18 0 40 my ( $self, $rua ) = @_;
322 18 50       45 return unless $rua;
323 18         82 my $recips_ref = $self->report->uri->parse($rua);
324 18         30 my @has_permission;
325 18         47 foreach my $uri_ref (@$recips_ref) {
326 16 100       64 if ( !$self->external_report( $uri_ref->{uri} ) ) {
327 13         27 push @has_permission, $uri_ref;
328 13         30 next;
329             }
330 3         11 my $ext = $self->verify_external_reporting($uri_ref);
331 3 100       13 push @has_permission, $uri_ref if $ext;
332             }
333 18         60 return @has_permission;
334             }
335              
336             sub get_dkim_pass_sigs {
337 41     41 0 82 my $self = shift;
338              
339 41 50       162 my $dkim_sigs = $self->dkim or return (); # message not signed
340              
341 41 50       144 if ( 'ARRAY' ne ref $dkim_sigs ) {
342 0         0 croak "dkim needs to be an array reference!";
343             }
344              
345 41         114 return grep { 'pass' eq lc $_->{result} } @$dkim_sigs;
  57         313  
346             }
347              
348             sub get_organizational_domain {
349 108     108 1 5586 my $self = shift;
350 108 50 66     381 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         485 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         233 my $greatest = 0;
366 108         366 for ( my $i = 0; $i <= scalar @labels; $i++ ) {
367 363 100       914 next if !$labels[$i];
368 255         776 my $tld = join '.', reverse( (@labels)[ 0 .. $i ] );
369              
370 255 100       736 if ( $self->is_public_suffix($tld) ) {
371 110         347 $greatest = $i + 1;
372             }
373             }
374              
375 108 100       320 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         351 my $org_dom = join '.', reverse( (@labels)[ 0 .. $greatest ] );
384 106 50       367 print "Organizational Domain: $org_dom\n" if $self->verbose;
385 106         544 return $org_dom;
386             }
387              
388             sub exists_in_dns {
389 18     18 1 62 my $self = shift;
390 18 50 66     121 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         207 my $org_dom = $self->get_organizational_domain($from_dom);
399 18         83 my @todo = $from_dom;
400 18 100       87 if ( $from_dom ne $org_dom ) {
401 9         26 push @todo, $org_dom;
402 9         45 $self->is_subdomain(1);
403             }
404 18         42 my $matched = 0;
405 18         83 foreach (@todo) {
406 27 100       117 last if $matched;
407 22 100 50     145 $matched++ and next if $self->has_dns_rr( 'MX', $_ );
408 22 100 50     162 $matched++ and next if $self->has_dns_rr( 'NS', $_ );
409 12 100 50     93 $matched++ and next if $self->has_dns_rr( 'A', $_ );
410 12 100 50     89 $matched++ and next if $self->has_dns_rr( 'AAAA', $_ );
411             }
412 18 100       109 if ( !$matched ) {
413 2         35 $self->result->result('none');
414 2         13 $self->result->disposition('none');
415 2         11 $self->result->reason(
416             type => 'other',
417             comment => "$from_dom not in DNS"
418             );
419             }
420 18         124 return $matched;
421             }
422              
423             sub fetch_dmarc_record {
424 15     15 1 2678 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       129 $self->is_subdomain( defined $org_dom ? 0 : 1 );
430 15         44 my @matches = ();
431 15 50       91 my $query = $self->get_resolver->send( "_dmarc.$zone", 'TXT' )
432             or return (\@matches, $zone);
433 15         783467 for my $rr ( $query->answer ) {
434 12 100       236 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       252 next if 'v=dmarc1' ne lc substr( $rr->txtdata, 0, 8 );
439 11 50       817 print "\n" . $rr->txtdata . "\n\n" if $self->verbose;
440 11         52 push @matches, join( '', $rr->txtdata ); # join long records
441             }
442 15 100       452 if (scalar @matches) {
443 11         227 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       24 if ( defined $org_dom ) { # <- recursion break
452 2 100       12 if ( $org_dom ne $zone ) {
453 1         13 return $self->fetch_dmarc_record($org_dom); # <- recursion
454             }
455             }
456              
457 3         78 return \@matches, $zone;
458             }
459              
460             sub get_from_dom {
461 25     25 1 103 my ($self) = @_;
462 25 100       90 return $self->header_from if $self->header_from;
463              
464 10 50       22 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         36 my ($from_dom) = ( split /@/, $header )[-1]; # grab everything after the @
479 10         71 ($from_dom) = split /(\s+|>)/, lc $from_dom; # remove trailing cruft
480 10 50       29 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         21 return $self->header_from($from_dom);
488             }
489              
490             sub external_report {
491 20     20 1 1661 my ( $self, $uri ) = @_;
492 20 50       66 my $dmarc_dom = $self->result->published->domain
493             or croak "published policy not tagged!";
494              
495 20 100       62 if ( 'mailto' eq $uri->scheme ) {
496 17         364 my $dest_email = lc $uri->path;
497 17         422 my ($dest_host) = ( split /@/, $dest_email )[-1];
498 17 100       61 if ( $self->get_organizational_domain( $dest_host )
499             eq
500             $self->get_organizational_domain( $dmarc_dom )
501             ) {
502 13 50       32 print "$dest_host not external for $dmarc_dom\n" if $self->verbose;
503 13         55 return 0;
504             };
505 4 50       12 print "$dest_host is external for $dmarc_dom\n" if $self->verbose;
506             }
507              
508 7 100       62 if ( 'http' eq $uri->scheme ) {
509 3 100       46 if ($uri->host eq $dmarc_dom ) {
510 2 50       110 print $uri->host ." not external for $dmarc_dom\n" if $self->verbose;
511 2         8 return 0;
512             };
513 1 50       41 print $uri->host ." is external for $dmarc_dom\n" if $self->verbose;
514             }
515              
516 5         71 return 1;
517             }
518              
519             sub verify_external_reporting {
520 6     6 1 237 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       20 my $dmarc_dom = $self->result->published->domain
526             or croak "published policy not tagged!";
527              
528 6 50       30 my $dest_email = $uri_ref->{uri}->path or croak("invalid URI");
529 6         118 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         22 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       34 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         474217 my @matches;
545 6         32 for my $rr ( $query->answer ) {
546 5 50       63 next if $rr->type ne 'TXT';
547              
548 5 50       95 next if 'v=dmarc1' ne lc substr( $rr->txtdata, 0, 8 );
549 5         293 my $policy = undef;
550 5         15 my $dmarc_str = join( '', $rr->txtdata ); # join parts
551 5         123 eval { $policy = $self->policy->parse($dmarc_str) }; ## no critic (Eval)
  5         41  
552 5 50       59 push @matches, $policy ? $policy : $dmarc_str;
553             }
554              
555             # 6. If the result includes no TXT resource records...stop
556 6 100       34 if ( !scalar @matches ) {
557 1 50       7 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       12 my @overrides = grep { ref $_ && $_->{rua} } @matches;
  5         35  
565 5         17 foreach my $or (@overrides) {
566 3 50       15 my $recips_ref = $self->report->uri->parse( $or->{rua} ) or next;
567 3 50       11 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       49 print "found override RUA: $or->{rua}\n" if $self->verbose;
572 3         15 $self->result->published->rua( $or->{rua} );
573             }
574             }
575              
576 5         85 return @matches;
577             }
578              
579             1;
580              
581             __END__