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.20210301'; # VERSION
4 30     30   496 use 5.20.0;
  30         106  
5 30     30   175 use Moose;
  30         57  
  30         205  
6 30     30   189136 use Mail::BIMI::Prelude;
  30         67  
  30         240  
7 30     30   28255 use Term::ANSIColor qw{ :constants };
  30         232046  
  30         32826  
8 30     30   16385 use Mail::BIMI::Record::Authority;
  30         123  
  30         2777  
9 30     30   19042 use Mail::BIMI::Record::Location;
  30         120  
  30         1278  
10 30     30   20217 use Mail::DMARC::PurePerl;
  30         8629168  
  30         83221  
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 18 sub cache_valid_for($self) { return 3600 }
  6         13  
  6         8  
  6         141  
41              
42 36     36   92 sub _build_version($self) {
  36         96  
  36         56  
43 36 100       861 if ( !exists $self->record_hashref->{v} ) {
44 1         21 return undef;
45             }
46 35         819 return $self->record_hashref->{v};
47             }
48              
49 44     44   101 sub _build_authority($self) {
  44         91  
  44         74  
50 44         109 my $uri;
51 44 100       1148 if ( exists $self->record_hashref->{a} ) {
52 5   50     109 $uri = $self->record_hashref->{a} // '';
53             }
54             # TODO better parser here
55 44         1048 return Mail::BIMI::Record::Authority->new( uri => $uri, bimi_object => $self->bimi_object );
56             }
57              
58 38     38   87 sub _build_location($self) {
  38         77  
  38         66  
59 38         72 my $uri;
60 38 100       924 if ( exists $self->record_hashref->{l} ) {
61 30   50     683 $uri = $self->record_hashref->{l} // '';
62             }
63             # TODO better parser here
64             # Need to decode , and ; as per spec>
65 38         137 my $location = Mail::BIMI::Record::Location->new( uri => $uri, is_relevant => $self->location_is_relevant, bimi_object => $self->bimi_object );
66 38         50516 return $location;
67             }
68              
69              
70 75     75 1 126 sub location_is_relevant($self) {
  75         105  
  75         122  
71             # True if we don't have a relevant authority OR if we are checking VMC AND Location
72 75 50       1634 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   96 sub _build_is_valid($self) {
  42         114  
  42         80  
81 42 100       1102 return 0 if ! keys $self->record_hashref->%*;
82              
83 36 100       882 if ( !defined $self->version ) {
84 1         10 $self->add_error('MISSING_V_TAG');
85 1         21 return 0;
86             }
87             else {
88 35 100       784 $self->add_error('EMPTY_V_TAG') if lc $self->version eq '';
89 35 100       773 $self->add_error('INVALID_V_TAG') if lc $self->version ne 'bimi1';
90 35 100       893 return 0 if $self->errors->@*;
91             }
92 31 50 33     842 if ($self->authority->is_relevant && !$self->authority->is_valid) {
93 0         0 $self->add_error_object( $self->authority->errors );
94             }
95 31 100 66     183 if ($self->location_is_relevant && !$self->location->is_valid) {
96 6         139 $self->add_error_object( $self->location->errors );
97             }
98              
99 31 100       846 return 0 if $self->errors->@*;
100              
101 25 50       619 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     639 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       604 return 0 if $self->errors->@*;
135 25         126 $self->log_verbose('Record is valid');
136 25         610 return 1;
137             }
138              
139 35     35   78 sub _build_record_hashref($self) {
  35         77  
  35         66  
140 35         950 my $domain = $self->domain;
141 35         906 my $selector = $self->selector;
142 35         862 my $fallback_selector = $self->selector;
143 35         438 my $fallback_domain = Mail::DMARC::PurePerl->new->get_organizational_domain($domain);
144              
145 35         772822 my @records;
146             eval {
147 35         212 @records = $self->_get_from_dns($selector,$domain);
148 34         147 1;
149 35 100       87 } || do {
150 1         69 my $error = $@;
151 1         8 $error =~ s/ at \/.*$//s;
152 1         8 $self->add_error('DNS_ERROR',$error);
153 1         31 return {};
154             };
155              
156 34         97 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  26         178  
157              
158 34 100       160 if ( !@records ) {
    100          
159 9 100 66     64 if ( $domain eq $fallback_domain && $selector eq $fallback_selector ) {
160             # nothing to fall back to
161 5         31 $self->add_error('NO_BIMI_RECORD');
162 5         122 return {};
163             }
164              
165 4         27 $self->log_verbose('Trying fallback domain');
166 4         11 my @records;
167             eval {
168 4         18 @records = $self->_get_from_dns($fallback_selector,$fallback_domain);
169 3         11 1;
170 4 100       10 } || do {
171 1         40 my $error = $@;
172 1         8 $error =~ s/ at \/.*$//;
173 1         8 $self->add_error('DNS_ERROR',$error);
174 1         31 return {};
175             };
176              
177 3         8 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  4         22  
178              
179 3 50       15 if ( !@records ) {
    100          
180 0         0 $self->add_error('NO_BIMI_RECORD');
181 0         0 return {};
182             }
183             elsif ( scalar @records > 1 ) {
184 1         8 $self->add_error('MULTI_BIMI_RECORD');
185 1         25 return {};
186             }
187             else {
188             # We have one record, let's use that.
189 2         78 $self->retrieved_record($records[0]);
190 2         61 $self->retrieved_domain($fallback_domain);
191 2         62 $self->retrieved_selector($fallback_selector);
192 2         9 return $self->_parse_record($records[0]);
193             }
194             }
195             elsif ( scalar @records > 1 ) {
196 1         8 $self->add_error('MULTI_BIMI_RECORD');
197 1         32 return {};
198             }
199             else {
200             # We have one record, let's use that.
201 24         860 $self->retrieved_record($records[0]);
202 24         622 $self->retrieved_domain($domain);
203 24         654 $self->retrieved_selector($selector);
204 24         147 return $self->_parse_record($records[0]);
205             }
206             }
207              
208 39     39   79 sub _get_from_dns($self,$selector,$domain) {
  39         80  
  39         90  
  39         82  
  39         89  
209 39         109 my @matches;
210             my $cname;
211 39 100       1350 if ($self->bimi_object->options->force_record) {
212 3         14 $self->log_verbose('Using fake record');
213 3         66 push @matches, $self->bimi_object->options->force_record;
214 3         10 return @matches;
215             }
216 36         809 my $res = $self->bimi_object->resolver;
217 36 100       613 my $query = $res->query( "$selector._bimi.$domain", 'TXT' ) or do {
218 9         19388 return @matches;
219             };
220 25         45238 for my $rr ( $query->answer ) {
221 27 100       373 $cname = $rr->cname if $rr->type eq 'CNAME';
222 27 100       805 next if $rr->type ne 'TXT';
223 26         333 push @matches, scalar $rr->txtdata;
224             }
225              
226 25 100 66     1293 if (!@matches && $cname) {
227             # follow a single CNAME
228 1 50       4 $query = $res->query( $cname, 'TXT' ) or do {
229 0         0 return @matches;
230             };
231 1         1122 for my $rr ( $query->answer ) {
232 1 50       8 next if $rr->type ne 'TXT';
233 1         13 push @matches, scalar $rr->txtdata;
234             }
235             }
236              
237 25         290 return @matches;
238             }
239              
240 41     41   185 sub _parse_record($self,$record) {
  41         85  
  41         98  
  41         71  
241 41         107 my $data = {};
242 41         258 my @parts = split ';', $record;
243 41         151 foreach my $part ( @parts ) {
244 86         344 $part =~ s/^ +//;
245 86         205 $part =~ s/ +$//;
246 86         303 my ( $key, $value ) = split '=', $part, 2;
247 86         205 $key = lc $key;
248 86 100       223 if ( exists $data->{ $key } ) {
249 1         23 $self->add_error('DUPLICATE_KEY');
250             }
251 86 50       171 if ( grep { $key eq $_ } ( qw{ v l a } ) ) {
  258         583  
252 86         284 $data->{$key} = $value;
253             }
254             }
255 41         1169 return $data;
256             }
257              
258              
259 14     14 1 32 sub finish($self) {
  14         41  
  14         23  
260 14 50       412 $self->authority->finish if $self->authority;
261 14 50       322 $self->location->finish if $self->location;
262 14         2960 $self->_write_cache;
263             }
264              
265              
266 6     6 1 13 sub app_validate($self) {
  6         12  
  6         9  
267 6 100       153 say 'Record Returned: '.($self->is_valid ? GREEN."\x{2713}" : BRIGHT_RED."\x{26A0}").RESET;
268 6         898 $self->is_valid; # To set retrieved record and retrieved domain/selector
269 6   50     84 say YELLOW.' Record : '.($self->retrieved_record//'-none-').RESET;
270 6 50       244 if ($self->retrieved_record){
271 6   50     73 say YELLOW.' Version '.WHITE.': '.CYAN.($self->version//'-none-').RESET;
272 6   50     181 say YELLOW.' Domain '.WHITE.': '.CYAN.($self->retrieved_domain//'-none-').RESET;
273 6   50     190 say YELLOW.' Selector '.WHITE.': '.CYAN.($self->retrieved_selector//'-none-').RESET;
274 6 50 100     241 say YELLOW.' Authority '.WHITE.': '.CYAN.($self->authority->uri//'-none-').RESET if $self->authority;
275 6 50 50     122 say YELLOW.' Location '.WHITE.': '.CYAN.($self->location->uri//'-none-').RESET if $self->location_is_relevant && $self->location;
      33        
276 6 100       202 say YELLOW.' Is Valid '.WHITE.': '.($self->is_valid?GREEN.'Yes':BRIGHT_RED.'No').RESET;
277             }
278              
279 6 50       355 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       140 if ( ! $self->is_valid ) {
287 3         11 say "Errors:";
288 3         105 foreach my $error ( $self->errors->@* ) {
289 3         66 my $error_code = $error->code;
290 3         12 my $error_text = $error->description;
291 3   50     63 my $error_detail = $error->detail // '';
292 3         7 $error_detail =~ s/\n/\n /g;
293 3 50       40 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.20210301
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