File Coverage

lib/App/wsgetmail/MS365.pm
Criterion Covered Total %
statement 66 108 61.1
branch 15 54 27.7
condition 2 6 33.3
subroutine 12 15 80.0
pod 5 5 100.0
total 100 188 53.1


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   1599 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   8 use Moo;
  2         13  
  2         9  
60 2     2   1194 use JSON;
  2         7348  
  2         21  
61              
62 2     2   611 use App::wsgetmail::MS365::Client;
  2         4  
  2         63  
63 2     2   783 use App::wsgetmail::MS365::Message;
  2         4  
  2         47  
64 2     2   635 use File::Temp;
  2         7964  
  2         2724  
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 debug
190              
191             A boolean. If true, the object will issue a warning with details about each
192             request it issues.
193              
194             =cut
195              
196             has debug => (
197             is => 'rw',
198             default => sub { return 0 }
199             );
200              
201             ###
202              
203             has _client => (
204             is => 'ro',
205             lazy => 1,
206             builder => '_build_client',
207             );
208              
209             has _fetched_messages => (
210             is => 'rw',
211             required => 0,
212             default => sub { [ ] }
213             );
214              
215             has _have_messages_to_fetch => (
216             is => 'rw',
217             required => 0,
218             default => sub { 1 }
219             );
220              
221             has _next_fetch_url => (
222             is => 'rw',
223             required => 0,
224             default => sub { '' }
225             );
226              
227              
228             # this sets the attributes in the object using values from the config.
229             # if no value is defined in the config, the attribute's "default" is used
230             # instead (if defined).
231             around BUILDARGS => sub {
232             my ( $orig, $class, $config ) = @_;
233              
234             my $attributes = {
235             map {
236             $_ => $config->{$_}
237             }
238             grep {
239             defined $config->{$_}
240             }
241             qw(client_id tenant_id username user_password global_access secret folder post_fetch_action debug)
242             };
243              
244             return $class->$orig($attributes);
245             };
246              
247              
248             =head1 METHODS
249              
250             =head2 new
251              
252             Class constructor method, returns new App::wsgetmail::MS365 object
253              
254             =head2 get_next_message
255              
256             Object method, returns the next message as an App::wsgetmail::MS365::Message object if there is one.
257              
258             Will lazily fetch messages until the list is exhausted.
259              
260             =cut
261              
262             sub get_next_message {
263 2     2 1 1304 my ($self) = @_;
264 2         2 my $next_message;
265              
266             # check for already fetched messages, otherwise fetch more
267 2         3 my $message_details = shift @{$self->_fetched_messages};
  2         5  
268 2 100       7 unless ( $message_details ) {
269 1 50       3 if ($self->_have_messages_to_fetch) {
270 1         2 $self->_fetch_messages();
271 1         1 $message_details = shift @{$self->_fetched_messages};
  1         2  
272             }
273             }
274 2 50       5 if (defined $message_details) {
275 2         19 $next_message = App::wsgetmail::MS365::Message->new($message_details);
276             }
277 2         72 return $next_message;
278             }
279              
280             =head2 get_message_mime_content
281              
282             Object method, takes message id and returns filename of fetched raw mime file for that message.
283              
284             =cut
285              
286             sub get_message_mime_content {
287 0     0 1 0 my ($self, $message_id) = @_;
288 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id, '$value') : ('me', 'messages', $message_id, '$value');
289              
290 0         0 my $response = $self->_client->get_request([@path_parts]);
291 0 0       0 unless ($response->is_success) {
292 0         0 warn "failed to fetch message $message_id " . $response->status_line;
293 0 0       0 warn "response from server : " . $response->content if $self->debug;
294 0         0 return undef;
295             }
296              
297             # can we just write straight to file from response?
298 0         0 my $tmp = File::Temp->new( UNLINK => 0, SUFFIX => '.mime' );
299 0         0 print $tmp $response->content;
300 0         0 return $tmp->filename;
301             }
302              
303             =head2 delete_message
304              
305             Object method, takes message id and deletes that message from the outlook365 mailbox
306              
307             =cut
308              
309             sub delete_message {
310 0     0 1 0 my ($self, $message_id) = @_;
311 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
312 0         0 my $response = $self->_client->delete_request([@path_parts]);
313 0 0       0 unless ($response->is_success) {
314 0         0 warn "failed to mark message as read " . $response->status_line;
315 0 0       0 warn "response from server : " . $response->content if $self->debug;
316             }
317              
318 0         0 return $response;
319             }
320              
321             =head2 mark_message_as_read
322              
323             Object method, takes message id and marks that message as read in the outlook365 mailbox
324              
325             =cut
326              
327             sub mark_message_as_read {
328 0     0 1 0 my ($self, $message_id) = @_;
329 0 0       0 my @path_parts = ($self->global_access) ? ('users', $self->username, 'messages', $message_id) : ('me', 'messages', $message_id);
330 0         0 my $response = $self->_client->patch_request([@path_parts],
331             {'Content-type'=> 'application/json',
332             Content => encode_json({isRead => $JSON::true }) });
333 0 0       0 unless ($response->is_success) {
334 0         0 warn "failed to mark message as read " . $response->status_line;
335 0 0       0 warn "response from server : " . $response->content if $self->debug;
336             }
337              
338 0         0 return $response;
339             }
340              
341              
342             =head2 get_folder_details
343              
344             Object method, returns hashref of details of the configured mailbox folder.
345              
346             =cut
347              
348             sub get_folder_details {
349 1     1 1 2 my $self = shift;
350 1         17 my $folder_name = $self->folder;
351 1 50       14 my @path_parts = ($self->global_access) ? ('users', $self->username, 'mailFolders' ) : ('me', 'mailFolders');
352 1         20 my $response = $self->_client->get_request(
353             [@path_parts], { '$filter' => "DisplayName eq '$folder_name'" }
354             );
355 1 50       1059 unless ($response->is_success) {
356 0         0 warn "failed to fetch folder detail " . $response->status_line;
357 0 0       0 warn "response from server : " . $response->content if $self->debug;
358 0         0 return undef;
359             }
360              
361 1         8 my $folders = decode_json( $response->content );
362 1         18 return $folders->{value}[0];
363             }
364              
365              
366             ##############
367              
368             sub _fetch_messages {
369 1     1   2 my ($self, $filter) = @_;
370 1         2 my $messages = [ ];
371 1         1 my $fetched_count = 0;
372             # check if expecting to fetch more using result paging
373 1         1 my ($decoded_response);
374 1 50       3 if ($self->_next_fetch_url) {
375 0         0 my $response = $self->_client->get_request_by_url($self->_next_fetch_url);
376 0 0       0 unless ($response->is_success) {
377 0         0 warn "failed to fetch messages " . $response->status_line;
378 0 0       0 warn "response from server : " . $response->content if $self->debug;
379 0         0 $self->_have_messages_to_fetch(0);
380 0         0 return 0;
381             }
382 0         0 $decoded_response = decode_json( $response->content );
383             } else {
384 1         2 my $fields = [qw(id subject sender isRead sentDateTime toRecipients parentFolderId categories)];
385 1         3 $decoded_response = $self->_get_message_list($fields, $filter);
386             }
387              
388 1         77 $messages = $decoded_response->{value};
389 1 50       3 if ($decoded_response->{'@odata.nextLink'}) {
390 0         0 $self->_next_fetch_url($decoded_response->{'@odata.nextLink'});
391 0         0 $self->_have_messages_to_fetch(1);
392             } else {
393 1         4 $self->_have_messages_to_fetch(0);
394             }
395 1         3 $self->_fetched_messages($messages);
396 1         2 return $fetched_count;
397             }
398              
399             sub _get_message_list {
400 1     1   2 my ($self, $fields, $filter) = @_;
401              
402 1         2 my $folder = $self->get_folder_details;
403 1 50       2 unless ($folder) {
404 0         0 warn "unable to fetch messages, can't find folder " . $self->folder;
405 0         0 return { '@odata.count' => 0, value => [ ] };
406             }
407              
408             # don't request list if folder has no items
409 1 50       3 unless ($folder->{totalItemCount} > 0) {
410 0         0 return { '@odata.count' => 0, value => [ ] };
411             }
412 1   33     4 $filter ||= $self->_get_message_filters;
413              
414             #TODO: handle filtering multiple folders using filters
415 1 50       4 my @path_parts = ($self->global_access) ? ( 'users', $self->username, 'mailFolders', $folder->{id}, 'messages' ) : ( 'me', 'mailFolders', $folder->{id}, 'messages' );
416              
417             # get oldest first, filter (i.e. unread) if filter provided
418 1 50       23 my $response = $self->_client->get_request(
    50          
419             [@path_parts],
420             {
421             '$count' => 'true', '$orderby' => 'sentDateTime',
422             ( $fields ? ('$select' => join(',',@$fields) ) : ( )),
423             ( $filter ? ('$filter' => $filter ) : ( ))
424             }
425             );
426              
427 1 50       975 unless ($response->is_success) {
428 0         0 warn "failed to fetch messages " . $response->status_line;
429 0 0       0 warn "response from server : " . $response->content if $self->debug;
430 0         0 return { value => [ ] };
431             }
432              
433 1         9 return decode_json( $response->content );
434             }
435              
436             sub _get_message_filters {
437 1     1   1 my $self = shift;
438             #TODO: handle filtering multiple folders
439 1         12 my $filters = [ ];
440 1 50 33     9 if ( $self->post_fetch_action && ($self->post_fetch_action eq 'mark_message_as_read')) {
441 1         2 push(@$filters, 'isRead eq false');
442             }
443              
444 1         4 my $filter = join(' ', @$filters);
445 1         4 return $filter;
446             }
447              
448             sub _build_client {
449 1     1   7 my $self = shift;
450 1         15 my $client = App::wsgetmail::MS365::Client->new( {
451             client_id => $self->client_id,
452             username => $self->username,
453             user_password => $self->user_password,
454             secret => $self->secret,
455             client_id => $self->client_id,
456             tenant_id => $self->tenant_id,
457             global_access => $self->global_access,
458             debug => $self->debug,
459             } );
460 1         9 return $client;
461              
462             }
463              
464             =head1 AUTHOR
465              
466             Best Practical Solutions, LLC
467              
468             =head1 LICENSE AND COPYRIGHT
469              
470             This software is Copyright (c) 2020 by Best Practical Solutions, LLC.
471              
472             This is free software, licensed under:
473              
474             The GNU General Public License, Version 2, June 1991
475              
476             =cut
477              
478             1;