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