mirror of https://github.com/caronc/apprise
SendGrid Attachment Support Added (#1190)
parent
3cb270cee8
commit
ca50cb7820
|
@ -29,6 +29,8 @@
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import base64
|
||||||
|
from .. import exception
|
||||||
from ..url import URLBase
|
from ..url import URLBase
|
||||||
from ..utils import parse_bool
|
from ..utils import parse_bool
|
||||||
from ..common import ContentLocation
|
from ..common import ContentLocation
|
||||||
|
@ -289,6 +291,37 @@ class AttachBase(URLBase):
|
||||||
|
|
||||||
return False if not retrieve_if_missing else self.download()
|
return False if not retrieve_if_missing else self.download()
|
||||||
|
|
||||||
|
def base64(self, encoding='utf-8'):
|
||||||
|
"""
|
||||||
|
Returns the attachment object as a base64 string otherwise
|
||||||
|
None is returned if an error occurs.
|
||||||
|
|
||||||
|
If encoding is set to None, then it is not encoded when returned
|
||||||
|
"""
|
||||||
|
if not self:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access attachment {}.'.format(
|
||||||
|
self.url(privacy=True)))
|
||||||
|
raise exception.AppriseFileNotFound("Attachment Missing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.path, 'rb') as f:
|
||||||
|
# Prepare our Attachment in Base64
|
||||||
|
return base64.b64encode(f.read()).decode(encoding) \
|
||||||
|
if encoding else base64.b64encode(f.read())
|
||||||
|
|
||||||
|
except (TypeError, FileNotFoundError):
|
||||||
|
# We no longer have a path to open
|
||||||
|
raise exception.AppriseFileNotFound("Attachment Missing")
|
||||||
|
|
||||||
|
except (TypeError, OSError, IOError) as e:
|
||||||
|
self.logger.warning(
|
||||||
|
'An I/O error occurred while reading {}.'.format(
|
||||||
|
self.name if self else 'attachment'))
|
||||||
|
self.logger.debug('I/O Exception: %s' % str(e))
|
||||||
|
raise exception.AppriseDiskIOError("Attachment Access Error")
|
||||||
|
|
||||||
def invalidate(self):
|
def invalidate(self):
|
||||||
"""
|
"""
|
||||||
Release any temporary data that may be open by child classes.
|
Release any temporary data that may be open by child classes.
|
||||||
|
|
|
@ -50,6 +50,7 @@ import requests
|
||||||
from json import dumps
|
from json import dumps
|
||||||
|
|
||||||
from .base import NotifyBase
|
from .base import NotifyBase
|
||||||
|
from .. import exception
|
||||||
from ..common import NotifyFormat
|
from ..common import NotifyFormat
|
||||||
from ..common import NotifyType
|
from ..common import NotifyType
|
||||||
from ..utils import parse_list
|
from ..utils import parse_list
|
||||||
|
@ -57,6 +58,7 @@ from ..utils import is_email
|
||||||
from ..utils import validate_regex
|
from ..utils import validate_regex
|
||||||
from ..locale import gettext_lazy as _
|
from ..locale import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
# Extend HTTP Error Messages
|
# Extend HTTP Error Messages
|
||||||
SENDGRID_HTTP_ERROR_MAP = {
|
SENDGRID_HTTP_ERROR_MAP = {
|
||||||
401: 'Unauthorized - You do not have authorization to make the request.',
|
401: 'Unauthorized - You do not have authorization to make the request.',
|
||||||
|
@ -90,6 +92,9 @@ class NotifySendGrid(NotifyBase):
|
||||||
# The default Email API URL to use
|
# The default Email API URL to use
|
||||||
notify_url = 'https://api.sendgrid.com/v3/mail/send'
|
notify_url = 'https://api.sendgrid.com/v3/mail/send'
|
||||||
|
|
||||||
|
# Support attachments
|
||||||
|
attachment_support = True
|
||||||
|
|
||||||
# Allow 300 requests per minute.
|
# Allow 300 requests per minute.
|
||||||
# 60/300 = 0.2
|
# 60/300 = 0.2
|
||||||
request_rate_per_sec = 0.2
|
request_rate_per_sec = 0.2
|
||||||
|
@ -297,7 +302,8 @@ class NotifySendGrid(NotifyBase):
|
||||||
"""
|
"""
|
||||||
return len(self.targets)
|
return len(self.targets)
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||||
|
**kwargs):
|
||||||
"""
|
"""
|
||||||
Perform SendGrid Notification
|
Perform SendGrid Notification
|
||||||
"""
|
"""
|
||||||
|
@ -331,6 +337,36 @@ class NotifySendGrid(NotifyBase):
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if attach and self.attachment_support:
|
||||||
|
attachments = []
|
||||||
|
|
||||||
|
# Send our attachments
|
||||||
|
for no, attachment in enumerate(attach, start=1):
|
||||||
|
try:
|
||||||
|
attachments.append({
|
||||||
|
"content": attachment.base64(),
|
||||||
|
"filename": attachment.name
|
||||||
|
if attachment.name else f'attach{no:03}.dat',
|
||||||
|
"type": "application/octet-stream",
|
||||||
|
"disposition": "attachment"
|
||||||
|
})
|
||||||
|
|
||||||
|
except exception.AppriseException:
|
||||||
|
# We could not access the attachment
|
||||||
|
self.logger.error(
|
||||||
|
'Could not access attachment {}.'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
'Appending SendGrid attachment {}'.format(
|
||||||
|
attachment.url(privacy=True)))
|
||||||
|
|
||||||
|
# Append our attachments to the payload
|
||||||
|
_payload.update({
|
||||||
|
'attachments': attachments,
|
||||||
|
})
|
||||||
|
|
||||||
if self.template:
|
if self.template:
|
||||||
_payload['template_id'] = self.template
|
_payload['template_id'] = self.template
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import urllib
|
import urllib
|
||||||
|
import pytest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from os.path import dirname
|
from os.path import dirname
|
||||||
|
@ -37,6 +38,7 @@ from apprise.attachment.base import AttachBase
|
||||||
from apprise.attachment.file import AttachFile
|
from apprise.attachment.file import AttachFile
|
||||||
from apprise import AppriseAttachment
|
from apprise import AppriseAttachment
|
||||||
from apprise.common import ContentLocation
|
from apprise.common import ContentLocation
|
||||||
|
from apprise import exception
|
||||||
|
|
||||||
# Disable logging for a cleaner testing output
|
# Disable logging for a cleaner testing output
|
||||||
import logging
|
import logging
|
||||||
|
@ -210,3 +212,37 @@ def test_attach_file():
|
||||||
# Test hosted configuration and that we can't add a valid file
|
# Test hosted configuration and that we can't add a valid file
|
||||||
aa = AppriseAttachment(location=ContentLocation.HOSTED)
|
aa = AppriseAttachment(location=ContentLocation.HOSTED)
|
||||||
assert aa.add(path) is False
|
assert aa.add(path) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_attach_file_base64():
|
||||||
|
"""
|
||||||
|
API: AttachFile() with base64 encoding
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Simple gif test
|
||||||
|
path = join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
response = AppriseAttachment.instantiate(path)
|
||||||
|
assert isinstance(response, AttachFile)
|
||||||
|
assert response.name == 'apprise-test.gif'
|
||||||
|
assert response.mimetype == 'image/gif'
|
||||||
|
|
||||||
|
# now test our base64 output
|
||||||
|
assert isinstance(response.base64(), str)
|
||||||
|
# No encoding if we choose
|
||||||
|
assert isinstance(response.base64(encoding=None), bytes)
|
||||||
|
|
||||||
|
# Error cases:
|
||||||
|
with mock.patch('os.path.isfile', return_value=False):
|
||||||
|
with pytest.raises(exception.AppriseFileNotFound):
|
||||||
|
response.base64()
|
||||||
|
|
||||||
|
with mock.patch("builtins.open", new_callable=mock.mock_open,
|
||||||
|
read_data="mocked file content") as mock_file:
|
||||||
|
mock_file.side_effect = FileNotFoundError
|
||||||
|
with pytest.raises(exception.AppriseFileNotFound):
|
||||||
|
response.base64()
|
||||||
|
|
||||||
|
mock_file.side_effect = OSError
|
||||||
|
with pytest.raises(exception.AppriseDiskIOError):
|
||||||
|
response.base64()
|
||||||
|
|
|
@ -28,9 +28,13 @@
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from apprise import Apprise
|
||||||
|
from apprise import NotifyType
|
||||||
|
from apprise import AppriseAttachment
|
||||||
from apprise.plugins.sendgrid import NotifySendGrid
|
from apprise.plugins.sendgrid import NotifySendGrid
|
||||||
from helpers import AppriseURLTester
|
from helpers import AppriseURLTester
|
||||||
|
|
||||||
|
@ -38,6 +42,9 @@ from helpers import AppriseURLTester
|
||||||
import logging
|
import logging
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
# Attachment Directory
|
||||||
|
TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
|
||||||
|
|
||||||
# a test UUID we can use
|
# a test UUID we can use
|
||||||
UUID4 = '8b799edf-6f98-4d3a-9be7-2862fb4e5752'
|
UUID4 = '8b799edf-6f98-4d3a-9be7-2862fb4e5752'
|
||||||
|
|
||||||
|
@ -161,3 +168,36 @@ def test_plugin_sendgrid_edge_cases(mock_post, mock_get):
|
||||||
from_email='l2g@example.com',
|
from_email='l2g@example.com',
|
||||||
bcc=('abc@def.com', '!invalid'),
|
bcc=('abc@def.com', '!invalid'),
|
||||||
cc=('abc@test.org', '!invalid')), NotifySendGrid)
|
cc=('abc@test.org', '!invalid')), NotifySendGrid)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch('requests.get')
|
||||||
|
@mock.patch('requests.post')
|
||||||
|
def test_plugin_sendgrid_attachments(mock_post, mock_get):
|
||||||
|
"""
|
||||||
|
NotifySendGrid() Attachments
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
request = mock.Mock()
|
||||||
|
request.status_code = requests.codes.ok
|
||||||
|
|
||||||
|
# Prepare Mock
|
||||||
|
mock_post.return_value = request
|
||||||
|
mock_get.return_value = request
|
||||||
|
|
||||||
|
path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif')
|
||||||
|
attach = AppriseAttachment(path)
|
||||||
|
obj = Apprise.instantiate('sendgrid://abcd:user@example.com')
|
||||||
|
assert isinstance(obj, NotifySendGrid)
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is True
|
||||||
|
|
||||||
|
mock_post.reset_mock()
|
||||||
|
mock_get.reset_mock()
|
||||||
|
|
||||||
|
# Try again in a use case where we can't access the file
|
||||||
|
with mock.patch("builtins.open", side_effect=OSError):
|
||||||
|
assert obj.notify(
|
||||||
|
body='body', title='title', notify_type=NotifyType.INFO,
|
||||||
|
attach=attach) is False
|
||||||
|
|
Loading…
Reference in New Issue