File Coverage

lib/App/wsgetmail/MS365.pm
Criterion Covered Total %
statement 66 158 41.7
branch 15 100 15.0
condition 2 35 5.7
subroutine 12 16 75.0
pod 5 5 100.0
total 100 314 31.8


line stmt bran cond sub pod time code
1             # BEGIN BPS TAGGED BLOCK {{{
2             #
3             # COPYRIGHT:
4             #
5             # This software is Copyright (c) 2020-2022 Best Practical Solutions, LLC
6             #
7             #
8             # (Except where explicitly superseded by other copyright notices)
9             #
10             #
11             # LICENSE:
12             #
13             # This work is made available to you under the terms of Version 2 of
14             # the GNU General Public License. A copy of that license should have
15             # been provided with this software, but in any event can be snarfed
16             # from www.gnu.org.
17             #
18             # This work is distributed in the hope that it will be useful, but
19             # WITHOUT ANY WARRANTY; without even the implied warranty of
20             # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
21             # General Public License for more details.
22             #
23             # You should have received a copy of the GNU General Public License
24             # along with this program; if not, write to the Free Software
25             # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
26             # 02110-1301 or visit their web page on the internet at
27             # http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
28             #
29             #
30             # CONTRIBUTION SUBMISSION POLICY:
31             #
32             # (The following paragraph is not intended to limit the rights granted
33             # to you to modify and distribute this software under the terms of
34             # the GNU General Public License and is only of importance to you if
35             # you choose to contribute your changes and enhancements to the
36             # community by submitting them to Best Practical Solutions, LLC.)
37             #
38             # By intentionally submitting any modifications, corrections or
39             # derivatives to this work, or any other work intended for use with
40             # Request Tracker, to Best Practical Solutions, LLC, you confirm that
41             # you are the copyright holder for those contributions and you grant
42             # Best Practical Solutions, LLC a nonexclusive, worldwide, irrevocable,
43             # royalty-free, perpetual, license to use, copy, create derivative
44             # works based on those contributions, and sublicense and distribute
45             # those contributions and any derivatives thereof.
46             #
47             # END BPS TAGGED BLOCK }}}
48              
49 2     2   2616 use v5.10;
  2         9  
50              
51             package App::wsgetmail::MS365;
52              
53             =head1 NAME
54              
55             App::wsgetmail::MS365 - Fetch mail from Microsoft 365
56              
57             =cut
58              
59 2     2   16 use Moo;
  2         12  
  2         15  
60 2     2   2000 use JSON;
  2         14727  
  2         16  
61              
62 2     2   1070 use App::wsgetmail::MS365::Client;
  2         10  
  2         95  
63 2     2   1339 use App::wsgetmail::MS365::Message;
  2         7  
  2         82  
64 2     2   1198 use File::Temp;
  2         15241  
  2         7334  
