File Coverage

lib/App/wsgetmail/MS365.pm
Criterion Covered Total %
statement 66 109 60.5
branch 15 56 26.7
condition 2 6 33.3
subroutine 12 15 80.0
pod 5 5 100.0
total 100 191 52.3


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   2390 use v5.10;
  2         11  
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   10 use Moo;
  2         19  
  2         18  
60 2     2   1440 use JSON;
  2         8336  
  2         20  
61              
62 2     2   903 use App::wsgetmail::MS365::Client;
  2         6  
  2         73  
63 2     2   1135 use App::wsgetmail::MS365::Message;
  2         7  
  2         81  
64 2     2   878 use File::Temp;
  2         9121  
  2         3169  
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             })
77              
78             =head1 DESCRIPTION
79              
80             Moo class providing methods to connect to and fetch mail from Microsoft 365
81             mailboxes using the Graph REST API.
82              
83             =head1 ATTRIBUTES
84              
85             You must provide C, C, C, and
86             authentication details. If C is false (the default), you must
87             provide C and C. If you set C to a
88             true value, you must provide C.
89              
90             =head2 client_id
91              
92             A string with the UUID of the client application to use for authentication.
93              
94             =cut
95              
96             has client_id => (
97             is => 'ro',
98             required => 1,
99             );
100              
101             =head2 tenant_id
102              
103             A string with the UUID of your Microsoft 365 tenant to use for authentication.
104              
105             =cut
106              
107             has tenant_id => (
108             is => 'ro',
109             required => 1,
110             );
111              
112             =head2 username
113              
114             A string with a username email address. If C is false (the
115             default), the client authenticates with this username. If C
116             is true, the client accesses this user's mailboxes.
117              
118             =cut
119              
120             has username => (
121             is => 'ro',
122             required => 0
123             );
124              
125             =head2 user_password
126              
127             A string with the user password to use for authentication without global
128             access.
129              
130             =cut
131              
132             has user_password => (
133             is => 'ro',
134             required => 0
135             );
136              
137             =head2 folder
138              
139             A string with the name of the email folder to read. Default "Inbox".
140              
141             =cut
142              
143             has folder => (
144             is => 'ro',
145             required => 0,
146             default => sub { 'Inbox' }
147             );
148              
149             =head2 global_access
150              
151             A boolean. If false (the default), the client will authenticate using
152             C and C. If true, the client will authenticate
153             using its C token.
154              
155             =cut
156              
157             has global_access => (
158             is => 'ro',
159             default => sub { return 0 }
160             );
161              
162             =head2 secret
163              
164             A string with the client secret to use for global authentication. This
165             should look like a long string of completely random characters, not a UUID
166             or other recognizable format.
167              
168             =cut
169              
170             has secret => (
171             is => 'ro',
172             required => 0,
173             );
174              
175             =head2 post_fetch_action
176              
177             A string with the name of a method to call after reading a message. You
178             probably want to pass either "mark_message_as_read" or "delete_message". In
179             principle, you can pass the name of any method that accepts a message ID
180             string argument.
181              
182             =cut
183              
184             has post_fetch_action => (
185             is => 'ro',
186             required => 1
187             );
188              
189             =head2 strip_cr
190              
191             A boolean. If true, the message content will have CRLF line terminators
192             converted to LF line terminators.
193              
194             =cut
195              
196             has strip_cr => (
197             is => 'ro',
198             required => 0,
199             );
200              
201             =head2 debug
202              
203             A boolean. If true, the object will issue a warning with details about each
204             request it issues.
205              
206             =cut
207              
208             has debug => (
209             is => 'rw',
210             default => sub { return 0 }
211             );
212              
213             ###
214              
215             has _client => (
216             is => 'ro',
217             lazy => 1,
218             builder => '_build_client',
219             );
220              
221             has _fetched_messages => (
222             is => 'rw',
223             required => 0,
224             default => sub { [ ] }
225             );
226              
227             has _have_messages_to_fetch => (
228             is => 'rw',
229             required => 0,
230             default => sub { 1 }
231             );
232              
233             has _next_fetch_url => (
234             is => 'rw',
235             required => 0,
236             default => sub { '' }
237             );
238              
239              
240             # this sets the attributes in the object using values from the config.
241             # if no value is defined in the config, the attribute's "default" is used
242             # instead (if defined).
243             around BUILDARGS => sub {
244             my ( $orig, $class, $config ) = @_;
245              
246             my $attributes = {
247             map {
248             $_ => $config->{$_}
249             }
250             grep {
251             defined $config->{$_}
252             }
253             qw(client_id tenant_id username user_password global_access secret folder post_fetch_action strip_cr debug)
254             };
255              
256             return $class->$orig($attributes);
257             };
258              
259              
260             =head1 METHODS
261              
262             =head2 new
263              
264             Class constructor method, returns new App::wsgetmail::MS365 object
265              
266             =head2 get_next_message
267              
268             Object method, returns the next message as an App::wsgetmail::MS365::Message object if there is one.
269              
270             Will lazily fetch messages until the list is exhausted.
271              
272             =cut
273              
274             sub get_next_message {
275 2     2 1 1839 my ($self) = @_;
276 2         4 my $next_message;
277              
278             # check for already fetched messages, otherwise fetch more
279 2         4 my $message_details = shift @{$self->_fetched_messages};
  2         12  
280 2 100       10 unless ( $message_details ) {
281 1 50       5 if ($self->_have_messages_to_fetch) {
282 1         5 $self->_fetch_messages();
283 1         2 $message_details = shift @{$self->_fetched_messages};
  1         5  
284             }
285             }
286 2 50       8 if (defined $message_details) {
287 2         33 $next_message = App::wsgetmail::MS365::Message->new($message_details);
288             }
289 2         90 return $next_message;
290             }
291              
292             =head2 get_message_mime_content
293              
294             Object method, takes message id and returns filename of fetched raw mime file for that message.
295              
296             =cut
297              
298             sub get_message_mime_content {
299 0     0 1 0 my ($self, $message_id) = @_;
300 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id, '$value') : ('me', 'messages', $message_id, '$value');
301              
302 0         0 my $response = $self->_client->get_request([@path_parts]);
303 0 0       0 unless ($response->is_success) {
304 0         0 warn "failed to fetch message $message_id " . $response->status_line;
305 0 0       0 warn "response from server : " . $response->content if $self->debug;
306 0         0 return undef;
307             }
308              
309             # can we just write straight to file from response?
310 0         0 my $tmp = File::Temp->new( UNLINK => 0, SUFFIX => '.mime' );
311 0         0 my $content = $response->content;
312 0 0       0 $content =~ s/\r$//mg if $self->strip_cr;
313 0         0 print $tmp $content;
314 0         0 return $tmp->filename;
315             }
316              
317             =head2 delete_message
318              
319             Object method, takes message id and deletes that message from the outlook365 mailbox
320              
321             =cut
322              
323             sub delete_message {
324 0     0 1 0 my ($self, $message_id) = @_;
325 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
326 0         0 my $response = $self->_client->delete_request([@path_parts]);
327 0 0       0 unless ($response->is_success) {
328 0         0 warn "failed to delete message " . $response->status_line;
329 0 0       0 warn "response from server : " . $response->content if $self->debug;
330             }
331              
332 0         0 return $response;
333             }
334              
335             =head2 mark_message_as_read
336              
337             Object method, takes message id and marks that message as read in the outlook365 mailbox
338              
339             =cut
340              
341             sub mark_message_as_read {
342 0     0 1 0 my ($self, $message_id) = @_;
343 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
344 0         0 my $response = $self->_client->patch_request([@path_parts],
345             {'Content-type'=> 'application/json',
346             Content => encode_json({isRead => $JSON::true }) });
347 0 0       0 unless ($response->is_success) {
348 0         0 warn "failed to mark message as read " . $response->status_line;
349 0 0       0 warn "response from server : " . $response->content if $self->debug;
350             }
351              
352 0         0 return $response;
353             }
354              
355              
356             =head2 get_folder_details
357              
358             Object method, returns hashref of details of the configured mailbox folder.
359              
360             =cut
361              
362             sub get_folder_details {
363 1     1 1 47 my $self = shift;
364 1         6 my $folder_name = $self->folder;
365 1 50       6 my @path_parts = ($self->global_access) ? ('users', $self->username, 'mailFolders' ) : ('me', 'mailFolders');
366 1         26 my $response = $self->_client->get_request(
367             [@path_parts], { '$filter' => "DisplayName eq '$folder_name'" }
368             );
369 1 50       1318 unless ($response->is_success) {
370 0         0 warn "failed to fetch folder detail " . $response->status_line;
371 0 0       0 warn "response from server : " . $response->content if $self->debug;
372 0         0 return undef;
373             }
374              
375 1         12 my $folders = decode_json( $response->content );
376 1         26 return $folders->{value}[0];
377             }
378              
379              
380             ##############
381              
382             sub _fetch_messages {
383 1     1   4 my ($self, $filter) = @_;
384 1         3 my $messages = [ ];
385 1         2 my $fetched_count = 0;
386             # check if expecting to fetch more using result paging
387 1         2 my ($decoded_response);
388 1 50       4 if ($self->_next_fetch_url) {
389 0         0 my $response = $self->_client->get_request_by_url($self->_next_fetch_url);
390 0 0       0 unless ($response->is_success) {
391 0         0 warn "failed to fetch messages " . $response->status_line;
392 0 0       0 warn "response from server : " . $response->content if $self->debug;
393 0         0 $self->_have_messages_to_fetch(0);
394 0         0 return 0;
395             }
396 0         0 $decoded_response = decode_json( $response->content );
397             } else {
398 1         12 my $fields = [qw(id subject sender isRead sentDateTime toRecipients parentFolderId categories)];
399 1         5 $decoded_response = $self->_get_message_list($fields, $filter);
400             }
401              
402 1         83 $messages = $decoded_response->{value};
403 1 50       4 if ($decoded_response->{'@odata.nextLink'}) {
404 0         0 $self->_next_fetch_url($decoded_response->{'@odata.nextLink'});
405 0         0 $self->_have_messages_to_fetch(1);
406             } else {
407 1         8 $self->_have_messages_to_fetch(0);
408             }
409 1         5 $self->_fetched_messages($messages);
410 1         2 return $fetched_count;
411             }
412              
413             sub _get_message_list {
414 1     1   3 my ($self, $fields, $filter) = @_;
415              
416 1         6 my $folder = $self->get_folder_details;
417 1 50       5 unless ($folder) {
418 0         0 die "unable to fetch messages, can't find folder " . $self->folder;
419             }
420              
421             # don't request list if folder has no items
422 1 50       5 unless ($folder->{totalItemCount} > 0) {
423 0         0 return { '@odata.count' => 0, value => [ ] };
424             }
425 1   33     9 $filter ||= $self->_get_message_filters;
426              
427             #TODO: handle filtering multiple folders using filters
428 1 50       7 my @path_parts = ($self->global_access) ? ( 'users', $self->username, 'mailFolders', $folder->{id}, 'messages' ) : ( 'me', 'mailFolders', $folder->{id}, 'messages' );
429              
430             # get oldest first, filter (i.e. unread) if filter provided
431 1 50       74 my $response = $self->_client->get_request(
    50          
432             [@path_parts],
433             {
434             '$count' => 'true', '$orderby' => 'sentDateTime',
435             ( $fields ? ('$select' => join(',',@$fields) ) : ( )),
436             ( $filter ? ('$filter' => $filter ) : ( ))
437             }
438             );
439              
440 1 50       1205 unless ($response->is_success) {
441 0         0 warn "failed to fetch messages " . $response->status_line;
442 0 0       0 warn "response from server : " . $response->content if $self->debug;
443 0         0 return { value => [ ] };
444             }
445              
446 1         12 return decode_json( $response->content );
447             }
448              
449             sub _get_message_filters {
450 1     1   3 my $self = shift;
451             #TODO: handle filtering multiple folders
452 1         3 my $filters = [ ];
453 1 50 33     14 if ( $self->post_fetch_action && ($self->post_fetch_action eq 'mark_message_as_read')) {
454 1         3 push(@$filters, 'isRead eq false');
455             }
456              
457 1         6 my $filter = join(' ', @$filters);
458 1         4 return $filter;
459             }
460              
461             sub _build_client {
462 1     1   11 my $self = shift;
463 1         27 my $client = App::wsgetmail::MS365::Client->new( {
464             client_id => $self->client_id,
465             username => $self->username,
466             user_password => $self->user_password,
467             secret => $self->secret,
468             client_id => $self->client_id,
469             tenant_id => $self->tenant_id,
470             global_access => $self->global_access,
471             debug => $self->debug,
472             } );
473 1         18 return $client;
474              
475             }
476              
477             =head1 AUTHOR
478              
479             Best Practical Solutions, LLC
480              
481             =head1 LICENSE AND COPYRIGHT
482              
483             This software is Copyright (c) 2020 by Best Practical Solutions, LLC.
484              
485             This is free software, licensed under:
486              
487             The GNU General Public License, Version 2, June 1991
488              
489             =cut
490              
491             1;