File Coverage

lib/App/wsgetmail.pm
Criterion Covered Total %
statement 27 57 47.3
branch 2 22 9.0
condition 0 3 0.0
subroutine 7 10 70.0
pod 2 2 100.0
total 38 94 40.4


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   1090 use v5.10;
  2         6  
50              
51             package App::wsgetmail;
52              
53 2     2   528 use Moo;
  2         9802  
  2         8  
54              
55             our $VERSION = '0.08';
56              
57             =head1 NAME
58              
59             App::wsgetmail - Fetch mail from the cloud using webservices
60              
61             =head1 SYNOPSIS
62              
63             Run:
64              
65             wsgetmail [options] --config=wsgetmail.json
66              
67             where C looks like:
68              
69             {
70             "client_id": "abcd1234-xxxx-xxxx-xxxx-1234abcdef99",
71             "tenant_id": "abcd1234-xxxx-xxxx-xxxx-123abcde1234",
72             "secret": "abcde1fghij2klmno3pqrst4uvwxy5~0",
73             "global_access": 1,
74             "username": "rt-comment@example.com",
75             "folder": "Inbox",
76             "stripcr": 0,
77             "command": "/opt/rt5/bin/rt-mailgate",
78             "command_args": "--url=http://rt.example.com/ --queue=General --action=comment",
79             "command_timeout": 30,
80             "action_on_fetched": "mark_as_read"
81             }
82              
83             Using App::wsgetmail as a library looks like:
84              
85             my $getmail = App::wsgetmail->new({config => {
86             # The config hashref takes all the same keys and values as the
87             # command line tool configuration JSON.
88             }});
89             while (my $message = $getmail->get_next_message()) {
90             $getmail->process_message($message)
91             or warn "could not process $message->id";
92             }
93              
94             =head1 DESCRIPTION
95              
96             wsgetmail retrieves mail from a folder available through a web services API
97             and delivers it to another system. Currently, it only knows how to retrieve
98             mail from the Microsoft Graph API, and deliver it by running another command
99             on the local system.
100              
101             =head1 INSTALLATION
102              
103             perl Makefile.PL
104             make
105             make test
106             sudo make install
107              
108             C will be installed under C if you're using the
109             system Perl, or in the same directory as C if you built your own.
110              
111             =cut
112              
113 2     2   1912 use Clone 'clone';
  2         2190  
  2         91  
114 2     2   859 use Module::Load;
  2         1887  
  2         12  
115 2     2   787 use App::wsgetmail::MDA;
  2         7  
  2         1249  
