File Coverage

blib/lib/Net/SMTP/Verify.pm
Criterion Covered Total %
statement 132 176 75.0
branch 27 52 51.9
condition 2 6 33.3
subroutine 15 17 88.2
pod 3 8 37.5
total 179 259 69.1


line stmt bran cond sub pod time code
1             package Net::SMTP::Verify;
2              
3 3     3   12224 use Moose;
  3         950652  
  3         65  
4              
5             our $VERSION = '1.03'; # VERSION
6             # ABSTRACT: verify SMTP recipient addresses
7              
8 3     3   27667 use Net::SMTP::Verify::ResultSet;
  3         11  
  3         112  
9              
10 3     3   1103 use Net::DNS::Resolver;
  3         85086  
  3         106  
11 3     3   2977 use Net::SMTP;
  3         257375  
  3         200  
12 3     3   24 use Net::Cmd qw( CMD_OK );
  3         7  
  3         178  
13 3     3   1535 use Sys::Hostname;
  3         2413  
  3         179  
14 3     3   2507 use Digest::SHA qw(sha224_hex);
  3         8767  
  3         6048  
15              
16              
17             has 'host' => ( is => 'rw', isa => 'Maybe[Str]' );
18             has 'port' => ( is => 'rw', isa => 'Int', default => 25 );
19              
20             has 'helo_name' => (
21             is => 'rw', isa => 'Str', lazy => 1,
22             default => sub { Sys::Hostname::hostname },
23             );
24             has 'timeout' => ( is => 'rw', isa => 'Int', default => 30 );
25              
26             has 'resolver' => (
27             is => 'rw', isa => 'Net::DNS::Resolver', lazy => 1,
28             default => sub {
29             Net::DNS::Resolver->new(
30             dnssec => 1,
31             adflag => 1,
32             );
33             },
34             );
35              
36             has 'tlsa' => ( is => 'rw', isa => 'Bool', default => 0 );
37             has 'openpgpkey' => ( is => 'rw', isa => 'Bool', default => 0 );
38              
39             has 'logging_callback' => (
40             is => 'rw', isa => 'CodeRef', lazy => 1,
41             traits => [ 'Code' ],
42             handles => {
43             log => 'execute',
44             },
45             default => sub { sub {} },
46             );
47              
48             has 'debug' => ( is => 'ro', isa => 'Bool', default => 0 );
49              
50             sub BUILD {
51 3     3 0 13913 my $self = shift;
52 3 50       110 if( $self->debug ) {
53             $self->logging_callback( sub {
54 0     0   0 print STDERR shift."\n";
55 0         0 } );
56             }
57             }
58              
59             has '_known_hosts' => (
60             is => 'ro', isa => 'ArrayRef', lazy => 1,
61             default => sub { [] },
62             traits => [ 'Array' ],
63             handles => {
64             '_reset_known_hosts' => 'clear',
65             '_add_known_host' => 'push',
66             }
67             );
68              
69             sub _is_known_host {
70 1     1   84 my ( $self, $host ) = @_;
71 1 50       3 if( grep { $_ eq $host } @{$self->_known_hosts} ) {
  0         0  
  1         44  
72 0         0 return 1;
73             }
74 1         3 return 0;
75             }
76              
77              
78             sub resolve {
79 5     5 1 527 my ( $self, $domain ) = @_;
80              
81 5 100       193 if( defined $self->host ) {
82 4         128 return $self->host;
83             } else {
84 1         48 $self->log('looking up MX for '.$domain.'...');
85 1         36 my $reply = $self->resolver->query( $domain, 'MX' );
86 1 50       9574 if( $reply->answer ) {
87 1         13 my @mx = grep { $_->type eq 'MX' } $reply->answer;
  1         11  
88 1         15 @mx = sort { $a->preference <=> $b->preference } @mx;
  0         0  
89 1         3 my @known_hosts = grep { $self->_is_known_host($_->exchange) } @mx;
  1         5  
90              
91 1         2 my $ex;
92 1 50       5 if( @known_hosts ) {
93 0         0 $ex = $known_hosts[0]->exchange;
94             } else {
95 1         4 $ex = $mx[0]->exchange;
96 1         61 $self->_add_known_host( $ex );
97             }
98 1 50       49 $self->log('found '.scalar(@mx).' records. using: '.$ex.
99             ( @known_hosts ? ' (reuse)' : '') );
100 1         19 return $ex;
101             }
102 0         0 $self->log('looking up AAAA,A for '.$domain.'...');
103 0         0 $reply = $self->resolver->query( $domain, 'AAAA', 'A' );
104 0 0       0 if( my @rr = $reply->answer ) {
105 0         0 $self->log('found '.scalar(@rr).' address records');
106 0         0 return $domain;
107             }
108 0         0 $self->log('unable to resolve domain '.$domain);
109 0         0 return; # lookup failed
110             }
111              
112 0         0 die('unknown mode: '.$self->mode);
113 0         0 return;
114             }
115              
116              
117             sub check_tlsa {
118 1     1 1 537 my ( $self, $host, $port ) = @_;
119 1 50       8 if( ! defined $port ) {
120 0         0 $port = 25;
121             }
122 1         6 my $tlsa_name = '_'.$port.'._tcp.'.$host;
123 1         50 $self->log('looking up TLSA for '.$tlsa_name.'...');
124 1         34 my $reply = $self->resolver->send( $tlsa_name, 'TLSA' );
125              
126 1 50       86040 if( ! $reply->header->ad ) {
127 0         0 $self->log('no adflag set in response');
128 0         0 return 0;
129             }
130              
131 1 50       23 if( ! $reply->answer ) {
132 0         0 $self->log('no TLSA record published');
133 0         0 return 0;
134             }
135              
136 1         48 return 1;
137             }
138              
139             sub check_openpgpkey {
140 0     0 0 0 my ( $self, $rs, @rcpts ) = @_;
141              
142 0         0 foreach my $rcpt ( @rcpts ) {
143 0         0 my ( $local, $domain ) = split('@', $rcpt, 2);
144 0         0 my $name = join('.', sha224_hex($local), '_openpgpkey', $domain);
145 0         0 $self->log('looking up OPENPGPKEY: '.$name.'...');
146 0         0 my $reply = $self->resolver->send( $name, 'TYPE61' );
147 0 0       0 if( ! $reply->header->ad ) {
    0          
148 0         0 $self->log('no adflag set in response');
149 0         0 $rs->set( $rcpt, 'has_openpgpkey', 0 );
150             } elsif( ! $reply->answer ) {
151 0         0 $self->log('no OPENPGPKEY record found');
152 0         0 $rs->set( $rcpt, 'has_openpgpkey', 0 );
153             } else {
154 0         0 $self->log('OPENPGPKEY record found');
155 0         0 $rs->set( $rcpt, 'has_openpgpkey', 1 );
156             }
157             }
158              
159 0         0 return;
160             }
161              
162             sub check_smtp {
163 4     4 0 11 my ( $self, $rs, $host, $size, $sender, @rcpts ) = @_;
164              
165 4         190 $self->log('connecting to '.$host.'...');
166 4         1029 my $smtp = Net::SMTP->new( $host,
167             Port => $self->port,
168             Hello => $self->helo_name,
169             Timeout => $self->timeout,
170             );
171 4 50       257811 if( ! defined $smtp ) {
172 0         0 $self->log('connection failed: '.$@);
173 0         0 $rs->set( \@rcpts, 'error', 'connection failed: '.$@ );
174 0         0 return;
175             }
176              
177 4 50       32 $rs->set( \@rcpts, 'has_starttls',
178             defined $smtp->supports('STARTTLS') ? 1 : 0 );
179              
180 4 100       30 if( defined $smtp->supports('PIPELINING') ) {
181 3         81 $self->check_smtp_addresses_pipelining( $rs, $smtp, $size, $sender, @rcpts );
182             } else {
183 1         26 $self->check_smtp_addresses( $rs, $smtp, $size, $sender, @rcpts );
184             }
185              
186 4         229 $self->log('sending QUIT...');
187 4         677 $smtp->quit;
188 4         5758 return;
189             }
190              
191             sub check_smtp_addresses {
192 1     1 0 6 my ( $self, $rs, $smtp, $size, $sender, @rcpts ) = @_;
193 1         77 $self->log('sending MAIL '.$sender.'...');
194 1 50 33     379 my $mail_ok = $smtp->mail( $sender,
195             defined $size && $smtp->supports('SIZE') ? ( Size => $size ):()
196             );
197 1         1250 my $msg = $smtp->message; chomp($msg);
  1         22  
198 1         129 $self->log('server said: '.$msg);
199 1 50       274 if( ! $mail_ok ) {
200 0         0 $rs->set( \@rcpts, 'smtp_message', $msg );
201 0         0 $rs->set( \@rcpts, 'smtp_code', $smtp->code );
202 0         0 return;
203             }
204              
205 1         6 foreach my $rcpt ( @rcpts ) {
206 3         198 $self->log('sending RCPT '.$rcpt.'...');
207 3         543 my $rcpt_ok = $smtp->recipient( $rcpt );
208 3         2043 my $msg = $smtp->message; chomp( $msg );
  3         35  
209 3         184 $self->log( 'server said: '.$msg );
210 3         508 $rs->set( $rcpt, 'smtp_message', $msg );
211 3         80 $rs->set( $rcpt, 'smtp_code', $smtp->code );
212             }
213 1         4 return;
214             }
215              
216             has 'rcpt_bulk_size' => ( is => 'ro', isa => 'Int', default => 10 );
217              
218             sub check_smtp_addresses_pipelining {
219 3     3 0 19 my ( $self, $rs, $smtp, $size, $sender, @rcpts ) = @_;
220 3         10 my $mail_sent = 0;
221              
222 3         172 while( my @bulk_rcpts = splice(@rcpts, 0, $self->rcpt_bulk_size) ) {
223 3         202 $self->log('sending pipelined bulk...');
224 3         916 my $bulk = '';
225 3 50       15 if( ! $mail_sent ) {
226 3 50 33     87 $bulk .= 'MAIL FROM: <'.$sender.'>'
227             .( defined $size && $smtp->supports('SIZE') ? ' SIZE='.$size : '' )
228             ."\n"
229             }
230             $bulk .= join("\n",
231 3         75 map { 'RCPT TO: <'.$_.'>' } @bulk_rcpts,
  9         52  
232             )."\n";
233              
234 3         99 $smtp->datasend( $bulk );
235              
236 3 50       3906 if( ! $mail_sent ) {
237 3         23 my $resp = $smtp->response;
238 3         546 my $msg = $smtp->message; chomp( $msg );
  3         51  
239 3         253 $self->log("server response to MAIL: ".$msg );
240 3 100       642 if( $resp != CMD_OK ) {
241 1         11 $rs->set( [ @bulk_rcpts, @rcpts ], 'smtp_code', $smtp->code );
242 1         6 $rs->set( [ @bulk_rcpts, @rcpts ], 'smtp_message', $msg );
243 1         4 return;
244             }
245 2         5 $mail_sent = 1;
246             }
247              
248 2         22 foreach my $rcpt ( @bulk_rcpts ) {
249 6         33 $smtp->response;
250 6         75130 my $msg = $smtp->message; chomp( $msg );
  6         96  
251 6         486 $self->log("server response to RCPT $rcpt: ".$msg );
252 6         1432 $rs->set( $rcpt, 'smtp_code', $smtp->code );
253 6         39 $rs->set( $rcpt, 'smtp_message', $msg );
254             }
255             }
256 2         11 return;
257             }
258              
259              
260             sub check {
261 4     4 1 852 my ( $self, $size, $sender, @rcpts ) = @_;
262 4         68 my $rs = Net::SMTP::Verify::ResultSet->new;
263              
264 4         1425 my $by_domain = {};
265 4         22 foreach my $rcpt ( @rcpts ) {
266 12         61 my ( $user, $domain ) = split('@', $rcpt, 2);
267 12 100       70 if( ! defined $by_domain->{$domain} ) {
268 4         19 $by_domain->{$domain} = [];
269             }
270 12         16 push( @{$by_domain->{$domain}}, $rcpt );
  12         45  
271             }
272              
273 4         7 my $by_host = {};
274 4         206 $self->_reset_known_hosts;
275 4         17 foreach my $domain ( keys %$by_domain ) {
276 4         43 my $host = $self->resolve( $domain );
277 4 50       21 if( ! defined $host ) {
278 0         0 $rs->set( $by_domain->{$domain},
279             'error', 'unable to lookup '.$domain );
280 0         0 return;
281             }
282 4 50       16 if( ! defined $by_host->{$host} ) {
283 4         11 $by_host->{$host} = [];
284             }
285 4         8 push( @{$by_host->{$host}}, @{$by_domain->{$domain}} );
  4         11  
  4         36  
286             }
287              
288 4         13 foreach my $host ( keys %$by_host ) {
289 4 50       161 if( $self->tlsa ) {
290 0         0 $rs->set( $by_host->{$host},
291             'has_tlsa', $self->check_tlsa( $host ) );
292             }
293 4         10 $self->check_smtp( $rs, $host, $size, $sender, @{$by_host->{$host}} );
  4         34  
294             }
295              
296 4 50       255 if( $self->openpgpkey ) {
297 0         0 $self->check_openpgpkey( $rs, @rcpts );
298             }
299              
300 4         78 return $rs;
301             }
302              
303             1;
304              
305             __END__
306              
307             =pod
308              
309             =encoding UTF-8
310              
311             =head1 NAME
312              
313             Net::SMTP::Verify - verify SMTP recipient addresses
314              
315             =head1 VERSION
316              
317             version 1.03
318              
319             =head1 SYNOPSIS
320              
321             use Net::SMTP::Verify;
322              
323             my $v = Net::SMTP::Verify->new;
324             my $resultset = $v->check(
325             100000, # size
326             'karl@senderdomain.de', # sender
327             'rcpt1@rcptdomain.de', # 1 or more recipients...
328             'rcpt2@rcptdomain.de',
329             'rcpt3@rcptdomain.de',
330             );
331              
332             # check overall status
333             $resultset->is_all_success;
334              
335             # check a single result
336             $resultset->rcpt('rcpt1@rcptdomain.de')->is_success;
337             $resultset->rcpt('rcpt1@rcptdomain.de')->smtp_code;
338             $resultset->rcpt('rcpt1@rcptdomain.de')->smtp_message;
339             $resultset->rcpt('rcpt1@rcptdomain.de')->has_starttls;
340             $resultset->rcpt('rcpt1@rcptdomain.de')->has_tlsa;
341              
342             # more ways to retrieve results by status...
343             $resultset->successfull_rcpts;
344             $resultset->error_rcpts;
345             $resultset->temp_error_rcpts;
346             $resultset->perm_error_rcpts;
347              
348             =head1 DESCRIPTION
349              
350             This class implements checks for verifying SMTP addresses.
351              
352             It implements the following checks:
353              
354             =over
355              
356             =item check addresses with SMTP MAIL FROM and RCPT TO commands
357              
358             Check if the MX would accept mail for test addresses.
359              
360             =item check of message size
361              
362             If the mail exchanger (MX) supports the SIZE extension and a size is given the
363             module will pass the message size with the MAIL FROM command.
364              
365             This will check if the message would exceed message size limits or recipients
366             quotas on the target MX.
367              
368             =item check if MX could handle TLS connections
369              
370             It will check if the STARTTLS extension required to enstablish encrypted TLS
371             connections is supported by the target MX.
372              
373             =item check if TLSA record is available
374              
375             The module could check if a TLSA record has been published for the target MX
376             server.
377              
378             If such a record has been published the target MX SSL certificate could be
379             verified with DANE.
380              
381             =back
382              
383             =head1 ATTRIBUTES
384              
385             =head2 host (default: undef)
386              
387             Query this smtp server instead of the MX records.
388              
389             =head2 port (default: 25)
390              
391             Use a different port.
392              
393             =head2 helo_name (default: hostname() )
394              
395             Use a helo_name other than the hostname of the system.
396              
397             =head2 timeout (default: 30)
398              
399             Use this timeout for the SMTP connection.
400              
401             =head2 resolver (default: system resolver)
402              
403             Use a custom Net::DNS::Resolver object.
404              
405             The default is:
406              
407             Net::DNS::Resolver->new(
408             dnssec => 1,
409             adflag => 1,
410             );
411              
412             The dnssec and adflag is required for the TLSA check.
413              
414             =head2 tlsa (default: 0)
415              
416             Set to 1 to activate TLSA lookup.
417              
418             =head2 openpgpkey (default: 0)
419              
420             Set to 1 to activate OPENPGPKEY lookup.
421              
422             =head2 logging_callback (default: sub {})
423              
424             Set a callback to retrieve log messages.
425              
426             =head2 debug (default: 0)
427              
428             If set to 1 it will set a logging_callback method to output
429             logs to STDERR.
430              
431             =head1 METHODS
432              
433             =head2 resolve( $domain )
434              
435             Tries to resolve a MX to an hostname.
436              
437             It will choose the first record with the highest priority listed as MX.
438              
439             When a host is MX for multiple domains it will try to reuse the same
440             host for checks.
441              
442             =head2 check_tlsa( $host, $port )
443              
444             Check if a TLSA record is available.
445              
446             =head2 check( $size, $sender, $rcpt1, $rcpts...)
447              
448             Performs check and returns a Net::SMTP::Verify::ResultSet.
449              
450             =head1 AUTHOR
451              
452             Markus Benning <ich@markusbenning.de>
453              
454             =head1 COPYRIGHT AND LICENSE
455              
456             This software is Copyright (c) 2015 by Markus Benning <ich@markusbenning.de>.
457              
458             This is free software, licensed under:
459              
460             The GNU General Public License, Version 2, June 1991
461              
462             =cut