File Coverage

blib/lib/Mail/BIMI/Record.pm
Criterion Covered Total %
statement 181 201 90.0
branch 66 98 67.3
condition 19 57 33.3
subroutine 18 18 100.0
pod 4 4 100.0
total 288 378 76.1


line stmt bran cond sub pod time code
1             package Mail::BIMI::Record;
2             # ABSTRACT: Class to model a BIMI record
3             our $VERSION = '3.20210225'; # VERSION
4 30     30   527 use 5.20.0;
  30         119  
5 30     30   177 use Moose;
  30         67  
  30         244  
6 30     30   207845 use Mail::BIMI::Prelude;
  30         76  
  30         267  
7 30     30   31009 use Term::ANSIColor qw{ :constants };
  30         252088  
  30         35918  
8 30     30   17836 use Mail::BIMI::Record::Authority;
  30         147  
  30         3343  
9 30     30   20895 use Mail::BIMI::Record::Location;
  30         134  
  30         1602  
10 30     30   24647 use Mail::DMARC::PurePerl;
  30         9597547  
  30         89980  
11              
12             extends 'Mail::BIMI::Base';
13             with(
14             'Mail::BIMI::Role::HasError',
15             'Mail::BIMI::Role::Cacheable',
16             );
17             has domain => ( is => 'rw', isa => 'Str', required => 1, traits => ['CacheKey'],
18             documentation => 'inputs: Domain the for the record', );
19             has retrieved_domain => ( is => 'rw', isa => 'Str', traits => ['Cacheable'],
20             documentation => 'Domain the record was retrieved from', );
21             has retrieved_record => ( is => 'rw', traits => ['Cacheable'],
22             documentation => 'Record as retrieved' );
23             has retrieved_selector => ( is => 'rw', isa => 'Str', traits => ['Cacheable'],
24             documentation => 'Selector the record was retrieved from', );
25             has selector => ( is => 'rw', isa => 'Str', traits => ['CacheKey'],
26             documentation => 'inputs: Selector used to retrieve the record; will become default if fallback was used', );
27             has version => ( is => 'rw', isa => 'Maybe[Str]', lazy => 1, builder => '_build_version', traits => ['Cacheable'],
28             documentation => 'BIMI Version tag' );
29             has authority => ( is => 'rw', isa => 'Mail::BIMI::Record::Authority', lazy => 1, builder => '_build_authority',
30             documentation => 'Mail::BIMI::Record::Authority object for this record' );
31             has location => ( is => 'rw', isa => 'Mail::BIMI::Record::Location', lazy => 1, builder => '_build_location',
32             documentation => 'Mail::BIMI::Record::Location object for this record' );
33             has record_hashref => ( is => 'rw', isa => 'HashRef', lazy => 1, builder => '_build_record_hashref', traits => ['Cacheable'],
34             documentation => 'Hashref of record values' );
35             has is_valid => ( is => 'rw', lazy => 1, builder => '_build_is_valid', traits => ['Cacheable'],
36             documentation => 'Is this record valid' );
37              
38              
39              
40 6     6 1 15 sub cache_valid_for($self) { return 3600 }
  6         33  
  6         14  
  6         154  
