mirror of https://github.com/caronc/apprise
Refactored Office 365 Plugin (#1225)
parent
e9020e6f74
commit
4d21759f60
|
@ -269,25 +269,26 @@ class AttachBase(URLBase):
|
||||||
cache = self.template_args['cache']['default'] \
|
cache = self.template_args['cache']['default'] \
|
||||||
if self.cache is None else self.cache
|
if self.cache is None else self.cache
|
||||||
|
|
||||||
if self.download_path and os.path.isfile(self.download_path) \
|
try:
|
||||||
and cache:
|
if self.download_path and os.path.isfile(self.download_path) \
|
||||||
|
and cache:
|
||||||
|
|
||||||
# We have enough reason to look further into our cached content
|
# We have enough reason to look further into our cached content
|
||||||
# and verify it has not expired.
|
# and verify it has not expired.
|
||||||
if cache is True:
|
if cache is True:
|
||||||
# return our fixed content as is; we will always cache it
|
# return our fixed content as is; we will always cache it
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Verify our cache time to determine whether we will get our
|
# Verify our cache time to determine whether we will get our
|
||||||
# content again.
|
# content again.
|
||||||
try:
|
age_in_sec = \
|
||||||
age_in_sec = time.time() - os.stat(self.download_path).st_mtime
|
time.time() - os.stat(self.download_path).st_mtime
|
||||||
if age_in_sec <= cache:
|
if age_in_sec <= cache:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except (OSError, IOError):
|
except (OSError, IOError):
|
||||||
# The file is not present
|
# The file is not present
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False if not retrieve_if_missing else self.download()
|
return False if not retrieve_if_missing else self.download()
|
||||||
|
|
||||||
|
@ -359,12 +360,27 @@ class AttachBase(URLBase):
|
||||||
|
|
||||||
def open(self, mode='rb'):
|
def open(self, mode='rb'):
|
||||||
"""
|
"""
|
||||||
return our file pointer and track it (we'll auto close later
|
return our file pointer and track it (we'll auto close later)
|
||||||
"""
|
"""
|
||||||
pointer = open(self.path, mode=mode)
|
pointer = open(self.path, mode=mode)
|
||||||
self.__pointers.add(pointer)
|
self.__pointers.add(pointer)
|
||||||
return pointer
|
return pointer
|
||||||
|
|
||||||
|
def chunk(self, size=5242880):
|
||||||
|
"""
|
||||||
|
A Generator that yield chunks of a file with the specified size.
|
||||||
|
|
||||||
|
By default the chunk size is set to 5MB (5242880 bytes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
with self.open() as file:
|
||||||
|
while True:
|
||||||
|
chunk = file.read(size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
|
||||||
|
yield chunk
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""
|
"""
|
||||||
support with keyword
|
support with keyword
|
||||||
|
@ -431,7 +447,15 @@ class AttachBase(URLBase):
|
||||||
Returns the filesize of the attachment.
|
Returns the filesize of the attachment.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return os.path.getsize(self.path) if self.path else 0
|
if not self:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
return os.path.getsize(self.path) if self.path else 0
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
# OSError can occur if the file is inaccessible
|
||||||
|
return 0
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -101,7 +101,11 @@ class AttachFile(AttachBase):
|
||||||
# Ensure any existing content set has been invalidated
|
# Ensure any existing content set has been invalidated
|
||||||
self.invalidate()
|
self.invalidate()
|
||||||
|
|
||||||
if not os.path.isfile(self.dirty_path):
|
try:
|
||||||
|
if not os.path.isfile(self.dirty_path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.max_file_size > 0 and \
|
if self.max_file_size > 0 and \
|
||||||
|
|
|
@ -33,38 +33,19 @@
|
||||||
# Information on sending an email:
|
# Information on sending an email:
|
||||||
# https://docs.microsoft.com/en-us/graph/api/user-sendmail\
|
# https://docs.microsoft.com/en-us/graph/api/user-sendmail\
|
||||||
# ?view=graph-rest-1.0&tabs=http
|
# ?view=graph-rest-1.0&tabs=http
|
||||||
|
|
||||||
# Steps to get your Microsoft Client ID, Client Secret, and Tenant ID:
|
|
||||||
# 1. You should have valid Microsoft personal account. Go to Azure Portal
|
|
||||||
# 2. Go to -> Microsoft Active Directory --> App Registrations
|
|
||||||
# 3. Click new -> give any name (your choice) in Name field -> select
|
|
||||||
# personal Microsoft accounts only --> Register
|
|
||||||
# 4. Now you have your client_id & Tenant id.
|
|
||||||
# 5. To create client_secret , go to active directory ->
|
|
||||||
# Certificate & Tokens -> New client secret
|
|
||||||
# **This is auto-generated string which may have '@' and '?'
|
|
||||||
# characters in it. You should encode these to prevent
|
|
||||||
# from having any issues.**
|
|
||||||
# 6. Now need to set permission Active directory -> API permissions ->
|
|
||||||
# Add permission (search mail) , add relevant permission.
|
|
||||||
# 7. Set the redirect uri (Web) to:
|
|
||||||
# https://login.microsoftonline.com/common/oauth2/nativeclient
|
|
||||||
#
|
#
|
||||||
# ...and click register.
|
# Note: One must set up Application Permissions (not Delegated Permissions)
|
||||||
|
# - Scopes required: Mail.Send
|
||||||
|
# - For Large Attachments: Mail.ReadWrite
|
||||||
|
# - For Email Lookups: User.Read.All
|
||||||
#
|
#
|
||||||
# This needs to be inserted into the "Redirect URI" text box as simply
|
|
||||||
# checking the check box next to this link seems to be insufficient.
|
|
||||||
# This is the default redirect uri used by this library, but you can use
|
|
||||||
# any other if you want.
|
|
||||||
#
|
|
||||||
# 8. Now you're good to go
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import json
|
||||||
|
from uuid import uuid4
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from json import loads
|
|
||||||
from json import dumps
|
|
||||||
from .base import NotifyBase
|
from .base import NotifyBase
|
||||||
|
from .. import exception
|
||||||
from ..url import PrivacyMode
|
from ..url import PrivacyMode
|
||||||
from ..common import NotifyFormat
|
from ..common import NotifyFormat
|
||||||
from ..common import NotifyType
|
from ..common import NotifyType
|
||||||
|
@ -72,6 +53,7 @@ from ..utils import is_email
|
||||||
from ..utils import parse_emails
|
from ..utils import parse_emails
|
||||||
from ..utils import validate_regex
|
from ..utils import validate_regex
|
||||||
from ..locale import gettext_lazy as _
|
from ..locale import gettext_lazy as _
|
||||||
|
from ..common import PersistentStoreMode
|
||||||
|
|
||||||
|
|
||||||
class NotifyOffice365(NotifyBase):
|
class NotifyOffice365(NotifyBase):
|
||||||
|
@ -86,7 +68,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
service_url = 'https://office.com/'
|
service_url = 'https://office.com/'
|
||||||
|
|
||||||
# The default protocol
|
# The default protocol
|
||||||
secure_protocol = 'o365'
|
secure_protocol = ('azure', 'o365')
|
||||||
|
|
||||||
# Allow 300 requests per minute.
|
# Allow 300 requests per minute.
|
||||||
# 60/300 = 0.2
|
# 60/300 = 0.2
|
||||||
|
@ -101,6 +83,20 @@ class NotifyOffice365(NotifyBase):
|
||||||
# Authentication URL
|
# Authentication URL
|
||||||
auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token'
|
auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token'
|
||||||
|
|
||||||
|
# Support attachments
|
||||||
|
attachment_support = True
|
||||||
|
|
||||||
|
# Our default is to no not use persistent storage beyond in-memory
|
||||||
|
# reference
|
||||||
|
storage_mode = PersistentStoreMode.AUTO
|
||||||
|
|
||||||
|
# the maximum size an attachment can be for it to be allowed to be
|
||||||
|
# uploaded inline with the current email going out (one http post)
|
||||||
|
# Anything larger than this and a second PUT request is required to
|
||||||
|
# the outlook server to post the content through reference.
|
||||||
|
# Currently (as of 2024.10.06) this was documented to be 3MB
|
||||||
|
outlook_attachment_inline_max = 3145728
|
||||||
|
|
||||||
# Use all the direct application permissions you have configured for your
|
# Use all the direct application permissions you have configured for your
|
||||||
# app. The endpoint should issue a token for the ones associated with the
|
# app. The endpoint should issue a token for the ones associated with the
|
||||||
# resource you want to use.
|
# resource you want to use.
|
||||||
|
@ -113,8 +109,9 @@ class NotifyOffice365(NotifyBase):
|
||||||
|
|
||||||
# Define object templates
|
# Define object templates
|
||||||
templates = (
|
templates = (
|
||||||
'{schema}://{tenant}:{email}/{client_id}/{secret}',
|
# Send as user (only supported method)
|
||||||
'{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}',
|
'{schema}://{source}/{tenant}/{client_id}/{secret}',
|
||||||
|
'{schema}://{source}/{tenant}/{client_id}/{secret}/{targets}',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define our template tokens
|
# Define our template tokens
|
||||||
|
@ -126,8 +123,8 @@ class NotifyOffice365(NotifyBase):
|
||||||
'private': True,
|
'private': True,
|
||||||
'regex': (r'^[a-z0-9-]+$', 'i'),
|
'regex': (r'^[a-z0-9-]+$', 'i'),
|
||||||
},
|
},
|
||||||
'email': {
|
'source': {
|
||||||
'name': _('Account Email'),
|
'name': _('Account Email or Object ID'),
|
||||||
'type': 'string',
|
'type': 'string',
|
||||||
'required': True,
|
'required': True,
|
||||||
},
|
},
|
||||||
|
@ -176,7 +173,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, tenant, email, client_id, secret,
|
def __init__(self, tenant, client_id, secret, source=None,
|
||||||
targets=None, cc=None, bcc=None, **kwargs):
|
targets=None, cc=None, bcc=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize Office 365 Object
|
Initialize Office 365 Object
|
||||||
|
@ -192,15 +189,8 @@ class NotifyOffice365(NotifyBase):
|
||||||
self.logger.warning(msg)
|
self.logger.warning(msg)
|
||||||
raise TypeError(msg)
|
raise TypeError(msg)
|
||||||
|
|
||||||
result = is_email(email)
|
# Store our email/ObjectID Source
|
||||||
if not result:
|
self.source = source
|
||||||
msg = 'An invalid Office 365 Email Account ID' \
|
|
||||||
'({}) was specified.'.format(email)
|
|
||||||
self.logger.warning(msg)
|
|
||||||
raise TypeError(msg)
|
|
||||||
|
|
||||||
# Otherwise store our the email address
|
|
||||||
self.email = result['full_email']
|
|
||||||
|
|
||||||
# Client Key (associated with generated OAuth2 Login)
|
# Client Key (associated with generated OAuth2 Login)
|
||||||
self.client_id = validate_regex(
|
self.client_id = validate_regex(
|
||||||
|
@ -247,8 +237,14 @@ class NotifyOffice365(NotifyBase):
|
||||||
.format(recipient))
|
.format(recipient))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# If our target email list is empty we want to add ourselves to it
|
result = is_email(self.source)
|
||||||
self.targets.append((False, self.email))
|
if not result:
|
||||||
|
self.logger.warning('No Target Office 365 Email Detected')
|
||||||
|
|
||||||
|
else:
|
||||||
|
# If our target email list is empty we want to add ourselves to
|
||||||
|
# it
|
||||||
|
self.targets.append((False, self.source))
|
||||||
|
|
||||||
# Validate recipients (cc:) and drop bad ones:
|
# Validate recipients (cc:) and drop bad ones:
|
||||||
for recipient in parse_emails(cc):
|
for recipient in parse_emails(cc):
|
||||||
|
@ -288,9 +284,23 @@ class NotifyOffice365(NotifyBase):
|
||||||
# Presume that our token has expired 'now'
|
# Presume that our token has expired 'now'
|
||||||
self.token_expiry = datetime.now()
|
self.token_expiry = datetime.now()
|
||||||
|
|
||||||
|
# Our email source; we detect this if the source is an ObjectID
|
||||||
|
# If it is unknown we set this to None
|
||||||
|
# User is the email associated with the account
|
||||||
|
self.from_email = self.store.get('from')
|
||||||
|
result = is_email(self.source)
|
||||||
|
if result:
|
||||||
|
self.from_email = result['full_email']
|
||||||
|
self.from_name = \
|
||||||
|
result['name'] or self.store.get('name')
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.from_name = self.store.get('name')
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Perform Office 365 Notification
|
Perform Office 365 Notification
|
||||||
"""
|
"""
|
||||||
|
@ -304,31 +314,143 @@ class NotifyOffice365(NotifyBase):
|
||||||
'There are no Email recipients to notify')
|
'There are no Email recipients to notify')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if self.from_email is None:
|
||||||
|
if not self.authenticate():
|
||||||
|
# We could not authenticate ourselves; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Acquire our from_email
|
||||||
|
url = "https://graph.microsoft.com/v1.0/users/{}".format(
|
||||||
|
self.source)
|
||||||
|
postokay, response = self._fetch(url=url, method='GET')
|
||||||
|
if not postokay:
|
||||||
|
self.logger.warning(
|
||||||
|
'Could not acquire From email address; ensure '
|
||||||
|
'"User.Read.All" Application scope is set!')
|
||||||
|
|
||||||
|
else: # Acquire our from_email (if possible)
|
||||||
|
from_email = \
|
||||||
|
response.get("mail") or response.get("userPrincipalName")
|
||||||
|
result = is_email(from_email)
|
||||||
|
if not result:
|
||||||
|
self.logger.warning(
|
||||||
|
'Could not get From email from the Azure endpoint.')
|
||||||
|
|
||||||
|
# Prevent re-occuring upstream fetches for info that isn't
|
||||||
|
# there
|
||||||
|
self.from_email = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Store our email for future reference
|
||||||
|
self.from_email = result['full_email']
|
||||||
|
self.store.set('from', result['full_email'])
|
||||||
|
|
||||||
|
self.from_name = response.get("displayName")
|
||||||
|
if self.from_name:
|
||||||
|
self.store.set('name', self.from_name)
|
||||||
|
|
||||||
# Setup our Content Type
|
# Setup our Content Type
|
||||||
content_type = \
|
content_type = \
|
||||||
'HTML' if self.notify_format == NotifyFormat.HTML else 'Text'
|
'HTML' if self.notify_format == NotifyFormat.HTML else 'Text'
|
||||||
|
|
||||||
# Prepare our payload
|
# Prepare our payload
|
||||||
payload = {
|
payload = {
|
||||||
'Message': {
|
'message': {
|
||||||
'Subject': title,
|
'subject': title,
|
||||||
'Body': {
|
'body': {
|
||||||
'ContentType': content_type,
|
'contentType': content_type,
|
||||||
'Content': body,
|
'content': body,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'SaveToSentItems': 'false'
|
# Below takes a string (not bool) of either 'true' or 'false'
|
||||||
|
'saveToSentItems': 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.from_email:
|
||||||
|
# Apply from email if it is known
|
||||||
|
payload.update({
|
||||||
|
'message': {
|
||||||
|
'from': {
|
||||||
|
"emailAddress": {
|
||||||
|
"address": self.from_email,
|
||||||
|
"name": self.from_name or self.app_id,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
# Create a copy of the email list
|
# Create a copy of the email list
|
||||||
emails = list(self.targets)
|
emails = list(self.targets)
|
||||||
|
|
||||||
# Define our URL to post to
|
# Define our URL to post to
|
||||||
url = '{graph_url}/v1.0/users/{email}/sendmail'.format(
|
url = '{graph_url}/v1.0/users/{userid}/sendMail'.format(
|
||||||
email=self.email,
|
|
||||||
graph_url=self.graph_url,
|
graph_url=self.graph_url,
|
||||||
|
userid=self.source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Prepare our Draft URL
|
||||||
|
draft_url = \
|
||||||
|
'{graph_url}/v1.0/users/{userid}/messages' \
|
||||||
|
.format(
|
||||||
|
graph_url=self.graph_url,
|
||||||
|
userid=self.source,
|
||||||
|
)
|
||||||
|
|
||||||
|
small_attachments = []
|
||||||
|
large_attachments = []
|
||||||
|
|
||||||
|
# draft emails
|
||||||
|
drafts = []
|
||||||
|
|
||||||
|
if attach and self.attachment_support:
|
||||||
|
for no, attachment in enumerate(attach, start=1):
|
||||||
|
# Perform some simple error checking
|
||||||
|
if not attachment:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access Office 365 attachment {}.'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(attachment) > self.outlook_attachment_inline_max:
|
||||||
|
# Messages larger then xMB need to be uploaded after; a
|
||||||
|
# draft email must be prepared; below is our session
|
||||||
|
large_attachments.append({
|
||||||
|
'obj': attachment,
|
||||||
|
'name': attachment.name
|
||||||
|
if attachment.name else f'file{no:03}.dat',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prepare our Attachment in Base64
|
||||||
|
small_attachments.append({
|
||||||
|
"@odata.type": "#microsoft.graph.fileAttachment",
|
||||||
|
# Name of the attachment (as it should appear in email)
|
||||||
|
"name": attachment.name
|
||||||
|
if attachment.name else f'file{no:03}.dat',
|
||||||
|
# MIME type of the attachment
|
||||||
|
"contentType": "attachment.mimetype",
|
||||||
|
# Base64 Content
|
||||||
|
"contentBytes": attachment.base64(),
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
except exception.AppriseException:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access Office 365 attachment {}.'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'Appending Office 365 attachment {}'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
|
||||||
|
if small_attachments:
|
||||||
|
# Store Attachments
|
||||||
|
payload['message']['attachments'] = small_attachments
|
||||||
|
|
||||||
while len(emails):
|
while len(emails):
|
||||||
# authenticate ourselves if we aren't already; but this function
|
# authenticate ourselves if we aren't already; but this function
|
||||||
# also tracks if our token we have is still valid and will
|
# also tracks if our token we have is still valid and will
|
||||||
|
@ -347,63 +469,197 @@ class NotifyOffice365(NotifyBase):
|
||||||
bcc = (self.bcc - set([to_addr]))
|
bcc = (self.bcc - set([to_addr]))
|
||||||
|
|
||||||
# Prepare our email
|
# Prepare our email
|
||||||
payload['Message']['ToRecipients'] = [{
|
payload['message']['toRecipients'] = [{
|
||||||
'EmailAddress': {
|
'emailAddress': {
|
||||||
'Address': to_addr
|
'address': to_addr
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
if to_name:
|
if to_name:
|
||||||
# Apply our To Name
|
# Apply our To Name
|
||||||
payload['Message']['ToRecipients'][0]['EmailAddress']['Name'] \
|
payload['message']['toRecipients'][0]['emailAddress']['name'] \
|
||||||
= to_name
|
= to_name
|
||||||
|
|
||||||
self.logger.debug('Email To: {}'.format(to_addr))
|
self.logger.debug('{}Email To: {}'.format(
|
||||||
|
'Draft' if large_attachments else '', to_addr))
|
||||||
|
|
||||||
if cc:
|
if cc:
|
||||||
# Prepare our CC list
|
# Prepare our CC list
|
||||||
payload['Message']['CcRecipients'] = []
|
payload['message']['ccRecipients'] = []
|
||||||
for addr in cc:
|
for addr in cc:
|
||||||
_payload = {'Address': addr}
|
_payload = {'address': addr}
|
||||||
if self.names.get(addr):
|
if self.names.get(addr):
|
||||||
_payload['Name'] = self.names[addr]
|
_payload['name'] = self.names[addr]
|
||||||
|
|
||||||
# Store our address in our payload
|
# Store our address in our payload
|
||||||
payload['Message']['CcRecipients']\
|
payload['message']['ccRecipients']\
|
||||||
.append({'EmailAddress': _payload})
|
.append({'emailAddress': _payload})
|
||||||
|
|
||||||
self.logger.debug('Email Cc: {}'.format(', '.join(
|
self.logger.debug('{}Email Cc: {}'.format(
|
||||||
['{}{}'.format(
|
'Draft' if large_attachments else '', ', '.join(
|
||||||
'' if self.names.get(e)
|
['{}{}'.format(
|
||||||
else '{}: '.format(self.names[e]), e) for e in cc])))
|
'' if self.names.get(e)
|
||||||
|
else '{}: '.format(
|
||||||
|
self.names[e]), e) for e in cc])))
|
||||||
|
|
||||||
if bcc:
|
if bcc:
|
||||||
# Prepare our CC list
|
# Prepare our CC list
|
||||||
payload['Message']['BccRecipients'] = []
|
payload['message']['bccRecipients'] = []
|
||||||
for addr in bcc:
|
for addr in bcc:
|
||||||
_payload = {'Address': addr}
|
_payload = {'address': addr}
|
||||||
if self.names.get(addr):
|
if self.names.get(addr):
|
||||||
_payload['Name'] = self.names[addr]
|
_payload['name'] = self.names[addr]
|
||||||
|
|
||||||
# Store our address in our payload
|
# Store our address in our payload
|
||||||
payload['Message']['BccRecipients']\
|
payload['message']['bccRecipients']\
|
||||||
.append({'EmailAddress': _payload})
|
.append({'emailAddress': _payload})
|
||||||
|
|
||||||
self.logger.debug('Email Bcc: {}'.format(', '.join(
|
self.logger.debug('{}Email Bcc: {}'.format(
|
||||||
['{}{}'.format(
|
'Draft' if large_attachments else '', ', '.join(
|
||||||
'' if self.names.get(e)
|
['{}{}'.format(
|
||||||
else '{}: '.format(self.names[e]), e) for e in bcc])))
|
'' if self.names.get(e)
|
||||||
|
else '{}: '.format(
|
||||||
|
self.names[e]), e) for e in bcc])))
|
||||||
|
|
||||||
# Perform upstream fetch
|
# Perform upstream post
|
||||||
postokay, response = self._fetch(
|
postokay, response = self._fetch(
|
||||||
url=url, payload=dumps(payload),
|
url=url if not large_attachments
|
||||||
content_type='application/json')
|
else draft_url, payload=payload)
|
||||||
|
|
||||||
# Test if we were okay
|
# Test if we were okay
|
||||||
if not postokay:
|
if not postokay:
|
||||||
has_error = True
|
has_error = True
|
||||||
|
|
||||||
|
elif large_attachments:
|
||||||
|
# We have large attachments now to upload and associate with
|
||||||
|
# our message. We need to prepare a draft message; acquire
|
||||||
|
# the message-id associated with it and then attach the file
|
||||||
|
# via this means.
|
||||||
|
|
||||||
|
# Acquire our Draft ID to work with
|
||||||
|
message_id = response.get("id")
|
||||||
|
if not message_id:
|
||||||
|
self.logger.warning(
|
||||||
|
'Email Draft ID could not be retrieved')
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.debug('Email Draft ID: {}'.format(message_id))
|
||||||
|
# In future, the below could probably be called via async
|
||||||
|
has_attach_error = False
|
||||||
|
for attachment in large_attachments:
|
||||||
|
if not self.upload_attachment(
|
||||||
|
attachment['obj'], message_id, attachment['name']):
|
||||||
|
self.logger.warning(
|
||||||
|
'Could not prepare attachment session for %s',
|
||||||
|
attachment['name'])
|
||||||
|
|
||||||
|
has_error = True
|
||||||
|
has_attach_error = True
|
||||||
|
# Take early exit
|
||||||
|
break
|
||||||
|
|
||||||
|
if has_attach_error:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Send off our draft
|
||||||
|
attach_url = \
|
||||||
|
"https://graph.microsoft.com/v1.0/users/" \
|
||||||
|
"{}/messages/{}/send"
|
||||||
|
|
||||||
|
attach_url = attach_url.format(
|
||||||
|
self.source,
|
||||||
|
message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger our send
|
||||||
|
postokay, response = self._fetch(url=url)
|
||||||
|
if not postokay:
|
||||||
|
self.logger.warning(
|
||||||
|
'Could not send drafted email id: {} ', message_id)
|
||||||
|
has_error = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Memory management
|
||||||
|
del small_attachments
|
||||||
|
del large_attachments
|
||||||
|
del drafts
|
||||||
|
|
||||||
return not has_error
|
return not has_error
|
||||||
|
|
||||||
|
def upload_attachment(self, attachment, message_id, name=None):
|
||||||
|
"""
|
||||||
|
Uploads an attachment to a session
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Perform some simple error checking
|
||||||
|
if not attachment:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access Office 365 attachment {}.'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Our Session URL
|
||||||
|
url = \
|
||||||
|
'{graph_url}/v1.0/users/{userid}/message/{message_id}' \
|
||||||
|
.format(
|
||||||
|
graph_url=self.graph_url,
|
||||||
|
userid=self.source,
|
||||||
|
message_id=message_id,
|
||||||
|
) + '/attachments/createUploadSession'
|
||||||
|
|
||||||
|
file_size = len(attachment)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"AttachmentItem": {
|
||||||
|
"attachmentType": "file",
|
||||||
|
"name": name if name else (
|
||||||
|
attachment.name
|
||||||
|
if attachment.name else '{}.dat'.format(str(uuid4()))),
|
||||||
|
# MIME type of the attachment
|
||||||
|
"contentType": attachment.mimetype,
|
||||||
|
"size": file_size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.authenticate():
|
||||||
|
# We could not authenticate ourselves; we're done
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get our Upload URL
|
||||||
|
postokay, response = self._fetch(url, payload)
|
||||||
|
if not postokay:
|
||||||
|
return False
|
||||||
|
|
||||||
|
upload_url = response.get('uploadUrl')
|
||||||
|
if not upload_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_byte = 0
|
||||||
|
postokay = False
|
||||||
|
response = None
|
||||||
|
|
||||||
|
for chunk in attachment.chunk():
|
||||||
|
end_byte = start_byte + len(chunk) - 1
|
||||||
|
|
||||||
|
# Define headers for this chunk
|
||||||
|
headers = {
|
||||||
|
'User-Agent': self.app_id,
|
||||||
|
'Content-Length': str(len(chunk)),
|
||||||
|
'Content-Range':
|
||||||
|
f'bytes {start_byte}-{end_byte}/{file_size}'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload the chunk
|
||||||
|
postokay, response = self._fetch(
|
||||||
|
upload_url, chunk, headers=headers, content_type=None,
|
||||||
|
method='PUT')
|
||||||
|
if not postokay:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Return our Upload URL
|
||||||
|
return postokay
|
||||||
|
|
||||||
def authenticate(self):
|
def authenticate(self):
|
||||||
"""
|
"""
|
||||||
Logs into and acquires us an authentication token to work with
|
Logs into and acquires us an authentication token to work with
|
||||||
|
@ -420,12 +676,12 @@ class NotifyOffice365(NotifyBase):
|
||||||
|
|
||||||
# Prepare our payload
|
# Prepare our payload
|
||||||
payload = {
|
payload = {
|
||||||
|
'grant_type': 'client_credentials',
|
||||||
'client_id': self.client_id,
|
'client_id': self.client_id,
|
||||||
'client_secret': self.secret,
|
'client_secret': self.secret,
|
||||||
'scope': '{graph_url}/{scope}'.format(
|
'scope': '{graph_url}/{scope}'.format(
|
||||||
graph_url=self.graph_url,
|
graph_url=self.graph_url,
|
||||||
scope=self.scope),
|
scope=self.scope),
|
||||||
'grant_type': 'client_credentials',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prepare our URL
|
# Prepare our URL
|
||||||
|
@ -453,7 +709,9 @@ class NotifyOffice365(NotifyBase):
|
||||||
# "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7"
|
# "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7"
|
||||||
# }
|
# }
|
||||||
|
|
||||||
postokay, response = self._fetch(url=url, payload=payload)
|
postokay, response = self._fetch(
|
||||||
|
url=url, payload=payload,
|
||||||
|
content_type='application/x-www-form-urlencoded')
|
||||||
if not postokay:
|
if not postokay:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -480,18 +738,19 @@ class NotifyOffice365(NotifyBase):
|
||||||
# We're authenticated
|
# We're authenticated
|
||||||
return True if self.token else False
|
return True if self.token else False
|
||||||
|
|
||||||
def _fetch(self, url, payload,
|
def _fetch(self, url, payload=None, headers=None,
|
||||||
content_type='application/x-www-form-urlencoded'):
|
content_type='application/json', method='POST'):
|
||||||
"""
|
"""
|
||||||
Wrapper to request object
|
Wrapper to request object
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Prepare our headers:
|
# Prepare our headers:
|
||||||
headers = {
|
if not headers:
|
||||||
'User-Agent': self.app_id,
|
headers = {
|
||||||
'Content-Type': content_type,
|
'User-Agent': self.app_id,
|
||||||
}
|
'Content-Type': content_type,
|
||||||
|
}
|
||||||
|
|
||||||
if self.token:
|
if self.token:
|
||||||
# Are we authenticated?
|
# Are we authenticated?
|
||||||
|
@ -501,36 +760,84 @@ class NotifyOffice365(NotifyBase):
|
||||||
content = {}
|
content = {}
|
||||||
|
|
||||||
# Some Debug Logging
|
# Some Debug Logging
|
||||||
self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format(
|
self.logger.debug('Office 365 %s URL: {} (cert_verify={})'.format(
|
||||||
url, self.verify_certificate))
|
url, self.verify_certificate), method)
|
||||||
self.logger.debug('Office 365 Payload: {}' .format(payload))
|
self.logger.debug('Office 365 Payload: {}' .format(payload))
|
||||||
|
|
||||||
# Always call throttle before any remote server i/o is made
|
# Always call throttle before any remote server i/o is made
|
||||||
self.throttle()
|
self.throttle()
|
||||||
|
|
||||||
# fetch function
|
# fetch function
|
||||||
|
req = requests.post if method == 'POST' else (
|
||||||
|
requests.put if method == 'PUT' else requests.get)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
r = req(
|
||||||
url,
|
url,
|
||||||
data=payload,
|
data=json.dumps(payload)
|
||||||
|
if content_type and content_type.endswith('/json')
|
||||||
|
else payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
verify=self.verify_certificate,
|
verify=self.verify_certificate,
|
||||||
timeout=self.request_timeout,
|
timeout=self.request_timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
if r.status_code not in (
|
if r.status_code not in (
|
||||||
requests.codes.ok, requests.codes.accepted):
|
requests.codes.ok, requests.codes.created,
|
||||||
|
requests.codes.accepted):
|
||||||
|
|
||||||
# We had a problem
|
# We had a problem
|
||||||
status_str = \
|
status_str = \
|
||||||
NotifyOffice365.http_response_code_lookup(r.status_code)
|
NotifyOffice365.http_response_code_lookup(r.status_code)
|
||||||
|
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'Failed to send Office 365 POST to {}: '
|
'Failed to send Office 365 %s to {}: '
|
||||||
'{}error={}.'.format(
|
'{}error={}.'.format(
|
||||||
url,
|
url,
|
||||||
', ' if status_str else '',
|
', ' if status_str else '',
|
||||||
r.status_code))
|
r.status_code), method)
|
||||||
|
|
||||||
|
# A Response could look like this if a Scope element was not
|
||||||
|
# found:
|
||||||
|
# {
|
||||||
|
# "error": {
|
||||||
|
# "code": "MissingClaimType",
|
||||||
|
# "message":"The token is missing the claim type \'oid\'.",
|
||||||
|
# "innerError": {
|
||||||
|
# "oAuthEventOperationId":" 7abe20-339f-4659-9381-38f52",
|
||||||
|
# "oAuthEventcV": "xsOSpAHSHVm3Tp4SNH5oIA.1.1",
|
||||||
|
# "errorUrl": "https://url",
|
||||||
|
# "requestId": "2328ea-ec9e-43a8-80f4-164c",
|
||||||
|
# "date":"2024-12-01T02:03:13"
|
||||||
|
# }}
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Error 403; the below is returned if he User.Read.All
|
||||||
|
# Application scope is not set and a lookup is
|
||||||
|
# attempted.
|
||||||
|
# {
|
||||||
|
# "error": {
|
||||||
|
# "code": "Authorization_RequestDenied",
|
||||||
|
# "message":
|
||||||
|
# "Insufficient privileges to complete the operation.",
|
||||||
|
# "innerError": {
|
||||||
|
# "date": "2024-12-06T00:15:57",
|
||||||
|
# "request-id":
|
||||||
|
# "48fdb3e7-2f1a-4f45-a5a0-99b8b851278b",
|
||||||
|
# "client-request-id": "48f-2f1a-4f45-a5a0-99b8"
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
|
# Another response type (error 415):
|
||||||
|
# {
|
||||||
|
# "error": {
|
||||||
|
# "code": "RequestBodyRead",
|
||||||
|
# "message": "A missing or empty content type header was \
|
||||||
|
# found when trying to read a message. The content \
|
||||||
|
# type header is required.",
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
'Response Details:\r\n{}'.format(r.content))
|
'Response Details:\r\n{}'.format(r.content))
|
||||||
|
@ -539,7 +846,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
return (False, content)
|
return (False, content)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = loads(r.content)
|
content = json.loads(r.content)
|
||||||
|
|
||||||
except (AttributeError, TypeError, ValueError):
|
except (AttributeError, TypeError, ValueError):
|
||||||
# ValueError = r.content is Unparsable
|
# ValueError = r.content is Unparsable
|
||||||
|
@ -549,8 +856,8 @@ class NotifyOffice365(NotifyBase):
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
'Exception received when sending Office 365 POST to {}: '.
|
'Exception received when sending Office 365 %s to {}: '.
|
||||||
format(url))
|
format(url), method)
|
||||||
self.logger.debug('Socket Exception: %s' % str(e))
|
self.logger.debug('Socket Exception: %s' % str(e))
|
||||||
|
|
||||||
# Mark our failure
|
# Mark our failure
|
||||||
|
@ -566,7 +873,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
here.
|
here.
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
self.secure_protocol, self.email, self.tenant, self.client_id,
|
self.secure_protocol[0], self.source, self.tenant, self.client_id,
|
||||||
self.secret,
|
self.secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -575,7 +882,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
Returns the URL built dynamically based on specified arguments.
|
Returns the URL built dynamically based on specified arguments.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Our URL parameters
|
# Extend our parameters
|
||||||
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
||||||
|
|
||||||
if self.cc:
|
if self.cc:
|
||||||
|
@ -592,13 +899,13 @@ class NotifyOffice365(NotifyBase):
|
||||||
'' if not self.names.get(e)
|
'' if not self.names.get(e)
|
||||||
else '{}:'.format(self.names[e]), e) for e in self.bcc])
|
else '{}:'.format(self.names[e]), e) for e in self.bcc])
|
||||||
|
|
||||||
return '{schema}://{tenant}:{email}/{client_id}/{secret}' \
|
return '{schema}://{source}/{tenant}/{client_id}/{secret}' \
|
||||||
'/{targets}/?{params}'.format(
|
'/{targets}/?{params}'.format(
|
||||||
schema=self.secure_protocol,
|
schema=self.secure_protocol[0],
|
||||||
tenant=self.pprint(self.tenant, privacy, safe=''),
|
tenant=self.pprint(self.tenant, privacy, safe=''),
|
||||||
# email does not need to be escaped because it should
|
# email does not need to be escaped because it should
|
||||||
# already be a valid host and username at this point
|
# already be a valid host and username at this point
|
||||||
email=self.email,
|
source=self.source,
|
||||||
client_id=self.pprint(self.client_id, privacy, safe=''),
|
client_id=self.pprint(self.client_id, privacy, safe=''),
|
||||||
secret=self.pprint(
|
secret=self.pprint(
|
||||||
self.secret, privacy, mode=PrivacyMode.Secret,
|
self.secret, privacy, mode=PrivacyMode.Secret,
|
||||||
|
@ -606,7 +913,7 @@ class NotifyOffice365(NotifyBase):
|
||||||
targets='/'.join(
|
targets='/'.join(
|
||||||
[NotifyOffice365.quote('{}{}'.format(
|
[NotifyOffice365.quote('{}{}'.format(
|
||||||
'' if not e[0] else '{}:'.format(e[0]), e[1]),
|
'' if not e[0] else '{}:'.format(e[0]), e[1]),
|
||||||
safe='') for e in self.targets]),
|
safe='@') for e in self.targets]),
|
||||||
params=NotifyOffice365.urlencode(params))
|
params=NotifyOffice365.urlencode(params))
|
||||||
|
|
||||||
def __len__(self):
|
def __len__(self):
|
||||||
|
@ -635,16 +942,52 @@ class NotifyOffice365(NotifyBase):
|
||||||
# of the secret key (since it can contain slashes in it)
|
# of the secret key (since it can contain slashes in it)
|
||||||
entries = NotifyOffice365.split_path(results['fullpath'])
|
entries = NotifyOffice365.split_path(results['fullpath'])
|
||||||
|
|
||||||
try:
|
# Initialize our tenant
|
||||||
|
results['tenant'] = None
|
||||||
|
|
||||||
|
# Initialize our email
|
||||||
|
results['email'] = None
|
||||||
|
|
||||||
|
# From Email
|
||||||
|
if 'from' in results['qsd'] and \
|
||||||
|
len(results['qsd']['from']):
|
||||||
|
# Extract the sending account's information
|
||||||
|
results['source'] = \
|
||||||
|
NotifyOffice365.unquote(results['qsd']['from'])
|
||||||
|
|
||||||
|
# If tenant is occupied, then the user defined makes up our source
|
||||||
|
elif results['user']:
|
||||||
|
results['source'] = '{}@{}'.format(
|
||||||
|
NotifyOffice365.unquote(results['user']),
|
||||||
|
NotifyOffice365.unquote(results['host']),
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Object ID instead of email
|
||||||
|
results['source'] = NotifyOffice365.unquote(results['host'])
|
||||||
|
|
||||||
|
# Tenant
|
||||||
|
if 'tenant' in results['qsd'] and len(results['qsd']['tenant']):
|
||||||
|
# Extract the Tenant from the argument
|
||||||
|
results['tenant'] = \
|
||||||
|
NotifyOffice365.unquote(results['qsd']['tenant'])
|
||||||
|
|
||||||
|
elif entries:
|
||||||
|
results['tenant'] = NotifyOffice365.unquote(entries.pop(0))
|
||||||
|
|
||||||
|
# OAuth2 ID
|
||||||
|
if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']):
|
||||||
|
# Extract the API Key from an argument
|
||||||
|
results['client_id'] = \
|
||||||
|
NotifyOffice365.unquote(results['qsd']['oauth_id'])
|
||||||
|
|
||||||
|
elif entries:
|
||||||
# Get our client_id is the first entry on the path
|
# Get our client_id is the first entry on the path
|
||||||
results['client_id'] = NotifyOffice365.unquote(entries.pop(0))
|
results['client_id'] = NotifyOffice365.unquote(entries.pop(0))
|
||||||
|
|
||||||
except IndexError:
|
#
|
||||||
# no problem, we may get the client_id another way through
|
|
||||||
# arguments...
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Prepare our target listing
|
# Prepare our target listing
|
||||||
|
#
|
||||||
results['targets'] = list()
|
results['targets'] = list()
|
||||||
while entries:
|
while entries:
|
||||||
# Pop the last entry
|
# Pop the last entry
|
||||||
|
@ -662,36 +1005,6 @@ class NotifyOffice365(NotifyBase):
|
||||||
# We're done
|
# We're done
|
||||||
break
|
break
|
||||||
|
|
||||||
# Initialize our tenant
|
|
||||||
results['tenant'] = None
|
|
||||||
|
|
||||||
# Assemble our secret key which is a combination of the host followed
|
|
||||||
# by all entries in the full path that follow up until the first email
|
|
||||||
results['secret'] = '/'.join(
|
|
||||||
[NotifyOffice365.unquote(x) for x in entries])
|
|
||||||
|
|
||||||
# Assemble our client id from the user@hostname
|
|
||||||
if results['password']:
|
|
||||||
results['email'] = '{}@{}'.format(
|
|
||||||
NotifyOffice365.unquote(results['password']),
|
|
||||||
NotifyOffice365.unquote(results['host']),
|
|
||||||
)
|
|
||||||
# Update our tenant
|
|
||||||
results['tenant'] = NotifyOffice365.unquote(results['user'])
|
|
||||||
|
|
||||||
else:
|
|
||||||
# No tenant specified..
|
|
||||||
results['email'] = '{}@{}'.format(
|
|
||||||
NotifyOffice365.unquote(results['user']),
|
|
||||||
NotifyOffice365.unquote(results['host']),
|
|
||||||
)
|
|
||||||
|
|
||||||
# OAuth2 ID
|
|
||||||
if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']):
|
|
||||||
# Extract the API Key from an argument
|
|
||||||
results['client_id'] = \
|
|
||||||
NotifyOffice365.unquote(results['qsd']['oauth_id'])
|
|
||||||
|
|
||||||
# OAuth2 Secret
|
# OAuth2 Secret
|
||||||
if 'oauth_secret' in results['qsd'] and \
|
if 'oauth_secret' in results['qsd'] and \
|
||||||
len(results['qsd']['oauth_secret']):
|
len(results['qsd']['oauth_secret']):
|
||||||
|
@ -699,19 +1012,12 @@ class NotifyOffice365(NotifyBase):
|
||||||
results['secret'] = \
|
results['secret'] = \
|
||||||
NotifyOffice365.unquote(results['qsd']['oauth_secret'])
|
NotifyOffice365.unquote(results['qsd']['oauth_secret'])
|
||||||
|
|
||||||
# Tenant
|
else:
|
||||||
if 'from' in results['qsd'] and \
|
# Assemble our secret key which is a combination of the host
|
||||||
len(results['qsd']['from']):
|
# followed by all entries in the full path that follow up until
|
||||||
# Extract the sending account's information
|
# the first email
|
||||||
results['email'] = \
|
results['secret'] = '/'.join(
|
||||||
NotifyOffice365.unquote(results['qsd']['from'])
|
[NotifyOffice365.unquote(x) for x in entries])
|
||||||
|
|
||||||
# Tenant
|
|
||||||
if 'tenant' in results['qsd'] and \
|
|
||||||
len(results['qsd']['tenant']):
|
|
||||||
# Extract the Tenant from the argument
|
|
||||||
results['tenant'] = \
|
|
||||||
NotifyOffice365.unquote(results['qsd']['tenant'])
|
|
||||||
|
|
||||||
# Support the 'to' variable so that we can support targets this way too
|
# Support the 'to' variable so that we can support targets this way too
|
||||||
# The 'to' makes it easier to use yaml configuration
|
# The 'to' makes it easier to use yaml configuration
|
||||||
|
|
|
@ -89,6 +89,31 @@ def test_file_expiry(tmpdir):
|
||||||
assert aa.exists()
|
assert aa.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_mimetype():
|
||||||
|
"""
|
||||||
|
API: AttachFile MimeType()
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Simple gif test
|
||||||
|
path = join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
response = AppriseAttachment.instantiate(path)
|
||||||
|
assert isinstance(response, AttachFile)
|
||||||
|
assert response.path == path
|
||||||
|
assert response.name == 'apprise-test.gif'
|
||||||
|
assert response.mimetype == 'image/gif'
|
||||||
|
|
||||||
|
# Force mimetype
|
||||||
|
response._mimetype = None
|
||||||
|
response.detected_mimetype = None
|
||||||
|
|
||||||
|
assert response.mimetype == 'image/gif'
|
||||||
|
|
||||||
|
response._mimetype = None
|
||||||
|
response.detected_mimetype = None
|
||||||
|
with mock.patch('mimetypes.guess_type', side_effect=TypeError):
|
||||||
|
assert response.mimetype == 'application/octet-stream'
|
||||||
|
|
||||||
|
|
||||||
def test_attach_file():
|
def test_attach_file():
|
||||||
"""
|
"""
|
||||||
API: AttachFile()
|
API: AttachFile()
|
||||||
|
@ -105,6 +130,18 @@ def test_attach_file():
|
||||||
# results from cache
|
# results from cache
|
||||||
assert response.download()
|
assert response.download()
|
||||||
|
|
||||||
|
with mock.patch('os.path.isfile', side_effect=OSError):
|
||||||
|
assert response.exists() is False
|
||||||
|
|
||||||
|
with mock.patch('os.path.isfile', return_value=False):
|
||||||
|
assert response.exists() is False
|
||||||
|
|
||||||
|
# Test that our file exists
|
||||||
|
assert response.exists() is True
|
||||||
|
response.cache = True
|
||||||
|
# Leverage always-cached flag
|
||||||
|
assert response.exists() is True
|
||||||
|
|
||||||
# On Windows, it is `file://D%3A%5Ca%5Capprise%5Capprise%5Ctest%5Cvar%5Capprise-test.gif`. # noqa E501
|
# On Windows, it is `file://D%3A%5Ca%5Capprise%5Capprise%5Ctest%5Cvar%5Capprise-test.gif`. # noqa E501
|
||||||
# TODO: Review - is this correct?
|
# TODO: Review - is this correct?
|
||||||
path_in_url = urllib.parse.quote(path)
|
path_in_url = urllib.parse.quote(path)
|
||||||
|
@ -213,6 +250,23 @@ def test_attach_file():
|
||||||
aa = AppriseAttachment(location=ContentLocation.HOSTED)
|
aa = AppriseAttachment(location=ContentLocation.HOSTED)
|
||||||
assert aa.add(path) is False
|
assert aa.add(path) is False
|
||||||
|
|
||||||
|
response = AppriseAttachment.instantiate(path)
|
||||||
|
assert len(response) > 0
|
||||||
|
|
||||||
|
# Get file
|
||||||
|
assert response.download()
|
||||||
|
|
||||||
|
# Test the inability to get our file size
|
||||||
|
with mock.patch('os.path.getsize', side_effect=(0, OSError)):
|
||||||
|
assert len(response) == 0
|
||||||
|
|
||||||
|
# get file again
|
||||||
|
assert response.download()
|
||||||
|
with mock.patch('os.path.isfile', return_value=True):
|
||||||
|
response.cache = True
|
||||||
|
with mock.patch('os.path.getsize', side_effect=OSError):
|
||||||
|
assert len(response) == 0
|
||||||
|
|
||||||
|
|
||||||
def test_attach_file_base64():
|
def test_attach_file_base64():
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -34,6 +34,8 @@ import requests
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from apprise import Apprise
|
from apprise import Apprise
|
||||||
|
from apprise import NotifyType
|
||||||
|
from apprise import AppriseAttachment
|
||||||
from apprise.plugins.office365 import NotifyOffice365
|
from apprise.plugins.office365 import NotifyOffice365
|
||||||
from helpers import AppriseURLTester
|
from helpers import AppriseURLTester
|
||||||
|
|
||||||
|
@ -57,7 +59,7 @@ apprise_url_tests = (
|
||||||
# invalid url
|
# invalid url
|
||||||
'instance': TypeError,
|
'instance': TypeError,
|
||||||
}),
|
}),
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
# invalid tenant
|
# invalid tenant
|
||||||
tenant=',',
|
tenant=',',
|
||||||
cid='ab-cd-ef-gh',
|
cid='ab-cd-ef-gh',
|
||||||
|
@ -65,24 +67,24 @@ apprise_url_tests = (
|
||||||
secret='abcd/123/3343/@jack/test',
|
secret='abcd/123/3343/@jack/test',
|
||||||
targets='/'.join(['email1@test.ca'])), {
|
targets='/'.join(['email1@test.ca'])), {
|
||||||
|
|
||||||
# We're valid and good to go
|
# Expected failure
|
||||||
'instance': TypeError,
|
'instance': TypeError,
|
||||||
}),
|
}),
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
# invalid client id
|
# invalid client id
|
||||||
cid='ab.',
|
cid='ab.',
|
||||||
aid='user@example.com',
|
aid='user2@example.com',
|
||||||
secret='abcd/123/3343/@jack/test',
|
secret='abcd/123/3343/@jack/test',
|
||||||
targets='/'.join(['email1@test.ca'])), {
|
targets='/'.join(['email1@test.ca'])), {
|
||||||
|
|
||||||
# We're valid and good to go
|
# Expected failure
|
||||||
'instance': TypeError,
|
'instance': TypeError,
|
||||||
}),
|
}),
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
('o365://{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
|
# email not required if mode is set to self
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
cid='ab-cd-ef-gh',
|
cid='ab-cd-ef-gh',
|
||||||
aid='user@example.com',
|
|
||||||
secret='abcd/123/3343/@jack/test',
|
secret='abcd/123/3343/@jack/test',
|
||||||
targets='/'.join(['email1@test.ca'])), {
|
targets='/'.join(['email1@test.ca'])), {
|
||||||
|
|
||||||
|
@ -93,17 +95,105 @@ apprise_url_tests = (
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'expires_in': 2000,
|
'expires_in': 2000,
|
||||||
'access_token': 'abcd1234',
|
'access_token': 'abcd1234',
|
||||||
|
'mail': 'user@example.ca',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
|
tenant='tenant',
|
||||||
|
cid='ab-cd-ef-gh',
|
||||||
|
aid='user@example.edu',
|
||||||
|
secret='abcd/123/3343/@jack/test',
|
||||||
|
targets='/'.join(['email1@test.ca'])), {
|
||||||
|
|
||||||
|
# We're valid and good to go
|
||||||
|
'instance': NotifyOffice365,
|
||||||
|
|
||||||
|
# Test what happens if a batch send fails to return a messageCount
|
||||||
|
'requests_response_text': {
|
||||||
|
'expires_in': 2000,
|
||||||
|
'access_token': 'abcd1234',
|
||||||
|
# For 'From:' Lookup
|
||||||
|
'mail': 'user@example.ca',
|
||||||
},
|
},
|
||||||
|
|
||||||
# Our expected url(privacy=True) startswith() response:
|
# Our expected url(privacy=True) startswith() response:
|
||||||
'privacy_url': 'o365://t...t:user@example.com/a...h/'
|
'privacy_url': 'azure://user@example.edu/t...t/a...h/'
|
||||||
'****/email1%40test.ca/'}),
|
'****/email1@test.ca/'}),
|
||||||
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
|
tenant='tenant',
|
||||||
|
cid='ab-cd-ef-gh',
|
||||||
|
# Source can also be Object ID
|
||||||
|
aid='hg-fe-dc-ba',
|
||||||
|
secret='abcd/123/3343/@jack/test',
|
||||||
|
targets='/'.join(['email1@test.ca'])), {
|
||||||
|
|
||||||
|
# We're valid and good to go
|
||||||
|
'instance': NotifyOffice365,
|
||||||
|
|
||||||
|
# Test what happens if a batch send fails to return a messageCount
|
||||||
|
'requests_response_text': {
|
||||||
|
'expires_in': 2000,
|
||||||
|
'access_token': 'abcd1234',
|
||||||
|
'mail': 'user@example.ca',
|
||||||
|
"displayName": "John",
|
||||||
|
},
|
||||||
|
|
||||||
|
# Our expected url(privacy=True) startswith() response:
|
||||||
|
'privacy_url': 'azure://hg-fe-dc-ba/t...t/a...h/'
|
||||||
|
'****/email1@test.ca/'}),
|
||||||
|
|
||||||
|
# ObjectID Specified, but no targets
|
||||||
|
('o365://{aid}/{tenant}/{cid}/{secret}/'.format(
|
||||||
|
tenant='tenant',
|
||||||
|
cid='ab-cd-ef-gh',
|
||||||
|
# Source can also be Object ID
|
||||||
|
aid='hg-fe-dc-ba',
|
||||||
|
secret='abcd/123/3343/@jack/test'), {
|
||||||
|
|
||||||
|
# We're valid and good to go
|
||||||
|
'instance': NotifyOffice365,
|
||||||
|
|
||||||
|
# Test what happens if a batch send fails to return a messageCount
|
||||||
|
'requests_response_text': {
|
||||||
|
'expires_in': 2000,
|
||||||
|
'access_token': 'abcd1234',
|
||||||
|
'mail': 'user@example.ca',
|
||||||
|
},
|
||||||
|
# No emails detected
|
||||||
|
'notify_response': False,
|
||||||
|
|
||||||
|
# Our expected url(privacy=True) startswith() response:
|
||||||
|
'privacy_url': 'azure://hg-fe-dc-ba/t...t/a...h/****'}),
|
||||||
|
|
||||||
|
# ObjectID Specified, but no targets
|
||||||
|
('o365://{aid}/{tenant}/{cid}/{secret}/'.format(
|
||||||
|
tenant='tenant',
|
||||||
|
cid='ab-cd-ef-gh',
|
||||||
|
# Source can also be Object ID
|
||||||
|
aid='hg-fe-dc-ba',
|
||||||
|
secret='abcd/123/3343/@jack/test'), {
|
||||||
|
|
||||||
|
# We're valid and good to go
|
||||||
|
'instance': NotifyOffice365,
|
||||||
|
|
||||||
|
# Test what happens if a batch send fails to return a messageCount
|
||||||
|
'requests_response_text': {
|
||||||
|
'expires_in': 2000,
|
||||||
|
'access_token': 'abcd1234',
|
||||||
|
'userPrincipalName': 'user@example.ca',
|
||||||
|
},
|
||||||
|
# No emails detected
|
||||||
|
'notify_response': False,
|
||||||
|
|
||||||
|
# Our expected url(privacy=True) startswith() response:
|
||||||
|
'privacy_url': 'azure://hg-fe-dc-ba/t...t/a...h/****'}),
|
||||||
|
|
||||||
# test our arguments
|
# test our arguments
|
||||||
('o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}'
|
('o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}'
|
||||||
'&to={targets}&from={aid}'.format(
|
'&to={targets}&from={aid}'.format(
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
cid='ab-cd-ef-gh',
|
cid='ab-cd-ef-gh',
|
||||||
aid='user@example.com',
|
aid='user@example.ca',
|
||||||
secret='abcd/123/3343/@jack/test',
|
secret='abcd/123/3343/@jack/test',
|
||||||
targets='email1@test.ca'),
|
targets='email1@test.ca'),
|
||||||
{
|
{
|
||||||
|
@ -114,13 +204,14 @@ apprise_url_tests = (
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'expires_in': 2000,
|
'expires_in': 2000,
|
||||||
'access_token': 'abcd1234',
|
'access_token': 'abcd1234',
|
||||||
|
'mail': 'user@example.ca',
|
||||||
},
|
},
|
||||||
|
|
||||||
# Our expected url(privacy=True) startswith() response:
|
# Our expected url(privacy=True) startswith() response:
|
||||||
'privacy_url': 'o365://t...t:user@example.com/a...h/'
|
'privacy_url': 'azure://user@example.ca/t...t/a...h/'
|
||||||
'****/email1%40test.ca/'}),
|
'****/email1@test.ca/'}),
|
||||||
# Test invalid JSON (no tenant defaults to email domain)
|
# Test invalid JSON (no tenant defaults to email domain)
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
cid='ab-cd-ef-gh',
|
cid='ab-cd-ef-gh',
|
||||||
aid='user@example.com',
|
aid='user@example.com',
|
||||||
|
@ -135,7 +226,7 @@ apprise_url_tests = (
|
||||||
'notify_response': False,
|
'notify_response': False,
|
||||||
}),
|
}),
|
||||||
# No Targets specified
|
# No Targets specified
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}'.format(
|
('o365://{aid}/{tenant}/{cid}/{secret}'.format(
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
cid='ab-cd-ef-gh',
|
cid='ab-cd-ef-gh',
|
||||||
aid='user@example.com',
|
aid='user@example.com',
|
||||||
|
@ -148,9 +239,10 @@ apprise_url_tests = (
|
||||||
'requests_response_text': {
|
'requests_response_text': {
|
||||||
'expires_in': 2000,
|
'expires_in': 2000,
|
||||||
'access_token': 'abcd1234',
|
'access_token': 'abcd1234',
|
||||||
|
'userPrincipalName': 'user@example.ca',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
('o365://{aid}/{tenant}/{cid}/{secret}/{targets}'.format(
|
||||||
tenant='tenant',
|
tenant='tenant',
|
||||||
cid='zz-zz-zz-zz',
|
cid='zz-zz-zz-zz',
|
||||||
aid='user@example.com',
|
aid='user@example.com',
|
||||||
|
@ -185,8 +277,9 @@ def test_plugin_office365_urls():
|
||||||
AppriseURLTester(tests=apprise_url_tests).run_all()
|
AppriseURLTester(tests=apprise_url_tests).run_all()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.get')
|
||||||
@mock.patch('requests.post')
|
@mock.patch('requests.post')
|
||||||
def test_plugin_office365_general(mock_post):
|
def test_plugin_office365_general(mock_get, mock_post):
|
||||||
"""
|
"""
|
||||||
NotifyOffice365() General Testing
|
NotifyOffice365() General Testing
|
||||||
|
|
||||||
|
@ -200,19 +293,24 @@ def test_plugin_office365_general(mock_post):
|
||||||
targets = 'target@example.com'
|
targets = 'target@example.com'
|
||||||
|
|
||||||
# Prepare Mock return object
|
# Prepare Mock return object
|
||||||
authentication = {
|
payload = {
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 6000,
|
"expires_in": 6000,
|
||||||
"access_token": "abcd1234"
|
"access_token": "abcd1234",
|
||||||
|
# For 'From:' Lookup
|
||||||
|
"mail": "abc@example.ca",
|
||||||
|
# For our Draft Email ID:
|
||||||
|
"id": "draft-id-no",
|
||||||
}
|
}
|
||||||
response = mock.Mock()
|
response = mock.Mock()
|
||||||
response.content = dumps(authentication)
|
response.content = dumps(payload)
|
||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
mock_post.return_value = response
|
mock_post.return_value = response
|
||||||
|
mock_get.return_value = response
|
||||||
|
|
||||||
# Instantiate our object
|
# Instantiate our object
|
||||||
obj = Apprise.instantiate(
|
obj = Apprise.instantiate(
|
||||||
'o365://{tenant}:{email}/{tenant}/{secret}/{targets}'.format(
|
'o365://{email}/{tenant}/{secret}/{targets}'.format(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
email=email,
|
email=email,
|
||||||
secret=secret,
|
secret=secret,
|
||||||
|
@ -228,10 +326,11 @@ def test_plugin_office365_general(mock_post):
|
||||||
|
|
||||||
# Instantiate our object
|
# Instantiate our object
|
||||||
obj = Apprise.instantiate(
|
obj = Apprise.instantiate(
|
||||||
'o365://{tenant}:{email}/{tenant}/{secret}/{targets}'
|
'o365://{email}/{tenant}/{client_id}/{secret}/{targets}'
|
||||||
'?bcc={bcc}&cc={cc}'.format(
|
'?bcc={bcc}&cc={cc}'.format(
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
email=email,
|
email=email,
|
||||||
|
client_id=client_id,
|
||||||
secret=secret,
|
secret=secret,
|
||||||
targets=targets,
|
targets=targets,
|
||||||
# Test the cc and bcc list (use good and bad email)
|
# Test the cc and bcc list (use good and bad email)
|
||||||
|
@ -257,26 +356,6 @@ def test_plugin_office365_general(mock_post):
|
||||||
targets=None,
|
targets=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
# Invalid email
|
|
||||||
NotifyOffice365(
|
|
||||||
email=None,
|
|
||||||
client_id=client_id,
|
|
||||||
tenant=tenant,
|
|
||||||
secret=secret,
|
|
||||||
targets=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
# Invalid email
|
|
||||||
NotifyOffice365(
|
|
||||||
email='garbage',
|
|
||||||
client_id=client_id,
|
|
||||||
tenant=tenant,
|
|
||||||
secret=secret,
|
|
||||||
targets=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# One of the targets are invalid
|
# One of the targets are invalid
|
||||||
obj = NotifyOffice365(
|
obj = NotifyOffice365(
|
||||||
email=email,
|
email=email,
|
||||||
|
@ -301,8 +380,9 @@ def test_plugin_office365_general(mock_post):
|
||||||
assert obj.notify(title='title', body='test') is False
|
assert obj.notify(title='title', body='test') is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.get')
|
||||||
@mock.patch('requests.post')
|
@mock.patch('requests.post')
|
||||||
def test_plugin_office365_authentication(mock_post):
|
def test_plugin_office365_authentication(mock_get, mock_post):
|
||||||
"""
|
"""
|
||||||
NotifyOffice365() Authentication Testing
|
NotifyOffice365() Authentication Testing
|
||||||
|
|
||||||
|
@ -333,10 +413,11 @@ def test_plugin_office365_authentication(mock_post):
|
||||||
response.content = dumps(authentication_okay)
|
response.content = dumps(authentication_okay)
|
||||||
response.status_code = requests.codes.ok
|
response.status_code = requests.codes.ok
|
||||||
mock_post.return_value = response
|
mock_post.return_value = response
|
||||||
|
mock_get.return_value = response
|
||||||
|
|
||||||
# Instantiate our object
|
# Instantiate our object
|
||||||
obj = Apprise.instantiate(
|
obj = Apprise.instantiate(
|
||||||
'o365://{tenant}:{email}/{client_id}/{secret}/{targets}'.format(
|
'azure://{email}/{tenant}/{client_id}/{secret}/{targets}'.format(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
email=email,
|
email=email,
|
||||||
|
@ -394,3 +475,408 @@ def test_plugin_office365_authentication(mock_post):
|
||||||
del invalid_auth_entries['expires_in']
|
del invalid_auth_entries['expires_in']
|
||||||
response.content = dumps(invalid_auth_entries)
|
response.content = dumps(invalid_auth_entries)
|
||||||
assert obj.authenticate() is False
|
assert obj.authenticate() is False
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.put')
|
||||||
|
@mock.patch('requests.get')
|
||||||
|
@mock.patch('requests.post')
|
||||||
|
def test_plugin_office365_queries(mock_post, mock_get, mock_put):
|
||||||
|
"""
|
||||||
|
NotifyOffice365() General Queries
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Initialize some generic (but valid) tokens
|
||||||
|
source = 'abc-1234-object-id'
|
||||||
|
tenant = 'ff-gg-hh-ii-jj'
|
||||||
|
client_id = 'aa-bb-cc-dd-ee'
|
||||||
|
secret = 'abcd/1234/abcd@ajd@/test'
|
||||||
|
targets = 'target@example.ca'
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
payload = {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 6000,
|
||||||
|
"access_token": "abcd1234",
|
||||||
|
# For 'From:' Lookup (email)
|
||||||
|
"mail": "user@example.edu",
|
||||||
|
# For 'From:' Lookup (name)
|
||||||
|
"displayName": "John",
|
||||||
|
# For our Draft Email ID:
|
||||||
|
"id": "draft-id-no",
|
||||||
|
# For FIle Uploads
|
||||||
|
"uploadUrl": "https://my.url.path/"
|
||||||
|
}
|
||||||
|
|
||||||
|
okay_response = mock.Mock()
|
||||||
|
okay_response.content = dumps(payload)
|
||||||
|
okay_response.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
mock_put.return_value = okay_response
|
||||||
|
|
||||||
|
bad_response = mock.Mock()
|
||||||
|
bad_response.content = dumps(payload)
|
||||||
|
bad_response.status_code = requests.codes.forbidden
|
||||||
|
|
||||||
|
# Assign our GET a bad response so we fail to look up the user
|
||||||
|
mock_get.return_value = bad_response
|
||||||
|
|
||||||
|
# Instantiate our object
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
# We can still send a notification even if we can't look up the email
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO) is True
|
||||||
|
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/{}/oauth2/v2.0/token'.format(tenant)
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/abc-1234-object-id/sendMail'
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Now test a case where we just couldn't get any email details from the
|
||||||
|
# payload returned
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
temp_payload = {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 6000,
|
||||||
|
"access_token": "abcd1234",
|
||||||
|
# For our Draft Email ID:
|
||||||
|
"id": "draft-id-no",
|
||||||
|
# For FIle Uploads
|
||||||
|
"uploadUrl": "https://my.url.path/"
|
||||||
|
}
|
||||||
|
|
||||||
|
bad_response.content = dumps(temp_payload)
|
||||||
|
bad_response.status_code = requests.codes.okay
|
||||||
|
mock_get.return_value = bad_response
|
||||||
|
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
# We can still send a notification even if we can't look up the email
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO) is True
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.put')
|
||||||
|
@mock.patch('requests.get')
|
||||||
|
@mock.patch('requests.post')
|
||||||
|
def test_plugin_office365_attachments(mock_post, mock_get, mock_put):
|
||||||
|
"""
|
||||||
|
NotifyOffice365() Attachments
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Initialize some generic (but valid) tokens
|
||||||
|
source = 'user@example.net'
|
||||||
|
tenant = 'ff-gg-hh-ii-jj'
|
||||||
|
client_id = 'aa-bb-cc-dd-ee'
|
||||||
|
secret = 'abcd/1234/abcd@ajd@/test'
|
||||||
|
targets = 'target@example.com'
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
payload = {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 6000,
|
||||||
|
"access_token": "abcd1234",
|
||||||
|
# For 'From:' Lookup
|
||||||
|
"mail": "user@example.edu",
|
||||||
|
# For our Draft Email ID:
|
||||||
|
"id": "draft-id-no",
|
||||||
|
# For FIle Uploads
|
||||||
|
"uploadUrl": "https://my.url.path/"
|
||||||
|
}
|
||||||
|
okay_response = mock.Mock()
|
||||||
|
okay_response.content = dumps(payload)
|
||||||
|
okay_response.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
mock_get.return_value = okay_response
|
||||||
|
mock_put.return_value = okay_response
|
||||||
|
|
||||||
|
# Instantiate our object
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
# Test Valid Attachment
|
||||||
|
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
attach = AppriseAttachment(path)
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/{}/oauth2/v2.0/token'.format(tenant)
|
||||||
|
assert mock_post.call_args_list[0][1]['headers'] \
|
||||||
|
.get('Content-Type') == 'application/x-www-form-urlencoded'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/sendMail'.format(source)
|
||||||
|
assert mock_post.call_args_list[1][1]['headers'] \
|
||||||
|
.get('Content-Type') == 'application/json'
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Test Authentication Failure
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source='object-id-requiring-lookup',
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
bad_response = mock.Mock()
|
||||||
|
bad_response.content = dumps(payload)
|
||||||
|
bad_response.status_code = requests.codes.forbidden
|
||||||
|
mock_post.return_value = bad_response
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
# Authentication will fail
|
||||||
|
assert obj.notify(
|
||||||
|
body='auth-fail', title='title', notify_type=NotifyType.INFO) is False
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token'
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Test invalid attachment
|
||||||
|
#
|
||||||
|
|
||||||
|
# Instantiate our object
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=path) is False
|
||||||
|
assert mock_post.call_count == 0
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
with mock.patch('base64.b64encode', side_effect=OSError()):
|
||||||
|
# We can't send the message if we fail to parse the data
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
assert mock_post.call_count == 0
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Test case where we can't authenticate
|
||||||
|
#
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
# Force a smaller attachment size forcing us to create an attachment
|
||||||
|
obj.outlook_attachment_inline_max = 50
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
attach = AppriseAttachment(path)
|
||||||
|
mock_post.return_value = bad_response
|
||||||
|
assert obj.upload_attachment(attach[0], 'id') is False
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token'
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
mock_post.side_effect = (okay_response, bad_response)
|
||||||
|
mock_post.return_value = None
|
||||||
|
assert obj.upload_attachment(attach[0], 'id') is False
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/'.format(source) + \
|
||||||
|
'message/id/attachments/createUploadSession'
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
# Return our status
|
||||||
|
mock_post.side_effect = None
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
payload_no_upload_url = {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 6000,
|
||||||
|
"access_token": "abcd1234",
|
||||||
|
# For 'From:' Lookup
|
||||||
|
"mail": "user@example.edu",
|
||||||
|
# For our Draft Email ID:
|
||||||
|
"id": "draft-id-no",
|
||||||
|
}
|
||||||
|
tmp_response = mock.Mock()
|
||||||
|
tmp_response.content = dumps(payload_no_upload_url)
|
||||||
|
tmp_response.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value = tmp_response
|
||||||
|
|
||||||
|
assert obj.upload_attachment(attach[0], 'id') is False
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/'.format(source) + \
|
||||||
|
'message/id/attachments/createUploadSession'
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
# Return our status
|
||||||
|
mock_post.side_effect = None
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
|
||||||
|
obj = Apprise.instantiate(
|
||||||
|
'azure://{source}/{tenant}/{client_id}{secret}/{targets}'.format(
|
||||||
|
client_id=client_id,
|
||||||
|
tenant=tenant,
|
||||||
|
source=source,
|
||||||
|
secret=secret,
|
||||||
|
targets=targets))
|
||||||
|
|
||||||
|
# Force a smaller attachment size forcing us to create an attachment
|
||||||
|
obj.outlook_attachment_inline_max = 50
|
||||||
|
|
||||||
|
assert isinstance(obj, NotifyOffice365)
|
||||||
|
|
||||||
|
# We now have to prepare sepparate session attachments using draft emails
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title-test', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
|
||||||
|
# Large Attachments
|
||||||
|
assert mock_post.call_count == 4
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://login.microsoftonline.com/ff-gg-hh-ii-jj/oauth2/v2.0/token'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/messages'.format(source)
|
||||||
|
assert mock_post.call_args_list[2][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/'.format(source) + \
|
||||||
|
'message/draft-id-no/attachments/createUploadSession'
|
||||||
|
assert mock_post.call_args_list[3][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/sendMail'.format(source)
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
#
|
||||||
|
# Handle another case where can't upload the attachment at all
|
||||||
|
#
|
||||||
|
path = os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg')
|
||||||
|
bad_attach = AppriseAttachment(path)
|
||||||
|
assert obj.upload_attachment(bad_attach[0], 'id') is False
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
#
|
||||||
|
# Handle test case where we can't send the draft email after everything
|
||||||
|
# has been prepared
|
||||||
|
#
|
||||||
|
mock_post.return_value = None
|
||||||
|
mock_post.side_effect = (okay_response, okay_response, bad_response)
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title-test', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
|
||||||
|
assert mock_post.call_count == 3
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/messages'.format(source)
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/'.format(source) + \
|
||||||
|
'message/draft-id-no/attachments/createUploadSession'
|
||||||
|
assert mock_post.call_args_list[2][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/sendMail'.format(source)
|
||||||
|
mock_post.reset_mock()
|
||||||
|
mock_post.side_effect = None
|
||||||
|
mock_post.return_value = okay_response
|
||||||
|
|
||||||
|
#
|
||||||
|
# Handle test case where we can not upload chunks
|
||||||
|
#
|
||||||
|
mock_put.return_value = bad_response
|
||||||
|
|
||||||
|
# We now have to prepare sepparate session attachments using draft emails
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title-no-chunk', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/messages'.format(source)
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/'.format(source) + \
|
||||||
|
'message/draft-id-no/attachments/createUploadSession'
|
||||||
|
|
||||||
|
mock_put.return_value = okay_response
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Prepare Mock return object
|
||||||
|
payload_missing_id = {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 6000,
|
||||||
|
"access_token": "abcd1234",
|
||||||
|
# For 'From:' Lookup
|
||||||
|
"mail": "user@example.edu",
|
||||||
|
# For FIle Uploads
|
||||||
|
"uploadUrl": "https://my.url.path/"
|
||||||
|
}
|
||||||
|
temp_response = mock.Mock()
|
||||||
|
temp_response.content = dumps(payload_missing_id)
|
||||||
|
temp_response.status_code = requests.codes.ok
|
||||||
|
mock_post.return_value = temp_response
|
||||||
|
|
||||||
|
# We could not acquire an attachment id, so we'll fail to send our
|
||||||
|
# notification
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title-test', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
|
||||||
|
# Large Attachments
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/user@example.net/messages'
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Reset attachment size
|
||||||
|
obj.outlook_attachment_inline_max = 50 * 1024 * 1024
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
|
||||||
|
# already authenticated
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://graph.microsoft.com/v1.0/users/{}/sendMail'.format(source)
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
Loading…
Reference in New Issue