diff --git a/apprise/attachment/base.py b/apprise/attachment/base.py index 6ae9d3aa..11b7f8c4 100644 --- a/apprise/attachment/base.py +++ b/apprise/attachment/base.py @@ -29,6 +29,8 @@ import os import time import mimetypes +import base64 +from .. import exception from ..url import URLBase from ..utils import parse_bool from ..common import ContentLocation @@ -289,6 +291,37 @@ class AttachBase(URLBase): 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): """ Release any temporary data that may be open by child classes. diff --git a/apprise/plugins/sendgrid.py b/apprise/plugins/sendgrid.py index 56a99a57..627815c7 100644 --- a/apprise/plugins/sendgrid.py +++ b/apprise/plugins/sendgrid.py @@ -50,6 +50,7 @@ import requests from json import dumps from .base import NotifyBase +from .. import exception from ..common import NotifyFormat from ..common import NotifyType from ..utils import parse_list @@ -57,6 +58,7 @@ from ..utils import is_email from ..utils import validate_regex from ..locale import gettext_lazy as _ + # Extend HTTP Error Messages SENDGRID_HTTP_ERROR_MAP = { 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 notify_url = 'https://api.sendgrid.com/v3/mail/send' + # Support attachments + attachment_support = True + # Allow 300 requests per minute. # 60/300 = 0.2 request_rate_per_sec = 0.2 @@ -297,7 +302,8 @@ class NotifySendGrid(NotifyBase): """ 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 """ @@ -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: _payload['template_id'] = self.template diff --git a/test/test_attach_file.py b/test/test_attach_file.py index d0734832..8c369784 100644 --- a/test/test_attach_file.py +++ b/test/test_attach_file.py @@ -29,6 +29,7 @@ import re import time import urllib +import pytest from unittest import mock from os.path import dirname @@ -37,6 +38,7 @@ from apprise.attachment.base import AttachBase from apprise.attachment.file import AttachFile from apprise import AppriseAttachment from apprise.common import ContentLocation +from apprise import exception # Disable logging for a cleaner testing output import logging @@ -210,3 +212,37 @@ def test_attach_file(): # Test hosted configuration and that we can't add a valid file aa = AppriseAttachment(location=ContentLocation.HOSTED) 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() diff --git a/test/test_plugin_sendgrid.py b/test/test_plugin_sendgrid.py index 328e8a00..4c775ca0 100644 --- a/test/test_plugin_sendgrid.py +++ b/test/test_plugin_sendgrid.py @@ -28,9 +28,13 @@ from unittest import mock +import os import pytest import requests +from apprise import Apprise +from apprise import NotifyType +from apprise import AppriseAttachment from apprise.plugins.sendgrid import NotifySendGrid from helpers import AppriseURLTester @@ -38,6 +42,9 @@ from helpers import AppriseURLTester import logging logging.disable(logging.CRITICAL) +# Attachment Directory +TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') + # a test UUID we can use 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', bcc=('abc@def.com', '!invalid'), 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