mirror of
https://github.com/caronc/apprise.git
synced 2025-01-03 20:49:19 +01:00
Office 365 Notification (via Email) Support (#229)
This commit is contained in:
parent
691fe2c227
commit
e9452b6f03
@ -52,6 +52,7 @@ The table below identifies the services this tool supports and some example serv
|
||||
| [Nextcloud](https://github.com/caronc/apprise/wiki/Notify_nextcloud) | ncloud:// or nclouds:// | (TCP) 80 or 443 | ncloud://adminuser:pass@host/User<br/>nclouds://adminuser:pass@host/User1/User2/UserN
|
||||
| [Notica](https://github.com/caronc/apprise/wiki/Notify_notica) | notica:// | (TCP) 443 | notica://Token/
|
||||
| [Notifico](https://github.com/caronc/apprise/wiki/Notify_notifico) | notifico:// | (TCP) 443 | notifico://ProjectID/MessageHook/
|
||||
| [Office 365](https://github.com/caronc/apprise/wiki/Notify_office365) | o365:// | (TCP) 443 | o365://TenantID:AccountEmail/ClientID/ClientSecret/<br />o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail/<br/ >o365://TenantID:AccountEmail/ClientID/ClientSecret/TargetEmail1/TargetEmail2/TargetEmailN
|
||||
| [Prowl](https://github.com/caronc/apprise/wiki/Notify_prowl) | prowl:// | (TCP) 443 | prowl://apikey<br />prowl://apikey/providerkey
|
||||
| [PushBullet](https://github.com/caronc/apprise/wiki/Notify_pushbullet) | pbul:// | (TCP) 443 | pbul://accesstoken<br />pbul://accesstoken/#channel<br/>pbul://accesstoken/A_DEVICE_ID<br />pbul://accesstoken/email@address.com<br />pbul://accesstoken/#channel/#channel2/email@address.net/DEVICE
|
||||
| [Pushjet](https://github.com/caronc/apprise/wiki/Notify_pushjet) | pjet:// or pjets:// | (TCP) 80 or 443 | pjet://hostname/secret<br />pjet://hostname:port/secret<br />pjets://secret@hostname/secret<br />pjets://hostname:port/secret
|
||||
|
@ -116,6 +116,21 @@ EMAIL_TEMPLATES = (
|
||||
},
|
||||
),
|
||||
|
||||
# Microsoft Office 365 (Email Server)
|
||||
# You must specify an authenticated sender address in the from= settings
|
||||
# and a valid email in the to= to deliver your emails to
|
||||
(
|
||||
'Microsoft Office 365',
|
||||
re.compile(
|
||||
r'^[^@]+@(?P<domain>(smtp\.)?office365\.com)$', re.I),
|
||||
{
|
||||
'port': 587,
|
||||
'smtp_host': 'smtp.office365.com',
|
||||
'secure': True,
|
||||
'secure_mode': SecureMailMode.STARTTLS,
|
||||
},
|
||||
),
|
||||
|
||||
# Yahoo Mail
|
||||
(
|
||||
'Yahoo Mail',
|
||||
|
578
apprise/plugins/NotifyOffice365.py
Normal file
578
apprise/plugins/NotifyOffice365.py
Normal file
@ -0,0 +1,578 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
# API Details:
|
||||
# https://docs.microsoft.com/en-us/previous-versions/office/\
|
||||
# office-365-api/?redirectedfrom=MSDN
|
||||
|
||||
# Information on sending an email:
|
||||
# https://docs.microsoft.com/en-us/graph/api/user-sendmail\
|
||||
# ?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.
|
||||
#
|
||||
# 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
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from json import loads
|
||||
from json import dumps
|
||||
from .NotifyBase import NotifyBase
|
||||
from ..URLBase import PrivacyMode
|
||||
from ..common import NotifyFormat
|
||||
from ..common import NotifyType
|
||||
from ..utils import is_email
|
||||
from ..utils import parse_list
|
||||
from ..utils import validate_regex
|
||||
from ..AppriseLocale import gettext_lazy as _
|
||||
|
||||
|
||||
class NotifyOffice365(NotifyBase):
|
||||
"""
|
||||
A wrapper for Office 365 Notifications
|
||||
"""
|
||||
|
||||
# The default descriptive name associated with the Notification
|
||||
service_name = 'Office 365'
|
||||
|
||||
# The services URL
|
||||
service_url = 'https://office.com/'
|
||||
|
||||
# The default protocol
|
||||
secure_protocol = 'o365'
|
||||
|
||||
# Allow 300 requests per minute.
|
||||
# 60/300 = 0.2
|
||||
request_rate_per_sec = 0.20
|
||||
|
||||
# A URL that takes you to the setup/help of the specific protocol
|
||||
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_office365'
|
||||
|
||||
# URL to Microsoft Graph Server
|
||||
graph_url = 'https://graph.microsoft.com'
|
||||
|
||||
# Authentication URL
|
||||
auth_url = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token'
|
||||
|
||||
# Use all the direct application permissions you have configured for your
|
||||
# app. The endpoint should issue a token for the ones associated with the
|
||||
# resource you want to use.
|
||||
# see https://docs.microsoft.com/en-us/azure/active-directory/develop/\
|
||||
# v2-permissions-and-consent#the-default-scope
|
||||
scope = '.default'
|
||||
|
||||
# Default Notify Format
|
||||
notify_format = NotifyFormat.HTML
|
||||
|
||||
# Define object templates
|
||||
templates = (
|
||||
'{schema}://{tenant}:{email}/{client_id}/{secret}',
|
||||
'{schema}://{tenant}:{email}/{client_id}/{secret}/{targets}',
|
||||
)
|
||||
|
||||
# Define our template tokens
|
||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||
'tenant': {
|
||||
'name': _('Tenant Domain'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'private': True,
|
||||
'regex': (r'^[a-z0-9-]+$', 'i'),
|
||||
},
|
||||
'email': {
|
||||
'name': _('Account Email'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
},
|
||||
'client_id': {
|
||||
'name': _('Client ID'),
|
||||
'type': 'string',
|
||||
'required': True,
|
||||
'private': True,
|
||||
'regex': (r'^[a-z0-9-]+$', 'i'),
|
||||
},
|
||||
'secret': {
|
||||
'name': _('Client Secret'),
|
||||
'type': 'string',
|
||||
'private': True,
|
||||
'required': True,
|
||||
},
|
||||
'targets': {
|
||||
'name': _('Target Emails'),
|
||||
'type': 'list:string',
|
||||
},
|
||||
})
|
||||
|
||||
# Define our template arguments
|
||||
template_args = dict(NotifyBase.template_args, **{
|
||||
'to': {
|
||||
'alias_of': 'targets',
|
||||
},
|
||||
'oauth_id': {
|
||||
'alias_of': 'client_id',
|
||||
},
|
||||
'oauth_secret': {
|
||||
'alias_of': 'secret',
|
||||
},
|
||||
})
|
||||
|
||||
def __init__(self, tenant, email, client_id, secret,
|
||||
targets=None, **kwargs):
|
||||
"""
|
||||
Initialize Office 365 Object
|
||||
"""
|
||||
super(NotifyOffice365, self).__init__(**kwargs)
|
||||
|
||||
# Tenant identifier
|
||||
self.tenant = validate_regex(
|
||||
tenant, *self.template_tokens['tenant']['regex'])
|
||||
if not self.tenant:
|
||||
msg = 'An invalid Office 365 Tenant' \
|
||||
'({}) was specified.'.format(tenant)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
if not is_email(email):
|
||||
msg = 'An invalid Office 365 Email Account ID' \
|
||||
'({}) was specified.'.format(email)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
self.email = email
|
||||
|
||||
# Client Key (associated with generated OAuth2 Login)
|
||||
self.client_id = validate_regex(
|
||||
client_id, *self.template_tokens['client_id']['regex'])
|
||||
if not self.client_id:
|
||||
msg = 'An invalid Office 365 Client OAuth2 ID ' \
|
||||
'({}) was specified.'.format(client_id)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Client Secret (associated with generated OAuth2 Login)
|
||||
self.secret = validate_regex(secret)
|
||||
if not self.secret:
|
||||
msg = 'An invalid Office 365 Client OAuth2 Secret ' \
|
||||
'({}) was specified.'.format(secret)
|
||||
self.logger.warning(msg)
|
||||
raise TypeError(msg)
|
||||
|
||||
# Parse our targets
|
||||
self.targets = list()
|
||||
|
||||
targets = parse_list(targets)
|
||||
if targets:
|
||||
for target in targets:
|
||||
# Validate targets and drop bad ones:
|
||||
if not is_email(target):
|
||||
self.logger.warning(
|
||||
'Dropped invalid email specified: {}'.format(target))
|
||||
continue
|
||||
|
||||
# Add our email to our target list
|
||||
self.targets.append(target)
|
||||
else:
|
||||
# Default to adding ourselves
|
||||
self.targets.append(self.email)
|
||||
|
||||
# Our token is acquired upon a successful login
|
||||
self.token = None
|
||||
|
||||
# Presume that our token has expired 'now'
|
||||
self.token_expiry = datetime.now()
|
||||
|
||||
return
|
||||
|
||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||
"""
|
||||
Perform Office 365 Notification
|
||||
"""
|
||||
|
||||
# error tracking (used for function return)
|
||||
has_error = False
|
||||
|
||||
if not self.targets:
|
||||
# There is no one to email; we're done
|
||||
self.logger.warning(
|
||||
'There are no Office 365 recipients to notify')
|
||||
return False
|
||||
|
||||
# Setup our Content Type
|
||||
content_type = \
|
||||
'HTML' if self.notify_format == NotifyFormat.HTML else 'Text'
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
'Message': {
|
||||
'Subject': title,
|
||||
'Body': {
|
||||
'ContentType': content_type,
|
||||
'Content': body,
|
||||
},
|
||||
},
|
||||
'SaveToSentItems': 'false'
|
||||
}
|
||||
|
||||
# Create a copy of the targets list
|
||||
targets = list(self.targets)
|
||||
|
||||
# Define our URL to post to
|
||||
url = '{graph_url}/v1.0/users/{email}/sendmail'.format(
|
||||
email=self.email,
|
||||
graph_url=self.graph_url,
|
||||
)
|
||||
|
||||
while len(targets):
|
||||
# Get our target to notify
|
||||
target = targets.pop(0)
|
||||
|
||||
# Prepare our email
|
||||
payload['Message']['ToRecipients'] = [{
|
||||
'EmailAddress': {
|
||||
'Address': target
|
||||
}
|
||||
}]
|
||||
|
||||
# authenticate ourselves if we aren't already; but this function
|
||||
# also tracks if our token we have is still valid and will
|
||||
# re-authenticate ourselves if nessisary.
|
||||
if not self.authenticate():
|
||||
# We could not authenticate ourselves; we're done
|
||||
return False
|
||||
|
||||
postokay, response = self._fetch(
|
||||
url=url, payload=dumps(payload),
|
||||
content_type='application/json')
|
||||
if not postokay:
|
||||
has_error = True
|
||||
|
||||
return not has_error
|
||||
|
||||
def authenticate(self):
|
||||
"""
|
||||
Logs into and acquires us an authentication token to work with
|
||||
"""
|
||||
|
||||
if self.token and self.token_expiry > datetime.now():
|
||||
# If we're already authenticated and our token is still valid
|
||||
self.logger.debug(
|
||||
'Already authenticate with token {}'.format(self.token))
|
||||
return True
|
||||
|
||||
# If we reach here, we've either expired, or we need to authenticate
|
||||
# for the first time.
|
||||
|
||||
# Prepare our payload
|
||||
payload = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.secret,
|
||||
'scope': '{graph_url}/{scope}'.format(
|
||||
graph_url=self.graph_url,
|
||||
scope=self.scope),
|
||||
'grant_type': 'client_credentials',
|
||||
}
|
||||
|
||||
# Prepare our URL
|
||||
url = self.auth_url.format(tenant=self.tenant)
|
||||
|
||||
# A response looks like the following:
|
||||
# {
|
||||
# "token_type": "Bearer",
|
||||
# "expires_in": 3599,
|
||||
# "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSzI1NiIsInNBXBP..."
|
||||
# }
|
||||
#
|
||||
# Where expires_in defines the number of seconds the key is valid for
|
||||
# before it must be renewed.
|
||||
|
||||
# Alternatively, this could happen too...
|
||||
# {
|
||||
# "error": "invalid_scope",
|
||||
# "error_description": "AADSTS70011: Blah... Blah Blah... Blah",
|
||||
# "error_codes": [
|
||||
# 70011
|
||||
# ],
|
||||
# "timestamp": "2020-01-09 02:02:12Z",
|
||||
# "trace_id": "255d1aef-8c98-452f-ac51-23d051240864",
|
||||
# "correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7"
|
||||
# }
|
||||
|
||||
postokay, response = self._fetch(url=url, payload=payload)
|
||||
if not postokay:
|
||||
return False
|
||||
|
||||
# Reset our token
|
||||
self.token = None
|
||||
|
||||
try:
|
||||
# Extract our time from our response and subtrace 10 seconds from
|
||||
# it to give us some wiggle/grace people to re-authenticate if we
|
||||
# need to
|
||||
self.token_expiry = datetime.now() + \
|
||||
timedelta(seconds=int(response.get('expires_in')) - 10)
|
||||
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
# ValueError: expires_in wasn't an integer
|
||||
# TypeError: expires_in was None
|
||||
# AttributeError: we could not extract anything from our response
|
||||
# object.
|
||||
return False
|
||||
|
||||
# Go ahead and store our token if it's available
|
||||
self.token = response.get('access_token')
|
||||
|
||||
# We're authenticated
|
||||
return True if self.token else False
|
||||
|
||||
def _fetch(self, url, payload,
|
||||
content_type='application/x-www-form-urlencoded'):
|
||||
"""
|
||||
Wrapper to request object
|
||||
|
||||
"""
|
||||
|
||||
# Prepare our headers:
|
||||
headers = {
|
||||
'User-Agent': self.app_id,
|
||||
'Content-Type': content_type,
|
||||
}
|
||||
|
||||
if self.token:
|
||||
# Are we authenticated?
|
||||
headers['Authorization'] = 'Bearer ' + self.token
|
||||
|
||||
# Default content response object
|
||||
content = {}
|
||||
|
||||
# Some Debug Logging
|
||||
self.logger.debug('Office 365 POST URL: {} (cert_verify={})'.format(
|
||||
url, self.verify_certificate))
|
||||
self.logger.debug('Office 365 Payload: {}' .format(payload))
|
||||
|
||||
# Always call throttle before any remote server i/o is made
|
||||
self.throttle()
|
||||
|
||||
# fetch function
|
||||
try:
|
||||
r = requests.post(
|
||||
url,
|
||||
data=payload,
|
||||
headers=headers,
|
||||
verify=self.verify_certificate,
|
||||
)
|
||||
|
||||
if r.status_code not in (
|
||||
requests.codes.ok, requests.codes.accepted):
|
||||
|
||||
# We had a problem
|
||||
status_str = \
|
||||
NotifyOffice365.http_response_code_lookup(r.status_code)
|
||||
|
||||
self.logger.warning(
|
||||
'Failed to send Office 365 POST to {}: '
|
||||
'{}error={}.'.format(
|
||||
url,
|
||||
', ' if status_str else '',
|
||||
r.status_code))
|
||||
|
||||
self.logger.debug(
|
||||
'Response Details:\r\n{}'.format(r.content))
|
||||
|
||||
# Mark our failure
|
||||
return (False, content)
|
||||
|
||||
try:
|
||||
content = loads(r.content)
|
||||
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
# ValueError = r.content is Unparsable
|
||||
# TypeError = r.content is None
|
||||
# AttributeError = r is None
|
||||
content = {}
|
||||
|
||||
except requests.RequestException as e:
|
||||
self.logger.warning(
|
||||
'Exception received when sending Office 365 POST to {}: '.
|
||||
format(url))
|
||||
self.logger.debug('Socket Exception: %s' % str(e))
|
||||
|
||||
# Mark our failure
|
||||
return (False, content)
|
||||
|
||||
return (True, content)
|
||||
|
||||
def url(self, privacy=False, *args, **kwargs):
|
||||
"""
|
||||
Returns the URL built dynamically based on specified arguments.
|
||||
"""
|
||||
|
||||
# Define any arguments set
|
||||
args = {
|
||||
'format': self.notify_format,
|
||||
'overflow': self.overflow_mode,
|
||||
'verify': 'yes' if self.verify_certificate else 'no',
|
||||
}
|
||||
|
||||
return '{schema}://{tenant}:{email}/{client_id}/{secret}' \
|
||||
'/{targets}/?{args}'.format(
|
||||
schema=self.secure_protocol,
|
||||
tenant=self.pprint(self.tenant, privacy, safe=''),
|
||||
# email does not need to be escaped because it should
|
||||
# already be a valid host and username at this point
|
||||
email=self.email,
|
||||
client_id=self.pprint(self.client_id, privacy, safe=''),
|
||||
secret=self.pprint(
|
||||
self.secret, privacy, mode=PrivacyMode.Secret,
|
||||
safe=''),
|
||||
targets='/'.join(
|
||||
[NotifyOffice365.quote(x, safe='') for x in self.targets]),
|
||||
args=NotifyOffice365.urlencode(args))
|
||||
|
||||
@staticmethod
|
||||
def parse_url(url):
|
||||
"""
|
||||
Parses the URL and returns enough arguments that can allow
|
||||
us to substantiate this object.
|
||||
|
||||
"""
|
||||
|
||||
results = NotifyBase.parse_url(url, verify_host=False)
|
||||
if not results:
|
||||
# We're done early as we couldn't load the results
|
||||
return results
|
||||
|
||||
# Now make a list of all our path entries
|
||||
# We need to read each entry back one at a time in reverse order
|
||||
# where each email found we mark as a target. Once we run out
|
||||
# of targets, the presume the remainder of the entries are part
|
||||
# of the secret key (since it can contain slashes in it)
|
||||
entries = NotifyOffice365.split_path(results['fullpath'])
|
||||
|
||||
try:
|
||||
# Get our client_id is the first entry on the path
|
||||
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
|
||||
results['targets'] = list()
|
||||
while entries:
|
||||
# Pop the last entry
|
||||
entry = NotifyOffice365.unquote(entries.pop(-1))
|
||||
|
||||
if is_email(entry):
|
||||
# Store our email and move on
|
||||
results['targets'].append(entry)
|
||||
continue
|
||||
|
||||
# If we reach here, the entry we just popped is part of the secret
|
||||
# key, so put it back
|
||||
entries.append(NotifyOffice365.quote(entry, safe=''))
|
||||
|
||||
# We're done
|
||||
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
|
||||
if 'oauth_secret' in results['qsd'] and \
|
||||
len(results['qsd']['oauth_secret']):
|
||||
# Extract the API Secret from an argument
|
||||
results['secret'] = \
|
||||
NotifyOffice365.unquote(results['qsd']['oauth_secret'])
|
||||
|
||||
# Tenant
|
||||
if 'from' in results['qsd'] and \
|
||||
len(results['qsd']['from']):
|
||||
# Extract the sending account's information
|
||||
results['email'] = \
|
||||
NotifyOffice365.unquote(results['qsd']['from'])
|
||||
|
||||
# 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
|
||||
# The 'to' makes it easier to use yaml configuration
|
||||
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
||||
results['targets'] += \
|
||||
NotifyOffice365.parse_list(results['qsd']['to'])
|
||||
|
||||
return results
|
@ -108,9 +108,10 @@ GET_EMAIL_RE = re.compile(
|
||||
r"(?P<fulluser>((?P<label>[^+]+)\+)?"
|
||||
r"(?P<userid>[a-z0-9$%=_~-]+"
|
||||
r"(?:\.[a-z0-9$%+=_~-]+)"
|
||||
r"*))@(?P<domain>(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
|
||||
r"[a-z0-9](?:[a-z0-9-]*"
|
||||
r"[a-z0-9]))?",
|
||||
r"*))@(?P<domain>("
|
||||
r"(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+"
|
||||
r"[a-z0-9](?:[a-z0-9-]*[a-z0-9]))|"
|
||||
r"[a-z0-9][a-z0-9-]{5,})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
@ -48,9 +48,9 @@ notification services that are out there. Apprise opens the door and makes
|
||||
it easy to access:
|
||||
|
||||
Boxcar, ClickSend, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl,
|
||||
IFTTT, Join, Kavenegar, KODI, Kumulos, Mailgun, MatterMost, Matrix, Microsoft
|
||||
Windows Notifications, Microsoft Teams, MessageBird, MSG91, Nexmo, Nextcloud,
|
||||
Notica, Notifico, Notify MacOSX, MyAndroid, Prowl, Pushalot, PushBullet,
|
||||
IFTTT, Join, Kavenegar, KODI, Kumulos, MacOSX, Mailgun, MatterMost, Matrix,
|
||||
Microsoft Windows, Microsoft Teams, MessageBird, MSG91, MyAndroid, Nexmo,
|
||||
Nextcloud, Notica, Notifico, Office365, Prowl, Pushalot, PushBullet,
|
||||
Pushjet, Pushover, PushSafer, Rocket.Chat, SendGrid, SimplePush, Sinch, Slack,
|
||||
Super Toasty, Stride, Syslog, Techulus Push, Telegram, Twilio, Twitter, Twist,
|
||||
XBMC, XMPP, Webex Teams}
|
||||
|
8
setup.py
8
setup.py
@ -72,10 +72,10 @@ setup(
|
||||
keywords='Push Notifications Alerts Email AWS SNS Boxcar ClickSend '
|
||||
'Discord Dbus Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join '
|
||||
'Kavenegar KODI Kumulos MacOS Mailgun Matrix Mattermost MessageBird '
|
||||
'MSG91 Nexmo Nextcloud Notica, Notifico Prowl PushBullet Pushjet '
|
||||
'Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid SimplePush '
|
||||
'Sinch Slack Stride Syslog Techulus Push Telegram Twilio Twist '
|
||||
'Twitter XBMC Microsoft MSTeams Windows Webex CLI API',
|
||||
'MSG91 Nexmo Nextcloud Notica Notifico Office365 Prowl PushBullet '
|
||||
'Pushjet Pushed Pushover PushSafer Rocket.Chat Ryver SendGrid '
|
||||
'SimplePush Sinch Slack Stride Syslog Techulus Push Telegram Twilio '
|
||||
'Twist Twitter XBMC Microsoft MSTeams Windows Webex CLI API',
|
||||
author='Chris Caron',
|
||||
author_email='lead2gold@gmail.com',
|
||||
packages=find_packages(),
|
||||
|
226
test/test_office365.py
Normal file
226
test/test_office365.py
Normal file
@ -0,0 +1,226 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# All rights reserved.
|
||||
#
|
||||
# This code is licensed under the MIT License.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files(the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions :
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
# THE SOFTWARE.
|
||||
|
||||
import os
|
||||
import mock
|
||||
import pytest
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from json import dumps
|
||||
from apprise import Apprise
|
||||
from apprise import plugins
|
||||
|
||||
# Disable logging for a cleaner testing output
|
||||
import logging
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
# Attachment Directory
|
||||
TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
|
||||
|
||||
|
||||
@mock.patch('requests.post')
|
||||
def test_office365_general(mock_post):
|
||||
"""
|
||||
API: NotifyOffice365 Testing
|
||||
|
||||
"""
|
||||
# Disable Throttling to speed testing
|
||||
plugins.NotifyBase.request_rate_per_sec = 0
|
||||
|
||||
# Initialize some generic (but valid) tokens
|
||||
email = '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
|
||||
authentication = {
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 6000,
|
||||
"access_token": "abcd1234"
|
||||
}
|
||||
response = mock.Mock()
|
||||
response.content = dumps(authentication)
|
||||
response.status_code = requests.codes.ok
|
||||
mock_post.return_value = response
|
||||
|
||||
# Instantiate our object
|
||||
obj = Apprise.instantiate(
|
||||
'o365://{tenant}:{email}/{tenant}/{secret}/{targets}'.format(
|
||||
tenant=tenant,
|
||||
client_id=client_id,
|
||||
email=email,
|
||||
secret=secret,
|
||||
targets=targets))
|
||||
|
||||
assert isinstance(obj, plugins.NotifyOffice365)
|
||||
|
||||
# Test our notification
|
||||
assert obj.notify(title='title', body='test') is True
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
# No secret
|
||||
plugins.NotifyOffice365(
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
secret=None,
|
||||
targets=None,
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
# Invalid email
|
||||
plugins.NotifyOffice365(
|
||||
email=None,
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
secret=secret,
|
||||
targets=None,
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
# Invalid email
|
||||
plugins.NotifyOffice365(
|
||||
email='garbage',
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
secret=secret,
|
||||
targets=None,
|
||||
)
|
||||
|
||||
# One of the targets are invalid
|
||||
plugins.NotifyOffice365(
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
secret=secret,
|
||||
targets=('abc@gmail.com', 'garbage'),
|
||||
)
|
||||
|
||||
# all of the targets are invalid
|
||||
assert plugins.NotifyOffice365(
|
||||
email=email,
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
secret=secret,
|
||||
targets=('invalid', 'garbage'),
|
||||
).notify(body="test") is False
|
||||
|
||||
|
||||
@mock.patch('requests.post')
|
||||
def test_office365_authentication(mock_post):
|
||||
"""
|
||||
API: NotifyOffice365 Authentication Testing
|
||||
|
||||
"""
|
||||
# Disable Throttling to speed testing
|
||||
plugins.NotifyBase.request_rate_per_sec = 0
|
||||
|
||||
# Initialize some generic (but valid) tokens
|
||||
tenant = 'ff-gg-hh-ii-jj'
|
||||
email = 'user@example.net'
|
||||
client_id = 'aa-bb-cc-dd-ee'
|
||||
secret = 'abcd/1234/abcd@ajd@/test'
|
||||
targets = 'target@example.com'
|
||||
|
||||
# Prepare Mock return object
|
||||
authentication_okay = {
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 6000,
|
||||
"access_token": "abcd1234"
|
||||
}
|
||||
authentication_failure = {
|
||||
"error": "invalid_scope",
|
||||
"error_description": "AADSTS70011: Blah... Blah Blah... Blah",
|
||||
"error_codes": [70011],
|
||||
"timestamp": "2020-01-09 02:02:12Z",
|
||||
"trace_id": "255d1aef-8c98-452f-ac51-23d051240864",
|
||||
"correlation_id": "fb3d2015-bc17-4bb9-bb85-30c5cf1aaaa7",
|
||||
}
|
||||
response = mock.Mock()
|
||||
response.content = dumps(authentication_okay)
|
||||
response.status_code = requests.codes.ok
|
||||
mock_post.return_value = response
|
||||
|
||||
# Instantiate our object
|
||||
obj = Apprise.instantiate(
|
||||
'o365://{tenant}:{email}/{client_id}/{secret}/{targets}'.format(
|
||||
client_id=client_id,
|
||||
tenant=tenant,
|
||||
email=email,
|
||||
secret=secret,
|
||||
targets=targets))
|
||||
|
||||
assert isinstance(obj, plugins.NotifyOffice365)
|
||||
|
||||
# Authenticate
|
||||
assert obj.authenticate() is True
|
||||
|
||||
# We're already authenticated
|
||||
assert obj.authenticate() is True
|
||||
|
||||
# Expire our token
|
||||
obj.token_expiry = datetime.now()
|
||||
|
||||
# Re-authentiate
|
||||
assert obj.authenticate() is True
|
||||
|
||||
# Change our response
|
||||
response.status_code = 400
|
||||
|
||||
# We'll fail to send a notification now...
|
||||
assert obj.notify(title='title', body='test') is False
|
||||
|
||||
# Expire our token
|
||||
obj.token_expiry = datetime.now()
|
||||
|
||||
# Set a failure response
|
||||
response.content = dumps(authentication_failure)
|
||||
|
||||
# We will fail to authenticate at this point
|
||||
assert obj.authenticate() is False
|
||||
|
||||
# Notifications will also fail in this case
|
||||
assert obj.notify(title='title', body='test') is False
|
||||
|
||||
# We will fail to authenticate with invalid data
|
||||
|
||||
invalid_auth_entries = authentication_okay.copy()
|
||||
invalid_auth_entries['expires_in'] = 'garbage'
|
||||
response.content = dumps(invalid_auth_entries)
|
||||
response.status_code = requests.codes.ok
|
||||
assert obj.authenticate() is False
|
||||
|
||||
invalid_auth_entries['expires_in'] = None
|
||||
response.content = dumps(invalid_auth_entries)
|
||||
assert obj.authenticate() is False
|
||||
|
||||
invalid_auth_entries['expires_in'] = ''
|
||||
response.content = dumps(invalid_auth_entries)
|
||||
assert obj.authenticate() is False
|
||||
|
||||
del invalid_auth_entries['expires_in']
|
||||
response.content = dumps(invalid_auth_entries)
|
||||
assert obj.authenticate() is False
|
@ -1229,63 +1229,70 @@ TEST_URLS = (
|
||||
'instance': None,
|
||||
}),
|
||||
# No Token specified
|
||||
('mailgun://user@host', {
|
||||
('mailgun://user@localhost.localdomain', {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# Token is valid, but no user name specified
|
||||
('mailgun://host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
('mailgun://localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# Invalid from email address
|
||||
('mailgun://!@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
('mailgun://!@localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# No To email address, but everything else is valid
|
||||
('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
# valid url with region specified (case insensitve)
|
||||
('mailgun://user@host/{}-{}-{}?region=uS'.format(
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}?region=uS'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
# valid url with region specified (case insensitve)
|
||||
('mailgun://user@host/{}-{}-{}?region=EU'.format(
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}?region=EU'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
# invalid url with region specified (case insensitve)
|
||||
('mailgun://user@host/{}-{}-{}?region=invalid'.format(
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}?region=invalid'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': TypeError,
|
||||
}),
|
||||
# One To Email address
|
||||
('mailgun://user@host/{}-{}-{}/test@example.com'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
('mailgun://user@host/{}-{}-{}?to=test@example.com'.format(
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}/test@example.com'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
('mailgun://user@localhost.localdomain/'
|
||||
'{}-{}-{}?to=test@example.com'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun}),
|
||||
|
||||
# One To Email address, a from name specified too
|
||||
('mailgun://user@host/{}-{}-{}/test@example.com?name="Frodo"'.format(
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}/'
|
||||
'test@example.com?name="Frodo"'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun}),
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
}),
|
||||
('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
# force a failure
|
||||
'response': False,
|
||||
'requests_response_code': requests.codes.internal_server_error,
|
||||
}),
|
||||
('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
# throw a bizzare code forcing us to fail to look it up
|
||||
'response': False,
|
||||
'requests_response_code': 999,
|
||||
}),
|
||||
('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), {
|
||||
('mailgun://user@localhost.localdomain/{}-{}-{}'.format(
|
||||
'a' * 32, 'b' * 8, 'c' * 8), {
|
||||
'instance': plugins.NotifyMailgun,
|
||||
# Throws a series of connection and transfer exceptions when this flag
|
||||
# is set and tests that we gracfully handle them
|
||||
@ -1894,6 +1901,133 @@ TEST_URLS = (
|
||||
'test_requests_exceptions': True,
|
||||
}),
|
||||
|
||||
##################################
|
||||
# NotifyOffice365
|
||||
##################################
|
||||
('o365://', {
|
||||
# Missing tenant, client_id, secret, and targets!
|
||||
'instance': TypeError,
|
||||
}),
|
||||
('o365://:@/', {
|
||||
# invalid url
|
||||
'instance': TypeError,
|
||||
}),
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
# invalid tenant
|
||||
tenant=',',
|
||||
cid='ab-cd-ef-gh',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
|
||||
# We're valid and good to go
|
||||
'instance': TypeError,
|
||||
}),
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
tenant='tenant',
|
||||
# invalid client id
|
||||
cid='ab.',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
|
||||
# We're valid and good to go
|
||||
'instance': TypeError,
|
||||
}),
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
tenant='tenant',
|
||||
cid='ab-cd-ef-gh',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
|
||||
# We're valid and good to go
|
||||
'instance': plugins.NotifyOffice365,
|
||||
|
||||
# Test what happens if a batch send fails to return a messageCount
|
||||
'requests_response_text': {
|
||||
'expires_in': 2000,
|
||||
'access_token': 'abcd1234',
|
||||
},
|
||||
|
||||
# Our expected url(privacy=True) startswith() response:
|
||||
'privacy_url': 'o365://t...t:user@example.com/a...h/' \
|
||||
'****/email1%40test.ca/'}),
|
||||
# test our arguments
|
||||
('o365://_/?oauth_id={cid}&oauth_secret={secret}&tenant={tenant}'
|
||||
'&to={targets}&from={aid}'.format(
|
||||
tenant='tenant',
|
||||
cid='ab-cd-ef-gh',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test',
|
||||
targets='email1@test.ca'),
|
||||
{
|
||||
# We're valid and good to go
|
||||
'instance': plugins.NotifyOffice365,
|
||||
|
||||
# Test what happens if a batch send fails to return a messageCount
|
||||
'requests_response_text': {
|
||||
'expires_in': 2000,
|
||||
'access_token': 'abcd1234',
|
||||
},
|
||||
|
||||
# Our expected url(privacy=True) startswith() response:
|
||||
'privacy_url': 'o365://t...t:user@example.com/a...h/' \
|
||||
'****/email1%40test.ca/'}),
|
||||
# Test invalid JSON (no tenant defaults to email domain)
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
tenant='tenant',
|
||||
cid='ab-cd-ef-gh',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
|
||||
# We're valid and good to go
|
||||
'instance': plugins.NotifyOffice365,
|
||||
|
||||
# invalid JSON response
|
||||
'requests_response_text': '{',
|
||||
'notify_response': False,
|
||||
}),
|
||||
# No Targets specified
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}'.format(
|
||||
tenant='tenant',
|
||||
cid='ab-cd-ef-gh',
|
||||
aid='user@example.com',
|
||||
secret='abcd/123/3343/@jack/test'), {
|
||||
|
||||
# We're valid and good to go
|
||||
'instance': plugins.NotifyOffice365,
|
||||
|
||||
# There were no targets to notify; so we use our own email
|
||||
'requests_response_text': {
|
||||
'expires_in': 2000,
|
||||
'access_token': 'abcd1234',
|
||||
},
|
||||
}),
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
tenant='tenant',
|
||||
cid='zz-zz-zz-zz',
|
||||
aid='user@example.com',
|
||||
secret='abcd/abc/dcba/@john/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
'instance': plugins.NotifyOffice365,
|
||||
# throw a bizzare code forcing us to fail to look it up
|
||||
'response': False,
|
||||
'requests_response_code': 999,
|
||||
}),
|
||||
('o365://{tenant}:{aid}/{cid}/{secret}/{targets}'.format(
|
||||
tenant='tenant',
|
||||
cid='01-12-23-34',
|
||||
aid='user@example.com',
|
||||
secret='abcd/321/4321/@test/test',
|
||||
targets='/'.join(['email1@test.ca'])), {
|
||||
'instance': plugins.NotifyOffice365,
|
||||
# Throws a series of connection and transfer exceptions when this flag
|
||||
# is set and tests that we gracfully handle them
|
||||
'test_requests_exceptions': True,
|
||||
}),
|
||||
|
||||
##################################
|
||||
# NotifyProwl
|
||||
##################################
|
||||
@ -3145,7 +3279,7 @@ TEST_URLS = (
|
||||
'message': '',
|
||||
},
|
||||
}),
|
||||
('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/' \
|
||||
('slack://username@T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/'
|
||||
'?to=#nuxref', {
|
||||
'instance': plugins.NotifySlack,
|
||||
|
||||
@ -3284,7 +3418,7 @@ TEST_URLS = (
|
||||
# Our expected url(privacy=True) startswith() response:
|
||||
'privacy_url': 'sns://T...D/****/us-west-2',
|
||||
}),
|
||||
('sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/us-east-1' \
|
||||
('sns://T1JJ3T3L2/A1BRTD4JD/TIiajkdnlazkcOXrIdevi7FQ/us-east-1'
|
||||
'?to=12223334444', {
|
||||
# Missing a topic and/or phone No
|
||||
'instance': plugins.NotifySNS,
|
||||
|
Loading…
Reference in New Issue
Block a user