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-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   361003 use v5.10;
  2         7  
50              
51             package App::wsgetmail;
52              
53 2     2   881 use Moo;
  2         10303  
  2         17  
54              
55             our $VERSION = '0.11';
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   2914 use Clone 'clone';
  2         732  
  2         134  
116 2     2   655 use Module::Load;
  2         1434  
  2         15  
117 2     2   949 use App::wsgetmail::MDA;
  2         6  
  2         1752  
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 2     2   14 my $self = shift;
183 2         4 my $fetched_action_method;
184 2         6 my $action = $self->config->{action_on_fetched};
185 2 50       6 return undef unless (defined $action);
186 2 50       7 if (lc($action) eq 'mark_as_read') {
    0          
187 2         3 $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 2         7 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 2     2   565 my $self = shift;
258 2         9 my $classname = 'App::wsgetmail::' . $self->client_class;
259 2         11 load $classname;
260 2         119 my $config = clone $self->config;
261 2         55 $config->{post_fetch_action} = $self->_post_fetch_action;
262 2         20 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             =head3 Configuring Microsoft Government Cloud
402              
403             If you are connecting to a Microsoft Government Cloud tenant (GCC High or
404             DoD), the default Microsoft Graph API and OAuth endpoints will not work. Use
405             the following options to point wsgetmail at the correct endpoints.
406              
407             =over 4
408              
409             =item resource_url
410              
411             Set this to the Microsoft Graph API base URL for your cloud environment.
412             The default is C. For GCC High, use
413             C. For DoD, use
414             C.
415              
416             =item login_base_url
417              
418             Set this to the base URL for OAuth authentication. The default is
419             C. For GCC High and DoD, use
420             C.
421              
422             =back
423              
424             =head2 Configuring the mail delivery command
425              
426             Now that you've configured wsgetmail to access a mail account, all that's
427             left is configuring delivery. Set the following in your wsgetmail
428             configuration file.
429              
430             =over 4
431              
432             =item folder
433              
434             Set this to the name string of a mail folder to read.
435              
436             =item stripcr
437              
438             Set this to 1 to make wsgetmail convert the messages from the CRLF
439             line-ending encoding to the LF line-ending encoding.
440              
441             This emulates the fetchmail option of the same name, which enabled
442             the stripcr option if an MDA was declared. The feature is similar,
443             but you need to enable it explicitly in your configuration.
444              
445             This option is helpful if you are forwarding email to a Linux
446             utility that doesn't work with CRLF line-endings.
447              
448             =item size_limit
449              
450             Set this to the max size in bytes. Messages bigger than it will be skipped.
451             Absence or 0 means to not limit size.
452              
453             E.g. to skip messages bigger than C<10MiB>, you can set it to C<10485760>.
454              
455             =item body_size_limit
456              
457             Set this to the max body size in bytes. Messages with body bigger than it will
458             be skipped. Absence or 0 means to not limit body size.
459              
460             E.g. to skip messages with body bigger than C<1MiB>, you can set it to
461             C<1048576>.
462              
463             The difference between C and C is the former
464             limits the size of the whole message, while the latter parses messages, skips
465             attachments and only checks text/plain and text/html parts.
466              
467             =item command
468              
469             Set this to an executable command. You can specify an absolute path,
470             or a plain command name which will be found from C<$PATH>. For each
471             email wsgetmail retrieves, it will run this command and pass the
472             message data to it via standard input.
473              
474             =item command_args
475              
476             Set this to a string with additional arguments to pass to C.
477             These arguments follow shell quoting rules: you can escape characters
478             with a backslash, and denote a single string argument with single or
479             double quotes.
480              
481             =item command_timeout
482              
483             Set this to the number of seconds the C has to return before
484             timeout is reached. The default value is 30. Use "inf" for no timeout.
485              
486             =item action_on_fetched
487              
488             Set this to a literal string C<"mark_as_read"> or C<"delete">.
489             For each email wsgetmail retrieves, after the configured delivery
490             command succeeds, it will take this action on the message.
491              
492             If you set this to C<"mark_as_read">, wsgetmail will only retrieve and
493             deliver messages that are marked unread in the configured folder, so it does
494             not try to deliver the same email multiple times.
495              
496             =item dump_messages
497              
498             Set this to 1 to preserve the temporary files after processing.
499              
500             When C<"debug"> is also set the filenames will be reported on STDERR.
501              
502             =item debug
503              
504             Set this to enable additional diagnostic and status messages.
505              
506             =item quiet
507              
508             Set this to put wsgetmail into C mode. This mode intended for use in cron
509             or other automation.
510              
511             When in C mode wsgetmail produces no output unless there is an error, in
512             which case the configuration filename and error messages will be printed to
513             STDERR.
514              
515             =back
516              
517             =head1 TESTING AND DEPLOYMENT
518              
519             After you write your wsgetmail configuration file, you can test it by running:
520              
521             wsgetmail --debug --dry-run --config=wsgetmail.json
522              
523             This will read and deliver messages, but will not mark them as read or
524             delete them. If there are any problems, those will be reported in the error
525             output. You can update your configuration file and try again until wsgetmail
526             runs successfully.
527              
528             Once your configuration is stable, you can configure wsgetmail to run
529             periodically through cron or a systemd service on a timer.
530              
531             =head1 ERRORS AND DIAGNOSTIC MESSAGES
532              
533             wsgetmail sends warning, error, and debug messages to STDERR, while purely
534             informational messages are sent to STDOUT. Operators may want to capture both
535             output streams as a merged stream for diagnostic purposes. For example:
536              
537             wsgetmail --debug --dry-run --config=wsgetmail.json > wsgetmail.debug 2>&1
538              
539             When the mail processing command exits with an error (non-zero) status the
540             action_on_fetched is not performed on that message so that it will be processed
541             on the next run.
542              
543             Full output of the processing command is produced with C<--debug>.
544              
545             =head1 LIMITATIONS
546              
547             =head2 Fetching from Multiple Folders
548              
549             wsgetmail can only read from a single folder each time it runs. If you need
550             to read multiple folders (possibly spanning different accounts), then you
551             need to run it multiple times with different configuration.
552              
553             If you only need to change a couple small configuration settings like the
554             folder name, you can use the C<--options> argument to override those from a
555             base configuration file. For example:
556              
557             wsgetmail --config=wsgetmail.json --options='{"folder": "Inbox"}'
558             wsgetmail --config=wsgetmail.json --options='{"folder": "Other Folder"}'
559              
560             NOTE: Setting C or C with C<--options> is not secure
561             and may expose your credentials to other users on the local system. If you
562             need to set these options, or just change a lot of settings in your
563             configuration, just run wsgetmail with different configurations:
564              
565             wsgetmail --config=account01.json
566             wsgetmail --config=account02.json
567              
568             =head2 Office 365 API Limits
569              
570             Microsoft applies some limits to the amount of API requests allowed as
571             documented in their L.
572             If you reach a limit, requests to the API will start failing with HTTP
573             status code 429 for a period of time.
574              
575             =head2 Office 365 API Errors
576              
577             Best Practical Solutions has observed that very rarely the Microsoft Graph API
578             will return a 5xx error code when attempting to list mail folder details,
579             retrieve the message list, fetch message details, or delete a message. The
580             cause for this is currently unknown, except that it is not related to the API
581             limits mentioned above.
582              
583             Despite the relative rarity, if you run wsgetmail often enough you can see this
584             multiple times in a typical day.
585              
586             Therefore, by default wsgetmail now ignores C<5xx> response codes on all API
587             calls, instead treating them as logically empty success response.
588              
589             =head1 SEE ALSO
590              
591             =over 4
592              
593             =item * L
594              
595             =item * L
596              
597             =item * L
598              
599             =item * L
600              
601             =back
602              
603             =head1 AUTHOR
604              
605             Best Practical Solutions, LLC
606              
607             =head1 LICENSE AND COPYRIGHT
608              
609             This software is Copyright (c) 2015-2026 by Best Practical Solutions, LLC.
610              
611             This is free software, licensed under:
612              
613             The GNU General Public License, Version 2, June 1991
614              
615             =cut
616              
617             1;