1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
4# All rights reserved.
5#
6# This code is licensed under the MIT License.
7#
8# Permission is hereby granted, free of charge, to any person obtaining a copy
9# of this software and associated documentation files(the "Software"), to deal
10# in the Software without restriction, including without limitation the rights
11# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
12# copies of the Software, and to permit persons to whom the Software is
13# furnished to do so, subject to the following conditions :
14#
15# The above copyright notice and this permission notice shall be included in
16# all copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
24# THE SOFTWARE.
25
26# API Details:
27# https://docs.microsoft.com/en-us/previous-versions/office/\
28#        office-365-api/?redirectedfrom=MSDN
29
30# Information on sending an email:
31# https://docs.microsoft.com/en-us/graph/api/user-sendmail\
32#       ?view=graph-rest-1.0&tabs=http
33
34# Steps to get your Microsoft Client ID, Client Secret, and Tenant ID:
35# 1. You should have valid Microsoft personal account. Go to Azure Portal
36# 2. Go to -> Microsoft Active Directory --> App Registrations
37# 3. Click new -> give any name (your choice) in Name field -> select
38#     personal Microsoft accounts only --> Register
39# 4.  Now you have your client_id & Tenant id.
40# 5. To create client_secret , go to active directory ->
41#          Certificate & Tokens -> New client secret
42#               **This is auto-generated string which may have '@' and '?'
43#                 characters in it. You should encode these to prevent
44#                 from having any issues.**
45# 6. Now need to set permission Active directory -> API permissions ->
46#         Add permission (search mail) , add relevant permission.
47# 7. Set the redirect uri (Web) to:
48#        https://login.microsoftonline.com/common/oauth2/nativeclient
49#
50#     ...and click register.
51#
52#     This needs to be inserted into the "Redirect URI" text box as simply
53#     checking the check box next to this link seems to be insufficient.
54#     This is the default redirect uri used by this library, but you can use
55#     any other if you want.
56#
57# 8. Now you're good to go
58
59import requests
60from datetime import datetime
61from datetime import timedelta
62from json import loads
63from json import dumps
64from .NotifyBase import NotifyBase
65from ..URLBase import PrivacyMode
66from ..common import NotifyFormat
67from ..common import NotifyType
68from ..utils import is_email
69from ..utils import parse_emails
70from ..utils import validate_regex
71from ..AppriseLocale import gettext_lazy as _
72
73
74class NotifyOffice365(NotifyBase):
75    """
76    A wrapper for Office 365 Notifications
77    """
78
79    # The default descriptive name associated with the Notification
80    service_name = 'Office 365'
81
82    # The services URL
83    service_url = 'https://office.com/'
84
85    # The default protocol
86    secure_protocol = 'o365'
87
88    # Allow 300 requests per minute.
89    # 60/300 = 0.2
90    request_rate_per_sec = 0.20
91
92    # A URL that takes you to the setup/help of the specific protocol
93    setup_url = 'https://github.com/caronc/apprise/wiki/Notify_office365'
94
95    # URL to Microsoft Graph Server
96    graph_url = 'https://graph.microsoft.com'
97
98    # Authentication URL
99    auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token'
100
101    # Use all the direct application permissions you have configured for your
102    # app. The endpoint should issue a token for the ones associated with the
103    # resource you want to use.
104    # see https://docs.microsoft.com/en-us/azure/active-directory/develop/\
105    #       v2-permissions-and-consent#the-default-scope
106    scope = '.default'
107
108    # Default Notify Format
109    notify_format = NotifyFormat.HTML
110
111    # Define object templates
112    templates = (
113        '{schema}://{tenant}:{email}/{client_id}/{secret}',
114        '{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}',
115    )
116
117    # Define our template tokens
118    template_tokens = dict(NotifyBase.template_tokens, **{
119        'tenant': {
120            'name': _('Tenant Domain'),
121            'type': 'string',
122            'required': True,
123            'private': True,
124            'regex': (r'^[a-z0-9-]+$', 'i'),
125        },
126        'email': {
127            'name': _('Account Email'),
128            'type': 'string',
129            'required': True,
130        },
131        'client_id': {
132            'name': _('Client ID'),
133            'type': 'string',
134            'required': True,
135            'private': True,
136            'regex': (r'^[a-z0-9-]+$', 'i'),
137        },
138        'secret': {
139            'name': _('Client Secret'),
140            'type': 'string',
141            'private': True,
142            'required': True,
143        },
144        'targets': {
145            'name': _('Target Emails'),
146            'type': 'list:string',
147        },
148    })
149
150    # Define our template arguments
151    template_args = dict(NotifyBase.template_args, **{
152        'to': {
153            'alias_of': 'targets',
154        },
155        'cc': {
156            'name': _('Carbon Copy'),
157            'type': 'list:string',
158        },
159        'bcc': {
160            'name': _('Blind Carbon Copy'),
161            'type': 'list:string',
162        },
163        'oauth_id': {
164            'alias_of': 'client_id',
165        },
166        'oauth_secret': {
167            'alias_of': 'secret',
168        },
169    })
170
171    def __init__(self, tenant, email, client_id, secret,
172                 targets=None, cc=None, bcc=None, **kwargs):
173        """
174        Initialize Office 365 Object
175        """
176        super(NotifyOffice365, self).__init__(**kwargs)
177
178        # Tenant identifier
179        self.tenant = validate_regex(
180            tenant, *self.template_tokens['tenant']['regex'])
181        if not self.tenant:
182            msg = 'An invalid Office 365 Tenant' \
183                  '({}) was specified.'.format(tenant)
184            self.logger.warning(msg)
185            raise TypeError(msg)
186
187        result = is_email(email)
188        if not result:
189            msg = 'An invalid Office 365 Email Account ID' \
190                  '({}) was specified.'.format(email)
191            self.logger.warning(msg)
192            raise TypeError(msg)
193
194        # Otherwise store our the email address
195        self.email = result['full_email']
196
197        # Client Key (associated with generated OAuth2 Login)
198        self.client_id = validate_regex(
199            client_id, *self.template_tokens['client_id']['regex'])
200        if not self.client_id:
201            msg = 'An invalid Office 365 Client OAuth2 ID ' \
202                  '({}) was specified.'.format(client_id)
203            self.logger.warning(msg)
204            raise TypeError(msg)
205
206        # Client Secret (associated with generated OAuth2 Login)
207        self.secret = validate_regex(secret)
208        if not self.secret:
209            msg = 'An invalid Office 365 Client OAuth2 Secret ' \
210                  '({}) was specified.'.format(secret)
211            self.logger.warning(msg)
212            raise TypeError(msg)
213
214        # For tracking our email -> name lookups
215        self.names = {}
216
217        # Acquire Carbon Copies
218        self.cc = set()
219
220        # Acquire Blind Carbon Copies
221        self.bcc = set()
222
223        # Parse our targets
224        self.targets = list()
225
226        if targets:
227            for recipient in parse_emails(targets):
228                # Validate recipients (to:) and drop bad ones:
229                result = is_email(recipient)
230                if result:
231                    # Add our email to our target list
232                    self.targets.append(
233                        (result['name'] if result['name'] else False,
234                            result['full_email']))
235                    continue
236
237                self.logger.warning(
238                    'Dropped invalid To email ({}) specified.'
239                    .format(recipient))
240
241        else:
242            # If our target email list is empty we want to add ourselves to it
243            self.targets.append((False, self.email))
244
245        # Validate recipients (cc:) and drop bad ones:
246        for recipient in parse_emails(cc):
247            email = is_email(recipient)
248            if email:
249                self.cc.add(email['full_email'])
250
251                # Index our name (if one exists)
252                self.names[email['full_email']] = \
253                    email['name'] if email['name'] else False
254                continue
255
256            self.logger.warning(
257                'Dropped invalid Carbon Copy email '
258                '({}) specified.'.format(recipient),
259            )
260
261        # Validate recipients (bcc:) and drop bad ones:
262        for recipient in parse_emails(bcc):
263            email = is_email(recipient)
264            if email:
265                self.bcc.add(email['full_email'])
266
267                # Index our name (if one exists)
268                self.names[email['full_email']] = \
269                    email['name'] if email['name'] else False
270                continue
271
272            self.logger.warning(
273                'Dropped invalid Blind Carbon Copy email '
274                '({}) specified.'.format(recipient),
275            )
276
277        # Our token is acquired upon a successful login
278        self.token = None
279
280        # Presume that our token has expired 'now'
281        self.token_expiry = datetime.now()
282
283        return
284
285    def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
286        """
287        Perform Office 365 Notification
288        """
289
290        # error tracking (used for function return)
291        has_error = False
292
293        if not self.targets:
294            # There is no one to email; we're done
295            self.logger.warning(
296                'There are no Email recipients to notify')
297            return False
298
299        # Setup our Content Type
300        content_type = \
301            'HTML' if self.notify_format == NotifyFormat.HTML else 'Text'
302
303        # Prepare our payload
304        payload = {
305            'Message': {
306                'Subject': title,
307                'Body': {
308                    'ContentType': content_type,
309                    'Content': body,
310                },
311            },
312            'SaveToSentItems': 'false'
313        }
314
315        # Create a copy of the email list
316        emails = list(self.targets)
317
318        # Define our URL to post to
319        url = '{graph_url}/v1.0/users/{email}/sendmail'.format(
320            email=self.email,
321            graph_url=self.graph_url,
322        )
323
324        while len(emails):
325            # authenticate ourselves if we aren't already; but this function
326            # also tracks if our token we have is still valid and will
327            # re-authenticate ourselves if nessisary.
328            if not self.authenticate():
329                # We could not authenticate ourselves; we're done
330                return False
331
332            # Get our email to notify
333            to_name, to_addr = emails.pop(0)
334
335            # Strip target out of cc list if in To or Bcc
336            cc = (self.cc - self.bcc - set([to_addr]))
337
338            # Strip target out of bcc list if in To
339            bcc = (self.bcc - set([to_addr]))
340
341            # Prepare our email
342            payload['Message']['ToRecipients'] = [{
343                'EmailAddress': {
344                    'Address': to_addr
345                }
346            }]
347            if to_name:
348                # Apply our To Name
349                payload['Message']['ToRecipients'][0]['EmailAddress']['Name'] \
350                    = to_name
351
352            self.logger.debug('Email To: {}'.format(to_addr))
353
354            if cc:
355                # Prepare our CC list
356                payload['Message']['CcRecipients'] = []
357                for addr in cc:
358                    _payload = {'Address': addr}
359                    if self.names.get(addr):
360                        _payload['Name'] = self.names[addr]
361
362                    # Store our address in our payload
363                    payload['Message']['CcRecipients']\
364                        .append({'EmailAddress': _payload})
365
366                self.logger.debug('Email Cc: {}'.format(', '.join(
367                    ['{}{}'.format(
368                        '' if self.names.get(e)
369                        else '{}: '.format(self.names[e]), e) for e in cc])))
370
371            if bcc:
372                # Prepare our CC list
373                payload['Message']['BccRecipients'] = []
374                for addr in bcc:
375                    _payload = {'Address': addr}
376                    if self.names.get(addr):
377                        _payload['Name'] = self.names[addr]
378
379                    # Store our address in our payload
380                    payload['Message']['BccRecipients']\
381                        .append({'EmailAddress': _payload})
382
383                self.logger.debug('Email Bcc: {}'.format(', '.join(
384                    ['{}{}'.format(
385                        '' if self.names.get(e)
386                        else '{}: '.format(self.names[e]), e) for e in bcc])))
387
388            # Perform upstream fetch
389            postokay, response = self._fetch(
390                url=url, payload=dumps(payload),
391                content_type='application/json')
392
393            # Test if we were okay
394            if not postokay:
395                has_error = True
396
397        return not has_error
398
399    def authenticate(self):
400        """
401        Logs into and acquires us an authentication token to work with
402        """
403
404        if self.token and self.token_expiry > datetime.now():
405            # If we're already authenticated and our token is still valid
406            self.logger.debug(
407                'Already authenticate with token {}'.format(self.token))
408            return True
409
410        # If we reach here, we've either expired, or we need to authenticate
411        # for the first time.
412
413        # Prepare our payload
414        payload = {
415            'client_id': self.client_id,
416            'client_secret': self.secret,
417            'scope': '{graph_url}/{scope}'.format(
418                graph_url=self.graph_url,
419                scope=self.scope),
420            'grant_type': 'client_credentials',
421        }
422
423        # Prepare our URL
424        url = self.auth_url.format(tenant=self.tenant)
425
426        # A response looks like the following:
427        #    {
428        #       "token_type": "Bearer",
429        #       "expires_in": 3599,
430        #       "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP..."
431        #    }
432        #
433        # Where expires_in defines the number of seconds the key is valid for
434        # before it must be renewed.
435
436        # Alternatively, this could happen too...
437        #    {
438        #      "error": "invalid_scope",
439        #      "error_description": "AADSTS70011: Blah... Blah Blah... Blah",
440        #      "error_codes": [
441        #        70011
442        #      ],
443        #      "timestamp": "2020-01-09 02:02:12Z",
444        #      "trace_id": "255d1aef-8c98-452f-ac51-23d051240864",
445        #      "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7"
446        #    }
447
448        postokay, response = self._fetch(url=url, payload=payload)
449        if not postokay:
450            return False
451
452        # Reset our token
453        self.token = None
454
455        try:
456            # Extract our time from our response and subtrace 10 seconds from
457            # it to give us some wiggle/grace people to re-authenticate if we
458            # need to
459            self.token_expiry = datetime.now() + \
460                timedelta(seconds=int(response.get('expires_in')) - 10)
461
462        except (ValueError, AttributeError, TypeError):
463            # ValueError: expires_in wasn't an integer
464            # TypeError: expires_in was None
465            # AttributeError: we could not extract anything from our response
466            #                object.
467            return False
468
469        # Go ahead and store our token if it's available
470        self.token = response.get('access_token')
471
472        # We're authenticated
473        return True if self.token else False
474
475    def _fetch(self, url, payload,
476               content_type='application/x-www-form-urlencoded'):
477        """
478        Wrapper to request object
479
480        """
481
482        # Prepare our headers:
483        headers = {
484            'User-Agent': self.app_id,
485            'Content-Type': content_type,
486        }
487
488        if self.token:
489            # Are we authenticated?
490            headers['Authorization'] = 'Bearer ' + self.token
491
492        # Default content response object
493        content = {}
494
495        # Some Debug Logging
496        self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format(
497            url, self.verify_certificate))
498        self.logger.debug('Office 365 Payload: {}' .format(payload))
499
500        # Always call throttle before any remote server i/o is made
501        self.throttle()
502
503        # fetch function
504        try:
505            r = requests.post(
506                url,
507                data=payload,
508                headers=headers,
509                verify=self.verify_certificate,
510                timeout=self.request_timeout,
511            )
512
513            if r.status_code not in (
514                    requests.codes.ok, requests.codes.accepted):
515
516                # We had a problem
517                status_str = \
518                    NotifyOffice365.http_response_code_lookup(r.status_code)
519
520                self.logger.warning(
521                    'Failed to send Office 365 POST to {}: '
522                    '{}error={}.'.format(
523                        url,
524                        ', ' if status_str else '',
525                        r.status_code))
526
527                self.logger.debug(
528                    'Response Details:\r\n{}'.format(r.content))
529
530                # Mark our failure
531                return (False, content)
532
533            try:
534                content = loads(r.content)
535
536            except (AttributeError, TypeError, ValueError):
537                # ValueError = r.content is Unparsable
538                # TypeError = r.content is None
539                # AttributeError = r is None
540                content = {}
541
542        except requests.RequestException as e:
543            self.logger.warning(
544                'Exception received when sending Office 365 POST to {}: '.
545                format(url))
546            self.logger.debug('Socket Exception: %s' % str(e))
547
548            # Mark our failure
549            return (False, content)
550
551        return (True, content)
552
553    def url(self, privacy=False, *args, **kwargs):
554        """
555        Returns the URL built dynamically based on specified arguments.
556        """
557
558        # Our URL parameters
559        params = self.url_parameters(privacy=privacy, *args, **kwargs)
560
561        if self.cc:
562            # Handle our Carbon Copy Addresses
563            params['cc'] = ','.join(
564                ['{}{}'.format(
565                    '' if not self.names.get(e)
566                    else '{}:'.format(self.names[e]), e) for e in self.cc])
567
568        if self.bcc:
569            # Handle our Blind Carbon Copy Addresses
570            params['bcc'] = ','.join(
571                ['{}{}'.format(
572                    '' if not self.names.get(e)
573                    else '{}:'.format(self.names[e]), e) for e in self.bcc])
574
575        return '{schema}://{tenant}:{email}/{client_id}/{secret}' \
576            '/{targets}/?{params}'.format(
577                schema=self.secure_protocol,
578                tenant=self.pprint(self.tenant, privacy, safe=''),
579                # email does not need to be escaped because it should
580                # already be a valid host and username at this point
581                email=self.email,
582                client_id=self.pprint(self.client_id, privacy, safe=''),
583                secret=self.pprint(
584                    self.secret, privacy, mode=PrivacyMode.Secret,
585                    safe=''),
586                targets='/'.join(
587                    [NotifyOffice365.quote('{}{}'.format(
588                        '' if not e[0] else '{}:'.format(e[0]), e[1]),
589                        safe='') for e in self.targets]),
590                params=NotifyOffice365.urlencode(params))
591
592    @staticmethod
593    def parse_url(url):
594        """
595        Parses the URL and returns enough arguments that can allow
596        us to re-instantiate this object.
597
598        """
599
600        results = NotifyBase.parse_url(url, verify_host=False)
601        if not results:
602            # We're done early as we couldn't load the results
603            return results
604
605        # Now make a list of all our path entries
606        # We need to read each entry back one at a time in reverse order
607        # where each email found we mark as a target. Once we run out
608        # of targets, the presume the remainder of the entries are part
609        # of the secret key (since it can contain slashes in it)
610        entries = NotifyOffice365.split_path(results['fullpath'])
611
612        try:
613            # Get our client_id is the first entry on the path
614            results['client_id'] = NotifyOffice365.unquote(entries.pop(0))
615
616        except IndexError:
617            # no problem, we may get the client_id another way through
618            # arguments...
619            pass
620
621        # Prepare our target listing
622        results['targets'] = list()
623        while entries:
624            # Pop the last entry
625            entry = NotifyOffice365.unquote(entries.pop(-1))
626
627            if is_email(entry):
628                # Store our email and move on
629                results['targets'].append(entry)
630                continue
631
632            # If we reach here, the entry we just popped is part of the secret
633            # key, so put it back
634            entries.append(NotifyOffice365.quote(entry, safe=''))
635
636            # We're done
637            break
638
639        # Initialize our tenant
640        results['tenant'] = None
641
642        # Assemble our secret key which is a combination of the host followed
643        # by all entries in the full path that follow up until the first email
644        results['secret'] = '/'.join(
645            [NotifyOffice365.unquote(x) for x in entries])
646
647        # Assemble our client id from the user@hostname
648        if results['password']:
649            results['email'] = '{}@{}'.format(
650                NotifyOffice365.unquote(results['password']),
651                NotifyOffice365.unquote(results['host']),
652            )
653            # Update our tenant
654            results['tenant'] = NotifyOffice365.unquote(results['user'])
655
656        else:
657            # No tenant specified..
658            results['email'] = '{}@{}'.format(
659                NotifyOffice365.unquote(results['user']),
660                NotifyOffice365.unquote(results['host']),
661            )
662
663        # OAuth2 ID
664        if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']):
665            # Extract the API Key from an argument
666            results['client_id'] = \
667                NotifyOffice365.unquote(results['qsd']['oauth_id'])
668
669        # OAuth2 Secret
670        if 'oauth_secret' in results['qsd'] and \
671                len(results['qsd']['oauth_secret']):
672            # Extract the API Secret from an argument
673            results['secret'] = \
674                NotifyOffice365.unquote(results['qsd']['oauth_secret'])
675
676        # Tenant
677        if 'from' in results['qsd'] and \
678                len(results['qsd']['from']):
679            # Extract the sending account's information
680            results['email'] = \
681                NotifyOffice365.unquote(results['qsd']['from'])
682
683        # Tenant
684        if 'tenant' in results['qsd'] and \
685                len(results['qsd']['tenant']):
686            # Extract the Tenant from the argument
687            results['tenant'] = \
688                NotifyOffice365.unquote(results['qsd']['tenant'])
689
690        # Support the 'to' variable so that we can support targets this way too
691        # The 'to' makes it easier to use yaml configuration
692        if 'to' in results['qsd'] and len(results['qsd']['to']):
693            results['targets'] += \
694                NotifyOffice365.parse_list(results['qsd']['to'])
695
696        # Handle Carbon Copy Addresses
697        if 'cc' in results['qsd'] and len(results['qsd']['cc']):
698            results['cc'] = results['qsd']['cc']
699
700        # Handle Blind Carbon Copy Addresses
701        if 'bcc' in results['qsd'] and len(results['qsd']['bcc']):
702            results['bcc'] = results['qsd']['bcc']
703
704        return results
705