File Coverage

lib/App/wsgetmail/MS365.pm
Criterion Covered Total %
statement 66 158 41.7
branch 19 104 18.2
condition 2 35 5.7
subroutine 12 16 75.0
pod 5 5 100.0
total 104 318 32.7


line stmt bran cond sub pod time code
1             # BEGIN BPS TAGGED BLOCK {{{
2             #
3             # COPYRIGHT:
4             #
5             # This software is Copyright (c) 2020-2026 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   2181 use v5.10;
  2         8  
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   12 use Moo;
  2         3  
  2         41  
60 2     2   1735 use JSON;
  2         12525  
  2         12  
61              
62 2     2   643 use App::wsgetmail::MS365::Client;
  2         5  
  2         86  
63 2     2   1127 use App::wsgetmail::MS365::Message;
  2         7  
  2         58  
64 2     2   1176 use File::Temp;
  2         11833  
  2         5047  
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             =head2 resource_url
306              
307             A string with the Microsoft Graph API base URL. Defaults to
308             C. For Microsoft Government Cloud (GCC High),
309             use C. For DoD, use
310             C.
311              
312             =cut
313              
314             has resource_url => (
315             is => 'ro',
316             required => 0,
317             );
318              
319             =head2 login_base_url
320              
321             A string with the base URL for OAuth authentication. Defaults to
322             C. For Microsoft Government Cloud
323             (GCC High and DoD), use C.
324              
325             =cut
326              
327             has login_base_url => (
328             is => 'ro',
329             required => 0,
330             );
331              
332             ###
333              
334             has _client => (
335             is => 'ro',
336             lazy => 1,
337             builder => '_build_client',
338             );
339              
340             has _fetched_messages => (
341             is => 'rw',
342             required => 0,
343             default => sub { [ ] }
344             );
345              
346             has _have_messages_to_fetch => (
347             is => 'rw',
348             required => 0,
349             default => sub { 1 }
350             );
351              
352             has _next_fetch_url => (
353             is => 'rw',
354             required => 0,
355             default => sub { '' }
356             );
357              
358              
359             # this sets the attributes in the object using values from the config.
360             # if no value is defined in the config, the attribute's "default" is used
361             # instead (if defined).
362             around BUILDARGS => sub {
363             my ( $orig, $class, $config ) = @_;
364              
365             my $attributes = {
366             map {
367             $_ => $config->{$_}
368             }
369             grep {
370             defined $config->{$_}
371             }
372             qw(client_id tenant_id username user_password global_access secret folder post_fetch_action stripcr size_limit body_size_limit debug response_matrix resource_url login_base_url)
373             };
374              
375             return $class->$orig($attributes);
376             };
377              
378              
379             =head1 METHODS
380              
381             =head2 new
382              
383             Class constructor method, returns new App::wsgetmail::MS365 object
384              
385             =head2 get_next_message
386              
387             Object method, returns the next message as an App::wsgetmail::MS365::Message object if there is one.
388              
389             Will lazily fetch messages until the list is exhausted.
390              
391             =cut
392              
393             sub get_next_message {
394 3     3 1 2671 my ($self) = @_;
395 3         4 my $next_message;
396              
397             # check for already fetched messages, otherwise fetch more
398 3         3 my $message_details = shift @{$self->_fetched_messages};
  3         9  
399 3 100       9 unless ( $message_details ) {
400 2 50       5 if ($self->_have_messages_to_fetch) {
401 2         7 $self->_fetch_messages();
402 2         2 $message_details = shift @{$self->_fetched_messages};
  2         6  
403             }
404             }
405 3 50       7 if (defined $message_details) {
406 3         47 $next_message = App::wsgetmail::MS365::Message->new($message_details);
407             }
408 3         4161 return $next_message;
409             }
410              
411             =head2 get_message_mime_content
412              
413             Object method, takes message id and returns filename of fetched raw mime file for that message.
414              
415             =cut
416              
417             sub get_message_mime_content {
418 0     0 1 0 my ($self, $message_id) = @_;
419 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id, '$value') : ('me', 'messages', $message_id, '$value');
420              
421 0         0 my $response = $self->_client->get_request([@path_parts]);
422 0 0       0 unless ($response->is_success) {
423 0 0       0 if ($self->_check_matrix('get_message_mime_content', $response->code) eq 'ignore') {
424 0         0 return '';
425             }
426              
427 0         0 warn "failed to fetch message $message_id " . $response->status_line;
428 0 0       0 warn "response from server : " . $response->content if $self->debug;
429 0         0 return undef;
430             }
431              
432 0 0 0     0 if ( $self->size_limit > 0 && length $response->content > $self->size_limit ) {
433 0 0       0 warn sprintf( "message $message_id exceeds size limit: %d > %d", length $response->content, $self->size_limit )
434             if $self->debug;
435 0         0 return ''; # Silently skip it.
436             }
437              
438 0 0 0     0 if ( $self->body_size_limit > 0 && length $response->content > $self->body_size_limit ) {
439 0         0 require MIME::Parser;
440 0         0 my $parser = MIME::Parser->new();
441 0         0 $parser->extract_nested_messages(0);
442 0         0 my $entity;
443 0         0 eval { $entity = $parser->parse_data( $response->content ) };
  0         0  
444 0 0       0 if ($@) {
445 0         0 warn "couldn't parse message $message_id: $@";
446 0         0 $parser->filer->purge;
447 0         0 return;
448             }
449              
450 0         0 my $exceeded_size;
451 0 0 0     0 if ( $entity->parts ) {
    0 0        
452             # Expand multiplart/alternative which usually contains text/plain and text/html
453 0 0 0     0 my @parts = map { ( $_->mime_type // '' ) =~ m{^multipart/alternative$}i ? $_->parts : $_ } $entity->parts;
  0         0  
454 0         0 for my $part ( @parts ) {
455 0 0 0     0 next unless ( $part->mime_type // '' ) =~ m{^text/(?:plain|html)$}i;
456 0 0 0     0 next if ( $part->head->get('Content-Disposition') // '' ) =~ /attachment/i;
457 0 0       0 if ( length $part->stringify_body > $self->body_size_limit ) {
458 0         0 $exceeded_size = length $part->stringify_body;
459 0         0 last;
460             }
461             }
462             }
463             elsif ( ( $entity->mime_type // '' ) =~ m{^text/(?:plain|html)$}i
464             && length $entity->stringify_body > $self->body_size_limit )
465             {
466 0         0 $exceeded_size = length $entity->stringify_body;
467             }
468              
469 0         0 $parser->filer->purge;
470              
471 0 0       0 if ($exceeded_size) {
472 0 0       0 warn sprintf(
473             "message $message_id exceeds body size limit: %d > %d",
474             $exceeded_size,
475             $self->body_size_limit
476             ) if $self->debug;
477 0         0 return ''; # Silently skip it.
478             }
479             }
480              
481             # can we just write straight to file from response?
482 0         0 my $tmp = File::Temp->new( UNLINK => 0, SUFFIX => '.mime' );
483 0         0 my $content = $response->content;
484 0 0       0 $content =~ s/\r$//mg if $self->stripcr;
485 0         0 print $tmp $content;
486 0         0 return $tmp->filename;
487             }
488              
489             =head2 delete_message
490              
491             Object method, takes message id and deletes that message from the outlook365 mailbox
492              
493             =cut
494              
495             sub delete_message {
496 0     0 1 0 my ($self, $message_id) = @_;
497 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
498 0         0 my $response = $self->_client->delete_request([@path_parts]);
499 0 0       0 unless ($response->is_success) {
500 0 0       0 if ($self->_check_matrix('delete_message', $response->code) eq 'ignore') {
501 0         0 $response->code( 200 );
502             }
503             else {
504 0         0 warn "failed to delete message " . $response->status_line;
505 0 0       0 warn "response from server : " . $response->content if $self->debug;
506             }
507             }
508              
509 0         0 return $response;
510             }
511              
512             =head2 mark_message_as_read
513              
514             Object method, takes message id and marks that message as read in the outlook365 mailbox
515              
516             =cut
517              
518             sub mark_message_as_read {
519 0     0 1 0 my ($self, $message_id) = @_;
520 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
521 0         0 my $response = $self->_client->patch_request([@path_parts],
522             {'Content-type'=> 'application/json',
523             Content => encode_json({isRead => $JSON::true }) });
524 0 0       0 unless ($response->is_success) {
525 0 0       0 if ($self->_check_matrix('mark_message_as_read', $response->code) eq 'ignore') {
526 0         0 $response->code( 200 );
527             }
528             else {
529 0         0 warn "failed to mark message as read " . $response->status_line;
530 0 0       0 warn "response from server : " . $response->content if $self->debug;
531             }
532             }
533              
534 0         0 return $response;
535             }
536              
537              
538             =head2 get_folder_details
539              
540             Object method, returns hashref of details of the configured mailbox folder.
541              
542             =cut
543              
544             sub get_folder_details {
545 2     2 1 3 my $self = shift;
546 2         4 my $folder_name = $self->folder;
547 2 50       33 my @path_parts = ($self->global_access) ? ('users', $self->username, 'mailFolders' ) : ('me', 'mailFolders');
548 2         26 my $response = $self->_client->get_request(
549             [@path_parts], { '$filter' => "DisplayName eq '$folder_name'" }
550             );
551 2 50       1745 unless ($response->is_success) {
552 0 0       0 if ($self->_check_matrix('get_folder_details', $response->code) eq 'ignore') {
553 0         0 return { totalItemCount => 0 };
554             }
555              
556 0         0 warn "failed to fetch folder detail " . $response->status_line;
557 0 0       0 warn "response from server : " . $response->content if $self->debug;
558 0         0 return undef;
559             }
560              
561 2         17 my $folders = decode_json( $response->content );
562 2         38 return $folders->{value}[0];
563             }
564              
565              
566             ##############
567              
568             sub _check_matrix {
569 0     0   0 my ($self, $label, $code) = @_;
570              
571 0         0 my $matrix = $self->response_matrix;
572              
573 0         0 my $code_category = $code;
574 0         0 $code_category =~ s/^(\d)\d\d$/${1}xx/;
575              
576 0 0 0     0 if (exists $matrix->{$label} and exists $matrix->{$label}{$code}) {
    0 0        
    0 0        
    0 0        
577 0         0 return $matrix->{$label}{$code};
578             }
579             elsif (exists $matrix->{default} and exists $matrix->{default}{$code}) {
580 0         0 return $matrix->{default}{$code};
581             }
582             elsif (exists $matrix->{$label} and exists $matrix->{$label}{$code_category}) {
583 0         0 return $matrix->{$label}{$code_category};
584             }
585             elsif (exists $matrix->{default} and exists $matrix->{default}{$code_category}) {
586 0         0 return $matrix->{default}{$code_category};
587             }
588              
589 0         0 return 'log';
590             }
591              
592             sub _fetch_messages {
593 2     2   5 my ($self, $filter) = @_;
594 2         2 my $messages = [ ];
595 2         4 my $fetched_count = 0;
596             # check if expecting to fetch more using result paging
597 2         3 my ($decoded_response);
598 2 50       5 if ($self->_next_fetch_url) {
599 0         0 my $response = $self->_client->get_request_by_url($self->_next_fetch_url);
600 0 0       0 unless ($response->is_success) {
601 0 0       0 if ($self->_check_matrix('_fetch_messages', $response->code) ne 'ignore') {
602 0         0 warn "failed to fetch messages " . $response->status_line;
603 0 0       0 warn "response from server : " . $response->content if $self->debug;
604             }
605              
606 0         0 $self->_have_messages_to_fetch(0);
607 0         0 return 0;
608             }
609 0         0 $decoded_response = decode_json( $response->content );
610             } else {
611 2         6 my $fields = [qw(id subject sender isRead sentDateTime toRecipients parentFolderId categories)];
612 2         7 $decoded_response = $self->_get_message_list($fields, $filter);
613             }
614              
615 2         121 $messages = $decoded_response->{value};
616 2 50       6 if ($decoded_response->{'@odata.nextLink'}) {
617 0         0 $self->_next_fetch_url($decoded_response->{'@odata.nextLink'});
618 0         0 $self->_have_messages_to_fetch(1);
619             } else {
620 2         8 $self->_have_messages_to_fetch(0);
621             }
622 2         5 $self->_fetched_messages($messages);
623 2         3 return $fetched_count;
624             }
625              
626             sub _get_message_list {
627 2     2   2 my ($self, $fields, $filter) = @_;
628              
629 2         6 my $folder = $self->get_folder_details;
630 2 50       6 unless ($folder) {
631 0         0 die "unable to fetch messages, can't find folder " . $self->folder;
632             }
633              
634             # don't request list if folder has no items
635 2 50       5 unless ($folder->{totalItemCount} > 0) {
636 0         0 return { '@odata.count' => 0, value => [ ] };
637             }
638 2   33     11 $filter ||= $self->_get_message_filters;
639              
640             #TODO: handle filtering multiple folders using filters
641 2 50       10 my @path_parts = ($self->global_access) ? ( 'users', $self->username, 'mailFolders', $folder->{id}, 'messages' ) : ( 'me', 'mailFolders', $folder->{id}, 'messages' );
642              
643             # get oldest first, filter (i.e. unread) if filter provided
644 2 50       63 my $response = $self->_client->get_request(
    50          
645             [@path_parts],
646             {
647             '$count' => 'true', '$orderby' => 'sentDateTime',
648             ( $fields ? ('$select' => join(',',@$fields) ) : ( )),
649             ( $filter ? ('$filter' => $filter ) : ( ))
650             }
651             );
652              
653 2 50       1770 unless ($response->is_success) {
654 0 0       0 if ($self->_check_matrix('_get_message_list', $response->code) ne 'ignore') {
655 0         0 warn "failed to fetch messages " . $response->status_line;
656 0 0       0 warn "response from server : " . $response->content if $self->debug;
657             }
658 0         0 return { value => [ ] };
659             }
660              
661 2         16 return decode_json( $response->content );
662             }
663              
664             sub _get_message_filters {
665 2     2   2 my $self = shift;
666             #TODO: handle filtering multiple folders
667 2         3 my $filters = [ ];
668 2 50 33     14 if ( $self->post_fetch_action && ($self->post_fetch_action eq 'mark_message_as_read')) {
669 2         3 push(@$filters, 'isRead eq false');
670             }
671              
672 2         5 my $filter = join(' ', @$filters);
673 2         7 return $filter;
674             }
675              
676             sub _build_client {
677 2     2   31 my $self = shift;
678 2 100       71 my $client = App::wsgetmail::MS365::Client->new( {
    100          
679             client_id => $self->client_id,
680             username => $self->username,
681             user_password => $self->user_password,
682             secret => $self->secret,
683             tenant_id => $self->tenant_id,
684             global_access => $self->global_access,
685             debug => $self->debug,
686             ( defined $self->resource_url ? ( resource_url => $self->resource_url ) : () ),
687             ( defined $self->login_base_url ? ( login_base_url => $self->login_base_url ) : () ),
688             } );
689 2         14 return $client;
690              
691             }
692              
693             =head1 AUTHOR
694              
695             Best Practical Solutions, LLC
696              
697             =head1 LICENSE AND COPYRIGHT
698              
699             This software is Copyright (c) 2020 by Best Practical Solutions, LLC.
700              
701             This is free software, licensed under:
702              
703             The GNU General Public License, Version 2, June 1991
704              
705             =cut
706              
707             1;