41              
42 36     36   120 sub _build_version($self) {
  36         93  
  36         77  
43 36 100       959 if ( !exists $self->record_hashref->{v} ) {
44 1         27 return undef;
45             }
46 35         886 return $self->record_hashref->{v};
47             }
48              
49 44     44   113 sub _build_authority($self) {
  44         103  
  44         85  
50 44         96 my $uri;
51 44 100       1235 if ( exists $self->record_hashref->{a} ) {
52 5   50     136 $uri = $self->record_hashref->{a} // '';
53             }
54             # TODO better parser here
55 44         1217 return Mail::BIMI::Record::Authority->new( uri => $uri, bimi_object => $self->bimi_object );
56             }
57              
58 38     38   81 sub _build_location($self) {
  38         155  
  38         65  
59 38         98 my $uri;
60 38 100       1042 if ( exists $self->record_hashref->{l} ) {
61 30   50     760 $uri = $self->record_hashref->{l} // '';
62             }
63             # TODO better parser here
64             # Need to decode , and ; as per spec>
65 38         179 my $location = Mail::BIMI::Record::Location->new( uri => $uri, is_relevant => $self->location_is_relevant, bimi_object => $self->bimi_object );
66 38         59172 return $location;
67             }
68              
69              
70 75     75 1 141 sub location_is_relevant($self) {
  75         164  
  75         138  
71             # True if we don't have a relevant authority OR if we are checking VMC AND Location
72 75 50       2117 return 1 unless $self->bimi_object->options->no_location_with_vmc;
73 0 0 0     0 if ( $self->authority && $self->authority->is_relevant ) {
74 0         0 $self->log_verbose('Location is not relevant');
75 0         0 return 0;
76             }
77 0         0 return 1;
78             }
79              
80 42     42   111 sub _build_is_valid($self) {
  42         110  
  42         140  
81 42 100       1309 return 0 if ! keys $self->record_hashref->%*;
82              
83 36 100       1040 if ( !defined $self->version ) {
84 1         11 $self->add_error('MISSING_V_TAG');
85 1         30 return 0;
86             }
87             else {
88 35 100       993 $self->add_error('EMPTY_V_TAG') if lc $self->version eq '';
89 35 100       852 $self->add_error('INVALID_V_TAG') if lc $self->version ne 'bimi1';
90 35 100       1106 return 0 if $self->errors->@*;
91             }
92 31 50 33     876 if ($self->authority->is_relevant && !$self->authority->is_valid) {
93 0         0 $self->add_error_object( $self->authority->errors );
94             }
95 31 100 66     200 if ($self->location_is_relevant && !$self->location->is_valid) {
96 6         178 $self->add_error_object( $self->location->errors );
97             }
98              
99 31 100       1088 return 0 if $self->errors->@*;
100              
101 25 50       664 if ( $self->bimi_object->options->require_vmc ) {
102 0 0 0     0 unless ( $self->authority && $self->authority->vmc && $self->authority->vmc->is_valid ) {
      0        
103 0         0 $self->add_error('VMC_REQUIRED');
104             }
105             }
106              
107 25 50 33     771 if ( $self->authority && $self->authority->is_relevant ) {
108             # Check the SVG payloads are identical
109             ## Compare raw? or Uncompressed?
110 0 0 0     0 if ( !$self->authority->vmc ) {
    0 0        
    0 0        
    0 0        
    0          
    0          
111             # We could not get a vmc to check, return an error.
112 0         0 $self->add_error('VMC_PARSE_ERROR');
113             }
114             elsif ( !$self->authority->vmc->indicator ) {
115             # We could not get an indicator from the vmc to check, return an error.
116 0         0 $self->add_error('VMC_PARSE_ERROR','Could not extract SVG from VMC');
117             }
118             elsif ( $self->location_is_relevant && !$self->location ) {
119             # We could not get a location to check against, return an error.
120 0         0 $self->add_error('SVG_MISMATCH');
121             }
122             elsif ( $self->location_is_relevant && !$self->location->indicator ) {
123             # We could not get an indicator from the location to check against, return an error.
124 0         0 $self->add_error('SVG_MISMATCH');
125             }
126             elsif ( $self->location_is_relevant && $self->authority->vmc->indicator->data_uncompressed_normalized ne $self->location->indicator->data_uncompressed_normalized ) {
127 0         0 $self->add_error('SVG_MISMATCH');
128             }
129             elsif ( $self->location_is_relevant && $self->authority->vmc->indicator->data_uncompressed ne $self->location->indicator->data_uncompressed ) {
130 0         0 $self->add_warning('Line encoding for SVG in bimi-location did not match SVG in VMC');
131             }
132             }
133              
134 25 50       657 return 0 if $self->errors->@*;
135 25         150 $self->log_verbose('Record is valid');
136 25         687 return 1;
137             }
138              
139 35     35   83 sub _build_record_hashref($self) {
  35         84  
  35         80  
140 35         1072 my $domain = $self->domain;
141 35         1048 my $selector = $self->selector;
142 35         874 my $fallback_selector = $self->selector;
143 35         483 my $fallback_domain = Mail::DMARC::PurePerl->new->get_organizational_domain($domain);
144              
145 35         858948 my @records;
146             eval {
147 35         246 @records = $self->_get_from_dns($selector,$domain);
148 34         161 1;
149 35 100       113 } || do {
150 1         79 my $error = $@;
151 1         10 $error =~ s/ at \/.*$//s;
152 1         12 $self->add_error('DNS_ERROR',$error);
153 1         36 return {};
154             };
155              
156 34         102 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  26         213  
157              
158 34 100       203 if ( !@records ) {
    100          
159 9 100 66     83 if ( $domain eq $fallback_domain && $selector eq $fallback_selector ) {
160             # nothing to fall back to
161 5         37 $self->add_error('NO_BIMI_RECORD');
162 5         155 return {};
163             }
164              
165 4         27 $self->log_verbose('Trying fallback domain');
166 4         11 my @records;
167             eval {
168 4         17 @records = $self->_get_from_dns($fallback_selector,$fallback_domain);
169 3         14 1;
170 4 100       10 } || do {
171 1         47 my $error = $@;
172 1         8 $error =~ s/ at \/.*$//;
173 1         9 $self->add_error('DNS_ERROR',$error);
174 1         32 return {};
175             };
176              
177 3         8 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  4         35  
178              
179 3 50       18 if ( !@records ) {
    100          
180 0         0 $self->add_error('NO_BIMI_RECORD');
181 0         0 return {};
182             }
183             elsif ( scalar @records > 1 ) {
184 1         9 $self->add_error('MULTI_BIMI_RECORD');
185 1         32 return {};
186             }
187             else {
188             # We have one record, let's use that.
189 2         118 $self->retrieved_record($records[0]);
190 2         65 $self->retrieved_domain($fallback_domain);
191 2         63 $self->retrieved_selector($fallback_selector);
192 2         11 return $self->_parse_record($records[0]);
193             }
194             }
195             elsif ( scalar @records > 1 ) {
196 1         7 $self->add_error('MULTI_BIMI_RECORD');
197 1         30 return {};
198             }
199             else {
200             # We have one record, let's use that.
201 24         1047 $self->retrieved_record($records[0]);
202 24         718 $self->retrieved_domain($domain);
203 24         757 $self->retrieved_selector($selector);
204 24         200 return $self->_parse_record($records[0]);
205             }
206             }
207              
208 39     39   95 sub _get_from_dns($self,$selector,$domain) {
  39         92  
  39         116  
  39         89  
  39         75  
209 39         110 my @matches;
210             my $cname;
211 39 100       1549 if ($self->bimi_object->options->force_record) {
212 3         15 $self->log_verbose('Using fake record');
213 3         77 push @matches, $self->bimi_object->options->force_record;
214 3         14 return @matches;
215             }
216 36         1017 my $res = $self->bimi_object->resolver;
217 36 100       684 my $query = $res->query( "$selector._bimi.$domain", 'TXT' ) or do {
218 9         22074 return @matches;
219             };
220 25         55979 for my $rr ( $query->answer ) {
221 27 100       435 $cname = $rr->cname if $rr->type eq 'CNAME';
222 27 100       816 next if $rr->type ne 'TXT';
223 26         352 push @matches, scalar $rr->txtdata;
224             }
225              
226 25 100 66     1507 if (!@matches && $cname) {
227             # follow a single CNAME
228 1 50       5 $query = $res->query( $cname, 'TXT' ) or do {
229 0         0 return @matches;
230             };
231 1         1386 for my $rr ( $query->answer ) {
232 1 50       10 next if $rr->type ne 'TXT';
233 1         19 push @matches, scalar $rr->txtdata;
234             }
235             }
236              
237 25         302 return @matches;
238             }
239              
240 41     41   223 sub _parse_record($self,$record) {
  41         105  
  41         122  
  41         85  
241 41         118 my $data = {};
242 41         310 my @parts = split ';', $record;
243 41         191 foreach my $part ( @parts ) {
244 86         389 $part =~ s/^ +//;
245 86         214 $part =~ s/ +$//;
246 86         345 my ( $key, $value ) = split '=', $part, 2;
247 86         214 $key = lc $key;
248 86 100       256 if ( exists $data->{ $key } ) {
249 1         10 $self->add_error('DUPLICATE_KEY');
250             }
251 86 50       190 if ( grep { $key eq $_ } ( qw{ v l a } ) ) {
  258         743  
252 86         322 $data->{$key} = $value;
253             }
254             }
255 41         1352 return $data;
256             }
257              
258              
259 14     14 1 37 sub finish($self) {
  14         33  
  14         23  
260 14 50       455 $self->authority->finish if $self->authority;
261 14 50       379 $self->location->finish if $self->location;
262 14         18699 $self->_write_cache;
263             }
264              
265              
266 6     6 1 31 sub app_validate($self) {
  6         14  
  6         12  
267 6 100       203 say 'Record Returned: '.($self->is_valid ? GREEN."\x{2713}" : BRIGHT_RED."\x{26A0}").RESET;
268 6         1164 $self->is_valid; # To set retrieved record and retrieved domain/selector
269 6   50     108 say YELLOW.' Record : '.($self->retrieved_record//'-none-').RESET;
270 6 50       320 if ($self->retrieved_record){
271 6   50     102 say YELLOW.' Version '.WHITE.': '.CYAN.($self->version//'-none-').RESET;
272 6   50     245 say YELLOW.' Domain '.WHITE.': '.CYAN.($self->retrieved_domain//'-none-').RESET;
273 6   50     233 say YELLOW.' Selector '.WHITE.': '.CYAN.($self->retrieved_selector//'-none-').RESET;
274 6 50 100     310 say YELLOW.' Authority '.WHITE.': '.CYAN.($self->authority->uri//'-none-').RESET if $self->authority;
275 6 50 50     161 say YELLOW.' Location '.WHITE.': '.CYAN.($self->location->uri//'-none-').RESET if $self->location_is_relevant && $self->location;
      33        
276 6 100       232 say YELLOW.' Is Valid '.WHITE.': '.($self->is_valid?GREEN.'Yes':BRIGHT_RED.'No').RESET;
277             }
278              
279 6 50       410 if ( $self->warnings->@* ) {
280 0         0 say "Warnings:";
281 0         0 foreach my $warning ( $self->warnings->@* ) {
282 0         0 say CYAN.' '.$warning.RESET;
283             }
284             }
285              
286 6 100       160 if ( ! $self->is_valid ) {
287 3         13 say "Errors:";
288 3         125 foreach my $error ( $self->errors->@* ) {
289 3         79 my $error_code = $error->code;
290 3         12 my $error_text = $error->description;
291 3   50     76 my $error_detail = $error->detail // '';
292 3         11 $error_detail =~ s/\n/\n /g;
293 3 50       48 say BRIGHT_RED." $error_code ".WHITE.': '.CYAN.$error_text.($error_detail?"\n ".$error_detail:'').RESET;
294             }
295             }
296             }
297              
298             1;
299              
300             __END__
301              
302             =pod
303              
304             =encoding UTF-8
305              
306             =head1 NAME
307              
308             Mail::BIMI::Record - Class to model a BIMI record
309              
310             =head1 VERSION
311              
312             version 3.20210225
313              
314             =head1 DESCRIPTION
315              
316             Class for representing, retrieving, validating, and processing a BIMI Record
317              
318             =head1 INPUTS
319              
320             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
321              
322             =head2 domain
323              
324             is=rw required
325              
326             Domain the for the record
327              
328             =head2 selector
329              
330             is=rw
331              
332             Selector used to retrieve the record; will become default if fallback was used
333              
334             =head1 ATTRIBUTES
335              
336             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
337              
338             =head2 authority
339              
340             is=rw
341              
342             Mail::BIMI::Record::Authority object for this record
343              
344             =head2 cache_backend
345              
346             is=ro
347              
348             =head2 errors
349              
350             is=rw
351              
352             =head2 is_valid
353              
354             is=rw
355              
356             Is this record valid
357              
358             =head2 location
359              
360             is=rw
361              
362             Mail::BIMI::Record::Location object for this record
363              
364             =head2 record_hashref
365              
366             is=rw
367              
368             Hashref of record values
369              
370             =head2 retrieved_domain
371              
372             is=rw
373              
374             Domain the record was retrieved from
375              
376             =head2 retrieved_record
377              
378             is=rw
379              
380             Record as retrieved
381              
382             =head2 retrieved_selector
383              
384             is=rw
385              
386             Selector the record was retrieved from
387              
388             =head2 version
389              
390             is=rw
391              
392             BIMI Version tag
393              
394             =head2 warnings
395              
396             is=rw
397              
398             =head1 CONSUMES
399              
400             =over 4
401              
402             =item * L<Mail::BIMI::Role::Cacheable>
403              
404             =item * L<Mail::BIMI::Role::HasError>
405              
406             =item * L<Mail::BIMI::Role::HasError|Mail::BIMI::Role::Cacheable>
407              
408             =back
409              
410             =head1 EXTENDS
411              
412             =over 4
413              
414             =item * L<Mail::BIMI::Base>
415              
416             =back
417              
418             =head1 METHODS
419              
420             =head2 I<cache_valid_for()>
421              
422             How long should the cache for this class be valid
423              
424             =head2 I<location_is_relevant()>
425              
426             Return true is the location is relevant to the validation of the record.
427              
428             If we don't have a relevant authority, or we are checking BOTH authority and location.
429              
430             =head2 I<finish()>
431              
432             Finish and clean up, write cache if enabled.
433              
434             =head2 I<app_validate()>
435              
436             Output human readable validation status of this object
437              
438             =head1 REQUIRES
439              
440             =over 4
441              
442             =item * L<Mail::BIMI::Prelude|Mail::BIMI::Prelude>
443              
444             =item * L<Mail::BIMI::Record::Authority|Mail::BIMI::Record::Authority>
445              
446             =item * L<Mail::BIMI::Record::Location|Mail::BIMI::Record::Location>
447              
448             =item * L<Mail::DMARC::PurePerl|Mail::DMARC::PurePerl>
449              
450             =item * L<Moose|Moose>
451              
452             =item * L<Term::ANSIColor|Term::ANSIColor>
453              
454             =back
455              
456             =head1 AUTHOR
457              
458             Marc Bradshaw <marc@marcbradshaw.net>
459              
460             =head1 COPYRIGHT AND LICENSE
461              
462             This software is copyright (c) 2020 by Marc Bradshaw.
463              
464             This is free software; you can redistribute it and/or modify it under
465             the same terms as the Perl 5 programming language system itself.
466              
467             =cut