116              
117             =head1 ATTRIBUTES
118              
119             =head2 config
120              
121             A hash ref that is passed to construct the C and C (see below).
122              
123             =cut
124              
125             has config => (
126             is => 'ro',
127             required => 1
128             );
129              
130             =head2 mda
131              
132             An instance of L created from our C object.
133              
134             =cut
135              
136             has mda => (
137             is => 'rw',
138             lazy => 1,
139             handles => [ qw(forward) ],
140             builder => '_build_mda'
141             );
142              
143             =head2 client_class
144              
145             The name of the App::wsgetmail package used to construct the
146             C. Default C.
147              
148             =cut
149              
150             has client_class => (
151             is => 'ro',
152             default => sub { 'MS365' }
153             );
154              
155             =head2 client
156              
157             An instance of the C created from our C object.
158              
159             =cut
160              
161             has client => (
162             is => 'ro',
163             lazy => 1,
164             handles => [ qw( get_next_message
165             get_message_mime_content
166             mark_message_as_read
167             delete_message) ],
168             builder => '_build_client'
169             );
170              
171              
172             has _post_fetch_action => (
173             is => 'ro',
174             lazy => 1,
175             builder => '_build__post_fetch_action'
176             );
177              
178              
179             sub _build__post_fetch_action {
180 1     1   8 my $self = shift;
181 1         2 my $fetched_action_method;
182 1         3 my $action = $self->config->{action_on_fetched};
183 1 50       3 return undef unless (defined $action);
184 1 50       4 if (lc($action) eq 'mark_as_read') {
    0          
185 1         2 $fetched_action_method = 'mark_message_as_read';
186             } elsif ( lc($action) eq "delete" ) {
187 0         0 $fetched_action_method = 'delete_message';
188             } else {
189 0         0 $fetched_action_method = undef;
190 0         0 warn "no recognised action for fetched mail, mailbox not updated";
191             }
192 1         3 return $fetched_action_method;
193             }
194              
195             =head1 METHODS
196              
197             =head2 process_message($message)
198              
199             Given a Message object, retrieves the full message content, delivers it
200             using the C, and then executes the configured post-fetch
201             action. Returns a boolean indicating success.
202              
203             =cut
204              
205             sub process_message {
206 0     0 1 0 my ($self, $message) = @_;
207 0         0 my $client = $self->client;
208 0         0 my $filename = $client->get_message_mime_content($message->id);
209 0 0       0 unless ($filename) {
210 0         0 warn "failed to get mime content for message ". $message->id;
211 0         0 return 0;
212             }
213 0         0 my $ok = $self->forward($message, $filename);
214 0 0       0 if ($ok) {
215 0         0 $ok = $self->post_fetch_action($message);
216             }
217 0 0       0 if ($self->config->{dump_messages}) {
218 0 0       0 warn "dumped message in file $filename" if ($self->config->{debug});
219             }
220             else {
221 0 0       0 unlink $filename or warn "couldn't delete message file $filename : $!";
222             }
223 0         0 return $ok;
224             }
225              
226             =head2 post_fetch_action($message)
227              
228             Given a Message object, executes the configured post-fetch action. Returns a
229             boolean indicating success.
230              
231             =cut
232              
233             sub post_fetch_action {
234 0     0 1 0 my ($self, $message) = @_;
235 0         0 my $method = $self->_post_fetch_action;
236 0         0 my $ok = 1;
237             # check for dry-run option
238 0 0       0 if ($self->config->{dry_run}) {
239 0         0 warn "dry run so not running $method action on fetched mail";
240 0         0 return 1;
241             }
242 0 0       0 if ($method) {
243 0         0 $ok = $self->$method($message->id);
244             }
245 0         0 return $ok;
246             }
247              
248             ###
249              
250             sub _build_client {
251 1     1   481 my $self = shift;
252 1         5 my $classname = 'App::wsgetmail::' . $self->client_class;
253 1         4 load $classname;
254 1         25 my $config = clone $self->config;
255 1         25 $config->{post_fetch_action} = $self->_post_fetch_action;
256 1         4 return $classname->new($config);
257             }
258              
259              
260             sub _build_mda {
261 0     0     my $self = shift;
262 0           my $config = clone $self->config;
263 0 0         if ( defined $self->config->{username}) {
264 0   0       $config->{recipient} //= $self->config->{username};
265             }
266 0           return App::wsgetmail::MDA->new($config);
267             }
268              
269             =head1 CONFIGURATION
270              
271             =head2 Configuring Microsoft 365 Client Access
272              
273             To use wsgetmail, first you need to set up the app in Microsoft 365.
274             Two authentication methods are supported:
275              
276             =over
277              
278             =item Client Credentials
279              
280             This method uses shared secrets and is preferred by Microsoft.
281             (See L)
282              
283             =item Username/password
284              
285             This method is more like previous connections via IMAP. It is currently
286             supported by Microsoft, but not recommended. (See L)
287              
288             =back
289              
290             This section walks you through each piece of configuration wsgetmail needs,
291             and how to obtain it.
292              
293             =over 4
294              
295             =item tenant_id
296              
297             wsgetmail authenticates to an Azure Active Directory (AD) tenant. This
298             tenant is identified by an identifier that looks like a UUID/GUID: it should
299             be mostly alphanumeric, with dividing dashes in the same places as shown in
300             the example configuration above. Microsoft documents how to find your tenant
301             ID, and create a tenant if needed, in the L<"Set up a tenant"
302             quickstart|https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-create-new-tenant>. Save
303             this as the C string in your wsgetmail configuration file.
304              
305             =item client_id
306              
307             You need to register wsgetmail as an application in your Azure Active
308             Directory tenant. Microsoft documents how to do this in the L<"Register an
309             application with the Microsoft identity platform"
310             quickstart|https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app#register-an-application>,
311             under the section "Register an application." When asked who can use this
312             application, you can leave that at the default "Accounts in this
313             organizational directory only (Single tenant)."
314              
315             After you successfully register the wsgetmail application, its information
316             page in your Azure account will display an "Application (client) ID" in the
317             same UUID/GUID format as your tenant ID. Save this as the C
318             string in your configuration file.
319              
320             After that is done, you need to grant wsgetmail permission to access the
321             Microsoft Graph mail APIs. Microsoft documents how to do this in the
322             L<"Configure a client application to access a web API"
323             quickstart|https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-configure-app-access-web-apis#application-permission-to-microsoft-graph>,
324             under the section "Add permissions to access Microsoft Graph." When selecting
325             the type of permissions, select "Application permissions." When prompted to
326             select permissions, select all of the following:
327              
328             =over 4
329              
330             =item * Mail.Read
331              
332             =item * Mail.Read.Shared
333              
334             =item * Mail.ReadWrite
335              
336             =item * Mail.ReadWrite.Shared
337              
338             =item * openid
339              
340             =item * User.Read
341              
342             =back
343              
344             =back
345              
346             =head3 Configuring client secret authentication
347              
348             We recommend you deploy wsgetmail by configuring it with a client
349             secret. Client secrets can be granted limited access to only the mailboxes
350             you choose. You can adjust or revoke wsgetmail's access without interfering
351             with other applications.
352              
353             Microsoft documents how to create a client secret in the L<"Register an
354             application with the Microsoft identity platform"
355             quickstart|https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app#add-a-client-secret>,
356             under the section "Add a client secret." Take care to record the secret
357             token when it appears; it will never be displayed again. It should look like
358             a completely random string, not a UUID/GUID.
359              
360             =over 4
361              
362             =item global_access
363              
364             Set this to C<1> in your wsgetmail configuration file.
365              
366             =item secret
367              
368             Set this to the secret token string you recorded earlier in your wsgetmail
369             configuration file.
370              
371             =item username
372              
373             wsgetmail will fetch mail from this user's account. Set this to an email
374             address string in your wsgetmail configuration file.
375              
376             =back
377              
378             =head3 Configuring user+password authentication
379              
380             If you do not want to use a client secret, you can also configure wsgetmail
381             to authenticate with a traditional username+password combination. As noted
382             above, this method is not recommended by Microsoft. It also does not work
383             for systems with federated authentication enabled.
384              
385             =over 4
386              
387             =item global_access
388              
389             Set this to C<0> in your wsgetmail configuration file.
390              
391             =item username
392              
393             wsgetmail will authenticate as this user. Set this to an email address
394             string in your wsgetmail configuration file.
395              
396             =item user_password
397              
398             Set this to the password string for C in your wsgetmail
399             configuration file.
400              
401             =back
402              
403             =head2 Configuring the mail delivery command
404              
405             Now that you've configured wsgetmail to access a mail account, all that's
406             left is configuring delivery. Set the following in your wsgetmail
407             configuration file.
408              
409             =over 4
410              
411             =item folder
412              
413             Set this to the name string of a mail folder to read.
414              
415             =item stripcr
416              
417             Set this to 1 to make wsgetmail convert the messages from the CRLF
418             line-ending encoding to the LF line-ending encoding.
419              
420             This emulates the fetchmail option of the same name, which enabled
421             the stripcr option if an MDA was declared. The feature is similar,
422             but you need to enable it explicitly in your configuration.
423              
424             This option is helpful if you are forwarding email to a Linux
425             utility that doesn't work with CRLF line-endings.
426              
427             =item command
428              
429             Set this to an executable command. You can specify an absolute path,
430             or a plain command name which will be found from C<$PATH>. For each
431             email wsgetmail retrieves, it will run this command and pass the
432             message data to it via standard input.
433              
434             =item command_args
435              
436             Set this to a string with additional arguments to pass to C.
437             These arguments follow shell quoting rules: you can escape characters
438             with a backslash, and denote a single string argument with single or
439             double quotes.
440              
441             =item command_timeout
442              
443             Set this to the number of seconds the C has to return before
444             timeout is reached. The default value is 30. Use "inf" for no timeout.
445              
446             =item action_on_fetched
447              
448             Set this to a literal string C<"mark_as_read"> or C<"delete">.
449             For each email wsgetmail retrieves, after the configured delivery
450             command succeeds, it will take this action on the message.
451              
452             If you set this to C<"mark_as_read">, wsgetmail will only retrieve and
453             deliver messages that are marked unread in the configured folder, so it does
454             not try to deliver the same email multiple times.
455              
456             =item dump_messages
457              
458             Set this to 1 to preserve the temporary files after processing.
459              
460             When C<"debug"> is also set the filenames will be reported on STDERR.
461              
462             =back
463              
464             =head1 TESTING AND DEPLOYMENT
465              
466             After you write your wsgetmail configuration file, you can test it by running:
467              
468             wsgetmail --debug --dry-run --config=wsgetmail.json
469              
470             This will read and deliver messages, but will not mark them as read or
471             delete them. If there are any problems, those will be reported in the error
472             output. You can update your configuration file and try again until wsgetmail
473             runs successfully.
474              
475             Once your configuration is stable, you can configure wsgetmail to run
476             periodically through cron or a systemd service on a timer.
477              
478             =head1 ERRORS AND DIAGNOSTIC MESSAGES
479              
480             wsgetmail sends warning, error, and debug messages to STDERR, while purely
481             informational messages are sent to STDOUT. Operators may want to capture both
482             output streams as a merged stream for diagnostic purposes. For example:
483              
484             wsgetmail --debug --dry-run --config=wsgetmail.json > wsgetmail.debug 2>&1
485              
486             When the mail processing command exits with an error (non-zero) status the
487             action_on_fetched is not performed on that message so that it will be processed
488             on the next run.
489              
490             Full output of the processing command is produced with C<--debug>.
491              
492             =head1 LIMITATIONS
493              
494             =head2 Fetching from Multiple Folders
495              
496             wsgetmail can only read from a single folder each time it runs. If you need
497             to read multiple folders (possibly spanning different accounts), then you
498             need to run it multiple times with different configuration.
499              
500             If you only need to change a couple small configuration settings like the
501             folder name, you can use the C<--options> argument to override those from a
502             base configuration file. For example:
503              
504             wsgetmail --config=wsgetmail.json --options='{"folder": "Inbox"}'
505             wsgetmail --config=wsgetmail.json --options='{"folder": "Other Folder"}'
506              
507             NOTE: Setting C or C with C<--options> is not secure
508             and may expose your credentials to other users on the local system. If you
509             need to set these options, or just change a lot of settings in your
510             configuration, just run wsgetmail with different configurations:
511              
512             wsgetmail --config=account01.json
513             wsgetmail --config=account02.json
514              
515             =head2 Office 365 API Limits
516              
517             Microsoft applies some limits to the amount of API requests allowed as
518             documented in their L.
519             If you reach a limit, requests to the API will start failing for a period
520             of time.
521              
522             =head1 SEE ALSO
523              
524             =over 4
525              
526             =item * L
527              
528             =item * L
529              
530             =item * L
531              
532             =item * L
533              
534             =back
535              
536             =head1 AUTHOR
537              
538             Best Practical Solutions, LLC
539              
540             =head1 LICENSE AND COPYRIGHT
541              
542             This software is Copyright (c) 2015-2022 by Best Practical Solutions, LLC.
543              
544             This is free software, licensed under:
545              
546             The GNU General Public License, Version 2, June 1991
547              
548             =cut
549              
550             1;