65              
66             =head1 SYNOPSIS
67              
68             my $ms365 = App::wsgetmail::MS365->new({
69             client_id => "client UUID",
70             tenant_id => "tenant UUID",
71             secret => "random secret token",
72             global_access => 1,
73             folder => "Inbox",
74             post_fetch_action => "mark_message_as_read",
75             debug => 0,
76             response_matrix => hash,
77             })
78              
79             =head1 DESCRIPTION
80              
81             Moo class providing methods to connect to and fetch mail from Microsoft 365
82             mailboxes using the Graph REST API.
83              
84             =head1 ATTRIBUTES
85              
86             You must provide C, C, C, and
87             authentication details. If C is false (the default), you must
88             provide C and C. If you set C to a
89             true value, you must provide C.
90              
91             =head2 client_id
92              
93             A string with the UUID of the client application to use for authentication.
94              
95             =cut
96              
97             has client_id => (
98             is => 'ro',
99             required => 1,
100             );
101              
102             =head2 tenant_id
103              
104             A string with the UUID of your Microsoft 365 tenant to use for authentication.
105              
106             =cut
107              
108             has tenant_id => (
109             is => 'ro',
110             required => 1,
111             );
112              
113             =head2 username
114              
115             A string with a username email address. If C is false (the
116             default), the client authenticates with this username. If C
117             is true, the client accesses this user's mailboxes.
118              
119             =cut
120              
121             has username => (
122             is => 'ro',
123             required => 0
124             );
125              
126             =head2 user_password
127              
128             A string with the user password to use for authentication without global
129             access.
130              
131             =cut
132              
133             has user_password => (
134             is => 'ro',
135             required => 0
136             );
137              
138             =head2 folder
139              
140             A string with the name of the email folder to read. Default "Inbox".
141              
142             =cut
143              
144             has folder => (
145             is => 'ro',
146             required => 0,
147             default => sub { 'Inbox' }
148             );
149              
150             =head2 global_access
151              
152             A boolean. If false (the default), the client will authenticate using
153             C and C. If true, the client will authenticate
154             using its C token.
155              
156             =cut
157              
158             has global_access => (
159             is => 'ro',
160             default => sub { return 0 }
161             );
162              
163             =head2 size_limit
164              
165             An integer. Messages with size in bytes bigger than it will be skipped.
166              
167             Default is 0, which means no limit.
168              
169             =cut
170              
171             has size_limit => (
172             is => 'ro',
173             default => sub { return 0 }
174             );
175              
176             =head2 body_size_limit
177              
178             An integer. Messages with body size in bytes bigger than it will be skipped.
179              
180             Default is 0, which means no limit.
181              
182             =cut
183              
184             has body_size_limit => (
185             is => 'ro',
186             default => sub { return 0 }
187             );
188              
189              
190             =head2 secret
191              
192             A string with the client secret to use for global authentication. This
193             should look like a long string of completely random characters, not a UUID
194             or other recognizable format.
195              
196             =cut
197              
198             has secret => (
199             is => 'ro',
200             required => 0,
201             );
202              
203             =head2 post_fetch_action
204              
205             A string with the name of a method to call after reading a message. You
206             probably want to pass either "mark_message_as_read" or "delete_message". In
207             principle, you can pass the name of any method that accepts a message ID
208             string argument.
209              
210             =cut
211              
212             has post_fetch_action => (
213             is => 'ro',
214             required => 1
215             );
216              
217             =head2 stripcr
218              
219             A boolean. If true, the message content will have CRLF line terminators
220             converted to LF line terminators.
221              
222             =cut
223              
224             has stripcr => (
225             is => 'ro',
226             required => 0,
227             );
228              
229             =head2 debug
230              
231             A boolean. If true, the object will issue a warning with details about each
232             request it issues.
233              
234             =cut
235              
236             has debug => (
237             is => 'rw',
238             default => sub { return 0 }
239             );
240              
241             =head2 response_matrix
242              
243             A hash describing special handling for combinations of API calls and
244             non-success HTTP response codes.
245              
246             The recognized API call labels, based on the Perl internal method names, are:
247              
248             =over 4
249              
250             =item get_message_mime_content
251              
252             =item delete_message
253              
254             =item mark_message_as_read
255              
256             =item get_folder_details
257              
258             =item _fetch_messages
259              
260             =item _get_message_list
261              
262             =back
263              
264             Instead of one of these API call labels C can be used to specify the
265             behavior for all labels without a value specified for that code.
266              
267             In addition to specific response codes it is also valid to use C as the
268             last two digits of the code to match all codes with the same first digit,
269             except where a specific code has its own value.
270              
271             The lookup priority order is:
272              
273             =over 4
274              
275             =item exact method / exact code
276              
277             =item default / exact code
278              
279             =item exact method / Nxx code
280              
281             =item default / Nxx code
282              
283             =back
284              
285             The only value with defined behavior is C, which indicates that
286             nothing should be logged and that the code should be treated as success
287             as closely as possible.
288              
289             The defaults in L could be represented as:
290              
291             $example_response_matrix = {
292             delete_message => { '400' => 'ignore', '404' => 'ignore' },
293              
294             default => { '5xx' => 'ignore' },
295             };
296              
297              
298             =cut
299              
300             has response_matrix => (
301             is => 'ro',
302             default => sub { return {} },
303             );
304              
305             ###
306              
307             has _client => (
308             is => 'ro',
309             lazy => 1,
310             builder => '_build_client',
311             );
312              
313             has _fetched_messages => (
314             is => 'rw',
315             required => 0,
316             default => sub { [ ] }
317             );
318              
319             has _have_messages_to_fetch => (
320             is => 'rw',
321             required => 0,
322             default => sub { 1 }
323             );
324              
325             has _next_fetch_url => (
326             is => 'rw',
327             required => 0,
328             default => sub { '' }
329             );
330              
331              
332             # this sets the attributes in the object using values from the config.
333             # if no value is defined in the config, the attribute's "default" is used
334             # instead (if defined).
335             around BUILDARGS => sub {
336             my ( $orig, $class, $config ) = @_;
337              
338             my $attributes = {
339             map {
340             $_ => $config->{$_}
341             }
342             grep {
343             defined $config->{$_}
344             }
345             qw(client_id tenant_id username user_password global_access secret folder post_fetch_action stripcr size_limit body_size_limit debug response_matrix)
346             };
347              
348             return $class->$orig($attributes);
349             };
350              
351              
352             =head1 METHODS
353              
354             =head2 new
355              
356             Class constructor method, returns new App::wsgetmail::MS365 object
357              
358             =head2 get_next_message
359              
360             Object method, returns the next message as an App::wsgetmail::MS365::Message object if there is one.
361              
362             Will lazily fetch messages until the list is exhausted.
363              
364             =cut
365              
366             sub get_next_message {
367 2     2 1 2629 my ($self) = @_;
368 2         4 my $next_message;
369              
370             # check for already fetched messages, otherwise fetch more
371 2         5 my $message_details = shift @{$self->_fetched_messages};
  2         10  
372 2 100       12 unless ( $message_details ) {
373 1 50       5 if ($self->_have_messages_to_fetch) {
374 1         5 $self->_fetch_messages();
375 1         3 $message_details = shift @{$self->_fetched_messages};
  1         4  
376             }
377             }
378 2 50       7 if (defined $message_details) {
379 2         41 $next_message = App::wsgetmail::MS365::Message->new($message_details);
380             }
381 2         122 return $next_message;
382             }
383              
384             =head2 get_message_mime_content
385              
386             Object method, takes message id and returns filename of fetched raw mime file for that message.
387              
388             =cut
389              
390             sub get_message_mime_content {
391 0     0 1 0 my ($self, $message_id) = @_;
392 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id, '$value') : ('me', 'messages', $message_id, '$value');
393              
394 0         0 my $response = $self->_client->get_request([@path_parts]);
395 0 0       0 unless ($response->is_success) {
396 0 0       0 if ($self->_check_matrix('get_message_mime_content', $response->code) eq 'ignore') {
397 0         0 return '';
398             }
399              
400 0         0 warn "failed to fetch message $message_id " . $response->status_line;
401 0 0       0 warn "response from server : " . $response->content if $self->debug;
402 0         0 return undef;
403             }
404              
405 0 0 0     0 if ( $self->size_limit > 0 && length $response->content > $self->size_limit ) {
406 0 0       0 warn sprintf( "message $message_id exceeds size limit: %d > %d", length $response->content, $self->size_limit )
407             if $self->debug;
408 0         0 return ''; # Silently skip it.
409             }
410              
411 0 0 0     0 if ( $self->body_size_limit > 0 && length $response->content > $self->body_size_limit ) {
412 0         0 require MIME::Parser;
413 0         0 my $parser = MIME::Parser->new();
414 0         0 $parser->extract_nested_messages(0);
415 0         0 my $entity;
416 0         0 eval { $entity = $parser->parse_data( $response->content ) };
  0         0  
417 0 0       0 if ($@) {
418 0         0 warn "couldn't parse message $message_id: $@";
419 0         0 $parser->filer->purge;
420 0         0 return;
421             }
422              
423 0         0 my $exceeded_size;
424 0 0 0     0 if ( $entity->parts ) {
    0 0        
425             # Expand multiplart/alternative which usually contains text/plain and text/html
426 0 0 0     0 my @parts = map { ( $_->mime_type // '' ) =~ m{^multipart/alternative$}i ? $_->parts : $_ } $entity->parts;
  0         0  
427 0         0 for my $part ( @parts ) {
428 0 0 0     0 next unless ( $part->mime_type // '' ) =~ m{^text/(?:plain|html)$}i;
429 0 0 0     0 next if ( $part->head->get('Content-Disposition') // '' ) =~ /attachment/i;
430 0 0       0 if ( length $part->stringify_body > $self->body_size_limit ) {
431 0         0 $exceeded_size = length $part->stringify_body;
432 0         0 last;
433             }
434             }
435             }
436             elsif ( ( $entity->mime_type // '' ) =~ m{^text/(?:plain|html)$}i
437             && length $entity->stringify_body > $self->body_size_limit )
438             {
439 0         0 $exceeded_size = length $entity->stringify_body;
440             }
441              
442 0         0 $parser->filer->purge;
443              
444 0 0       0 if ($exceeded_size) {
445 0 0       0 warn sprintf(
446             "message $message_id exceeds body size limit: %d > %d",
447             $exceeded_size,
448             $self->body_size_limit
449             ) if $self->debug;
450 0         0 return ''; # Silently skip it.
451             }
452             }
453              
454             # can we just write straight to file from response?
455 0         0 my $tmp = File::Temp->new( UNLINK => 0, SUFFIX => '.mime' );
456 0         0 my $content = $response->content;
457 0 0       0 $content =~ s/\r$//mg if $self->stripcr;
458 0         0 print $tmp $content;
459 0         0 return $tmp->filename;
460             }
461              
462             =head2 delete_message
463              
464             Object method, takes message id and deletes that message from the outlook365 mailbox
465              
466             =cut
467              
468             sub delete_message {
469 0     0 1 0 my ($self, $message_id) = @_;
470 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
471 0         0 my $response = $self->_client->delete_request([@path_parts]);
472 0 0       0 unless ($response->is_success) {
473 0 0       0 if ($self->_check_matrix('delete_message', $response->code) eq 'ignore') {
474 0         0 $response->code( 200 );
475             }
476             else {
477 0         0 warn "failed to delete message " . $response->status_line;
478 0 0       0 warn "response from server : " . $response->content if $self->debug;
479             }
480             }
481              
482 0         0 return $response;
483             }
484              
485             =head2 mark_message_as_read
486              
487             Object method, takes message id and marks that message as read in the outlook365 mailbox
488              
489             =cut
490              
491             sub mark_message_as_read {
492 0     0 1 0 my ($self, $message_id) = @_;
493 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
494 0         0 my $response = $self->_client->patch_request([@path_parts],
495             {'Content-type'=> 'application/json',
496             Content => encode_json({isRead => $JSON::true }) });
497 0 0       0 unless ($response->is_success) {
498 0 0       0 if ($self->_check_matrix('mark_message_as_read', $response->code) eq 'ignore') {
499 0         0 $response->code( 200 );
500             }
501             else {
502 0         0 warn "failed to mark message as read " . $response->status_line;
503 0 0       0 warn "response from server : " . $response->content if $self->debug;
504             }
505             }
506              
507 0         0 return $response;
508             }
509              
510              
511             =head2 get_folder_details
512              
513             Object method, returns hashref of details of the configured mailbox folder.
514              
515             =cut
516              
517             sub get_folder_details {
518 1     1 1 2 my $self = shift;
519 1         5 my $folder_name = $self->folder;
520 1 50       6 my @path_parts = ($self->global_access) ? ('users', $self->username, 'mailFolders' ) : ('me', 'mailFolders');
521 1         28 my $response = $self->_client->get_request(
522             [@path_parts], { '$filter' => "DisplayName eq '$folder_name'" }
523             );
524 1 50       1630 unless ($response->is_success) {
525 0 0       0 if ($self->_check_matrix('get_folder_details', $response->code) eq 'ignore') {
526 0         0 return { totalItemCount => 0 };
527             }
528              
529 0         0 warn "failed to fetch folder detail " . $response->status_line;
530 0 0       0 warn "response from server : " . $response->content if $self->debug;
531 0         0 return undef;
532             }
533              
534 1         14 my $folders = decode_json( $response->content );
535 1         66 return $folders->{value}[0];
536             }
537              
538              
539             ##############
540              
541             sub _check_matrix {
542 0     0   0 my ($self, $label, $code) = @_;
543              
544 0         0 my $matrix = $self->response_matrix;
545              
546 0         0 my $code_category = $code;
547 0         0 $code_category =~ s/^(\d)\d\d$/${1}xx/;
548              
549 0 0 0     0 if (exists $matrix->{$label} and exists $matrix->{$label}{$code}) {
    0 0        
    0 0        
    0 0        
550 0         0 return $matrix->{$label}{$code};
551             }
552             elsif (exists $matrix->{default} and exists $matrix->{default}{$code}) {
553 0         0 return $matrix->{default}{$code};
554             }
555             elsif (exists $matrix->{$label} and exists $matrix->{$label}{$code_category}) {
556 0         0 return $matrix->{$label}{$code_category};
557             }
558             elsif (exists $matrix->{default} and exists $matrix->{default}{$code_category}) {
559 0         0 return $matrix->{default}{$code_category};
560             }
561              
562 0         0 return 'log';
563             }
564              
565             sub _fetch_messages {
566 1     1   3 my ($self, $filter) = @_;
567 1         3 my $messages = [ ];
568 1         2 my $fetched_count = 0;
569             # check if expecting to fetch more using result paging
570 1         2 my ($decoded_response);
571 1 50       5 if ($self->_next_fetch_url) {
572 0         0 my $response = $self->_client->get_request_by_url($self->_next_fetch_url);
573 0 0       0 unless ($response->is_success) {
574 0 0       0 if ($self->_check_matrix('_fetch_messages', $response->code) ne 'ignore') {
575 0         0 warn "failed to fetch messages " . $response->status_line;
576 0 0       0 warn "response from server : " . $response->content if $self->debug;
577             }
578              
579 0         0 $self->_have_messages_to_fetch(0);
580 0         0 return 0;
581             }
582 0         0 $decoded_response = decode_json( $response->content );
583             } else {
584 1         5 my $fields = [qw(id subject sender isRead sentDateTime toRecipients parentFolderId categories)];
585 1         4 $decoded_response = $self->_get_message_list($fields, $filter);
586             }
587              
588 1         86 $messages = $decoded_response->{value};
589 1 50       5 if ($decoded_response->{'@odata.nextLink'}) {
590 0         0 $self->_next_fetch_url($decoded_response->{'@odata.nextLink'});
591 0         0 $self->_have_messages_to_fetch(1);
592             } else {
593 1         6 $self->_have_messages_to_fetch(0);
594             }
595 1         5 $self->_fetched_messages($messages);
596 1         3 return $fetched_count;
597             }
598              
599             sub _get_message_list {
600 1     1   4 my ($self, $fields, $filter) = @_;
601              
602 1         4 my $folder = $self->get_folder_details;
603 1 50       6 unless ($folder) {
604 0         0 die "unable to fetch messages, can't find folder " . $self->folder;
605             }
606              
607             # don't request list if folder has no items
608 1 50       7 unless ($folder->{totalItemCount} > 0) {
609 0         0 return { '@odata.count' => 0, value => [ ] };
610             }
611 1   33     9 $filter ||= $self->_get_message_filters;
612              
613             #TODO: handle filtering multiple folders using filters
614 1 50       9 my @path_parts = ($self->global_access) ? ( 'users', $self->username, 'mailFolders', $folder->{id}, 'messages' ) : ( 'me', 'mailFolders', $folder->{id}, 'messages' );
615              
616             # get oldest first, filter (i.e. unread) if filter provided
617 1 50       43 my $response = $self->_client->get_request(
    50          
618             [@path_parts],
619             {
620             '$count' => 'true', '$orderby' => 'sentDateTime',
621             ( $fields ? ('$select' => join(',',@$fields) ) : ( )),
622             ( $filter ? ('$filter' => $filter ) : ( ))
623             }
624             );
625              
626 1 50       1625 unless ($response->is_success) {
627 0 0       0 if ($self->_check_matrix('_get_message_list', $response->code) ne 'ignore') {
628 0         0 warn "failed to fetch messages " . $response->status_line;
629 0 0       0 warn "response from server : " . $response->content if $self->debug;
630             }
631 0         0 return { value => [ ] };
632             }
633              
634 1         16 return decode_json( $response->content );
635             }
636              
637             sub _get_message_filters {
638 1     1   3 my $self = shift;
639             #TODO: handle filtering multiple folders
640 1         3 my $filters = [ ];
641 1 50 33     14 if ( $self->post_fetch_action && ($self->post_fetch_action eq 'mark_message_as_read')) {
642 1         3 push(@$filters, 'isRead eq false');
643             }
644              
645 1         4 my $filter = join(' ', @$filters);
646 1         5 return $filter;
647             }
648              
649             sub _build_client {
650 1     1   12 my $self = shift;
651 1         71 my $client = App::wsgetmail::MS365::Client->new( {
652             client_id => $self->client_id,
653             username => $self->username,
654             user_password => $self->user_password,
655             secret => $self->secret,
656             client_id => $self->client_id,
657             tenant_id => $self->tenant_id,
658             global_access => $self->global_access,
659             debug => $self->debug,
660             } );
661 1         15 return $client;
662              
663             }
664              
665             =head1 AUTHOR
666              
667             Best Practical Solutions, LLC
668              
669             =head1 LICENSE AND COPYRIGHT
670              
671             This software is Copyright (c) 2020 by Best Practical Solutions, LLC.
672              
673             This is free software, licensed under:
674              
675             The GNU General Public License, Version 2, June 1991
676              
677             =cut
678              
679             1;