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