File Coverage

blib/lib/Mail/BIMI/Record.pm
Criterion Covered Total %
statement 156 201 77.6
branch 54 98 55.1
condition 10 57 17.5
subroutine 17 18 94.4
pod 4 4 100.0
total 241 378 63.7


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.20210512'; # VERSION
4 29     29   506 use 5.20.0;
  29         109  
5 29     29   174 use Moose;
  29         60  
  29         230  
6 29     29   199647 use Mail::BIMI::Prelude;
  29         72  
  29         259  
7 29     29   30462 use Term::ANSIColor qw{ :constants };
  29         247114  
  29         34563  
8 29     29   17639 use Mail::BIMI::Record::Authority;
  29         120  
  29         3447  
9 29     29   20206 use Mail::BIMI::Record::Location;
  29         122  
  29         1526  
10 29     29   22049 use Mail::DMARC::PurePerl;
  29         9714304  
  29         85646  
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         14  
  6         8  
  6         145  
41              
42 30     30   62 sub _build_version($self) {
  30         70  
  30         60  
43 30 100       772 if ( !exists $self->record_hashref->{v} ) {
44 1         26 return undef;
45             }
46 29         710 return $self->record_hashref->{v};
47             }
48              
49 35     35   76 sub _build_authority($self) {
  35         75  
  35         81  
50 35         69 my $uri;
51 35 100       937 if ( exists $self->record_hashref->{a} ) {
52 2   50     53 $uri = $self->record_hashref->{a} // '';
53             }
54             # TODO better parser here
55 35         1001 return Mail::BIMI::Record::Authority->new( uri => $uri, bimi_object => $self->bimi_object );
56             }
57              
58 29     29   62 sub _build_location($self) {
  29         62  
  29         55  
59 29         56 my $uri;
60 29 100       778 if ( exists $self->record_hashref->{l} ) {
61 24   50     573 $uri = $self->record_hashref->{l} // '';
62             }
63             # TODO better parser here
64             # Need to decode , and ; as per spec>
65 29         161 my $location = Mail::BIMI::Record::Location->new( uri => $uri, is_relevant => $self->location_is_relevant, bimi_object => $self->bimi_object );
66 29         44401 return $location;
67             }
68              
69              
70 54     54 1 103 sub location_is_relevant($self) {
  54         89  
  54         89  
71             # True if we don't have a relevant authority OR if we are checking VMC AND Location
72 54 50       1328 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 36     36   89 sub _build_is_valid($self) {
  36         103  
  36         63  
81 36 100       1073 return 0 if ! keys $self->record_hashref->%*;
82              
83 30 100       830 if ( !defined $self->version ) {
84 1         10 $self->add_error('MISSING_V_TAG');
85 1         28 return 0;
86             }
87             else {
88 29 100       721 $self->add_error('EMPTY_V_TAG') if lc $self->version eq '';
89 29 100       704 $self->add_error('INVALID_V_TAG') if lc $self->version ne 'bimi1';
90 29 100       822 return 0 if $self->errors->@*;
91             }
92 25 50 33     671 if ($self->authority->is_relevant && !$self->authority->is_valid) {
93 0         0 $self->add_error_object( $self->authority->errors );
94             }
95 25 100 66     173 if ($self->location_is_relevant && !$self->location->is_valid) {
96 3         80 $self->add_error_object( $self->location->errors );
97             }
98              
99 25 100       741 return 0 if $self->errors->@*;
100              
101 22 50       545 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 22 50 33     608 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 22 50       546 return 0 if $self->errors->@*;
135 22         113 $self->log_verbose('Record is valid');
136 22         568 return 1;
137             }
138              
139 26     26   59 sub _build_record_hashref($self) {
  26         53  
  26         66  
140 26         764 my $domain = $self->domain;
141 26         769 my $selector = $self->selector;
142 26         656 my $fallback_selector = $self->selector;
143 26         359 my $fallback_domain = Mail::DMARC::PurePerl->new->get_organizational_domain($domain);
144              
145 26         897238 my @records;
146             eval {
147 26         173 @records = $self->_get_from_dns($selector,$domain);
148 25         139 1;
149 26 100       94 } || do {
150 1         78 my $error = $@;
151 1         9 $error =~ s/ at \/.*$//s;
152 1         9 $self->add_error('DNS_ERROR',$error);
153 1         30 return {};
154             };
155              
156 25         82 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  20         191  
157              
158 25 100       144 if ( !@records ) {
    100          
159 6 100 66     45 if ( $domain eq $fallback_domain && $selector eq $fallback_selector ) {
160             # nothing to fall back to
161 2         22 $self->add_error('NO_BIMI_RECORD');
162 2         60 return {};
163             }
164              
165 4         29 $self->log_verbose('Trying fallback domain');
166 4         11 my @records;
167             eval {
168 4         21 @records = $self->_get_from_dns($fallback_selector,$fallback_domain);
169 3         11 1;
170 4 100       13 } || do {
171 1         50 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         10 @records = grep { $_ =~ /^v=bimi1;/i } @records;
  4         26  
178              
179 3 50       17 if ( !@records ) {
    100          
180 0         0 $self->add_error('NO_BIMI_RECORD');
181 0         0 return {};
182             }
183             elsif ( scalar @records > 1 ) {
184 1         11 $self->add_error('MULTI_BIMI_RECORD');
185 1         39 return {};
186             }
187             else {
188             # We have one record, let's use that.
189 2         76 $self->retrieved_record($records[0]);
190 2         59 $self->retrieved_domain($fallback_domain);
191 2         59 $self->retrieved_selector($fallback_selector);
192 2         9 return $self->_parse_record($records[0]);
193             }
194             }
195             elsif ( scalar @records > 1 ) {
196 1         14 $self->add_error('MULTI_BIMI_RECORD');
197 1         31 return {};
198             }
199             else {
200             # We have one record, let's use that.
201 18         834 $self->retrieved_record($records[0]);
202 18         521 $self->retrieved_domain($domain);
203 18         554 $self->retrieved_selector($selector);
204 18         88 return $self->_parse_record($records[0]);
205             }
206             }
207              
208 30     30   78 sub _get_from_dns($self,$selector,$domain) {
  30         79  
  30         70  
  30         68  
  30         57  
209 30         99 my @matches;
210             my $cname;
211 30 50       1386 if ($self->bimi_object->options->force_record) {
212 0         0 $self->log_verbose('Using fake record');
213 0         0 push @matches, $self->bimi_object->options->force_record;
214 0         0 return @matches;
215             }
216 30         748 my $res = $self->bimi_object->resolver;
217 30 100       661 my $query = $res->query( "$selector._bimi.$domain", 'TXT' ) or do {
218 6         18053 return @matches;
219             };
220 22         45309 for my $rr ( $query->answer ) {
221 24 100       442 $cname = $rr->cname if $rr->type eq 'CNAME';
222 24 100       373 next if $rr->type ne 'TXT';
223 23         333 push @matches, scalar $rr->txtdata;
224             }
225              
226 22 100 66     1969 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         1431 for my $rr ( $query->answer ) {
232 1 50       10 next if $rr->type ne 'TXT';
233 1         15 push @matches, scalar $rr->txtdata;
234             }
235             }
236              
237 22         313 return @matches;
238             }
239              
240 35     35   155 sub _parse_record($self,$record) {
  35         79  
  35         76  
  35         73  
241 35         98 my $data = {};
242 35         206 my @parts = split ';', $record;
243 35         132 foreach my $part ( @parts ) {
244 71         305 $part =~ s/^ +//;
245 71         185 $part =~ s/ +$//;
246 71         265 my ( $key, $value ) = split '=', $part, 2;
247 71         162 $key = lc $key;
248 71 100       214 if ( exists $data->{ $key } ) {
249 1         7 $self->add_error('DUPLICATE_KEY');
250             }
251 71 50       151 if ( grep { $key eq $_ } ( qw{ v l a } ) ) {
  213         569  
252 71         283 $data->{$key} = $value;
253             }
254             }
255 35         1104 return $data;
256             }
257              
258              
259 5     5 1 11 sub finish($self) {
  5         13  
  5         10  
260 5 50       240 $self->authority->finish if $self->authority;
261 5 50       134 $self->location->finish if $self->location;
262 5         5360 $self->_write_cache;
263             }
264              
265              
266 0     0 1   sub app_validate($self) {
  0            
  0            
267 0 0         say 'Record Returned: '.($self->is_valid ? GREEN."\x{2713}" : BRIGHT_RED."\x{26A0}").RESET;
268 0           $self->is_valid; # To set retrieved record and retrieved domain/selector
269 0   0       say YELLOW.' Record : '.($self->retrieved_record//'-none-').RESET;
270 0 0         if ($self->retrieved_record){
271 0   0       say YELLOW.' Version '.WHITE.': '.CYAN.($self->version//'-none-').RESET;
272 0   0       say YELLOW.' Domain '.WHITE.': '.CYAN.($self->retrieved_domain//'-none-').RESET;
273 0   0       say YELLOW.' Selector '.WHITE.': '.CYAN.($self->retrieved_selector//'-none-').RESET;
274 0 0 0       say YELLOW.' Authority '.WHITE.': '.CYAN.($self->authority->uri//'-none-').RESET if $self->authority;
275 0 0 0       say YELLOW.' Location '.WHITE.': '.CYAN.($self->location->uri//'-none-').RESET if $self->location_is_relevant && $self->location;
      0        
276 0 0         say YELLOW.' Is Valid '.WHITE.': '.($self->is_valid?GREEN.'Yes':BRIGHT_RED.'No').RESET;
277             }
278              
279 0 0         if ( $self->warnings->@* ) {
280 0           say "Warnings:";
281 0           foreach my $warning ( $self->warnings->@* ) {
282 0           say CYAN.' '.$warning.RESET;
283             }
284             }
285              
286 0 0         if ( ! $self->is_valid ) {
287 0           say "Errors:";
288 0           foreach my $error ( $self->errors->@* ) {
289 0           my $error_code = $error->code;
290 0           my $error_text = $error->description;
291 0   0       my $error_detail = $error->detail // '';
292 0           $error_detail =~ s/\n/\n /g;
293 0 0         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.20210512
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