File Coverage

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