diff --git a/apprise/attachment/base.py b/apprise/attachment/base.py index 11b7f8c4..a0f74f9a 100644 --- a/apprise/attachment/base.py +++ b/apprise/attachment/base.py @@ -291,7 +291,7 @@ class AttachBase(URLBase): return False if not retrieve_if_missing else self.download() - def base64(self, encoding='utf-8'): + def base64(self, encoding='ascii'): """ Returns the attachment object as a base64 string otherwise None is returned if an error occurs. @@ -306,7 +306,7 @@ class AttachBase(URLBase): raise exception.AppriseFileNotFound("Attachment Missing") try: - with open(self.path, 'rb') as f: + with self.open() as f: # Prepare our Attachment in Base64 return base64.b64encode(f.read()).decode(encoding) \ if encoding else base64.b64encode(f.read()) diff --git a/apprise/attachment/memory.py b/apprise/attachment/memory.py index 94645f26..c7d5dca2 100644 --- a/apprise/attachment/memory.py +++ b/apprise/attachment/memory.py @@ -29,7 +29,9 @@ import re import os import io +import base64 from .base import AttachBase +from .. import exception from ..common import ContentLocation from ..locale import gettext_lazy as _ import uuid @@ -145,6 +147,23 @@ class AttachMemory(AttachBase): return True + def base64(self, encoding='ascii'): + """ + We need to over-ride this since the base64 sub-library seems to close + our file descriptor making it no longer referencable. + """ + + 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") + self._data.seek(0, 0) + + return base64.b64encode(self._data.read()).decode(encoding) \ + if encoding else base64.b64encode(self._data.read()) + def invalidate(self): """ Removes data diff --git a/apprise/plugins/apprise_api.py b/apprise/plugins/apprise_api.py index fd71236b..eda41b6c 100644 --- a/apprise/plugins/apprise_api.py +++ b/apprise/plugins/apprise_api.py @@ -29,8 +29,8 @@ import re import requests from json import dumps -import base64 +from .. import exception from .base import NotifyBase from ..url import PrivacyMode from ..common import NotifyType @@ -261,21 +261,20 @@ class NotifyAppriseAPI(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Apprise API attachment {}.'.format( attachment.url(privacy=True))) return False try: if self.method == AppriseAPIMethod.JSON: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'base64': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + "filename": attachment.name + if attachment.name else f'file{no:03}.dat', + 'base64': attachment.base64(), + 'mimetype': attachment.mimetype, + }) else: # AppriseAPIMethod.FORM files.append(( @@ -287,13 +286,17 @@ class NotifyAppriseAPI(NotifyBase): ) )) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except (TypeError, OSError, exception.AppriseException): + # We could not access the attachment + self.logger.error( + 'Could not access AppriseAPI attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending AppriseAPI attachment {}'.format( + attachment.url(privacy=True))) + # prepare Apprise API Object payload = { # Apprise API Payload diff --git a/apprise/plugins/custom_json.py b/apprise/plugins/custom_json.py index 25b4467d..84ad2c21 100644 --- a/apprise/plugins/custom_json.py +++ b/apprise/plugins/custom_json.py @@ -27,9 +27,9 @@ # POSSIBILITY OF SUCH DAMAGE. import requests -import base64 from json import dumps +from .. import exception from .base import NotifyBase from ..url import PrivacyMode from ..common import NotifyImageSize @@ -213,33 +213,34 @@ class NotifyJSON(NotifyBase): # Track our potential attachments attachments = [] if attach and self.attachment_support: - for attachment in attach: + for no, attachment in enumerate(attach): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Custom JSON attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'base64': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) + attachments.append({ + "filename": attachment.name + if attachment.name else f'file{no:03}.dat', + 'base64': attachment.base64(), + 'mimetype': attachment.mimetype, + }) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access Custom JSON attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending Custom JSON attachment {}'.format( + attachment.url(privacy=True))) + # Prepare JSON Object payload = { JSONPayloadField.VERSION: self.json_version, diff --git a/apprise/plugins/custom_xml.py b/apprise/plugins/custom_xml.py index f72e9a1a..8bfff3ec 100644 --- a/apprise/plugins/custom_xml.py +++ b/apprise/plugins/custom_xml.py @@ -28,8 +28,8 @@ import re import requests -import base64 +from .. import exception from .base import NotifyBase from ..url import PrivacyMode from ..common import NotifyImageSize @@ -287,35 +287,39 @@ class NotifyXML(NotifyBase): attachments = [] if attach and self.attachment_support: - for attachment in attach: + 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 attachment {}.'.format( + 'Could not access Custom XML attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Prepare our Attachment in Base64 - entry = \ - ''.format( - NotifyXML.escape_html( - attachment.name, whitespace=False), - NotifyXML.escape_html( - attachment.mimetype, whitespace=False)) - entry += base64.b64encode(f.read()).decode('utf-8') - entry += '' - attachments.append(entry) + # Prepare our Attachment in Base64 + entry = \ + ''.format( + NotifyXML.escape_html( + attachment.name if attachment.name + else f'file{no:03}.dat', whitespace=False), + NotifyXML.escape_html( + attachment.mimetype, whitespace=False)) + entry += attachment.base64() + entry += '' + attachments.append(entry) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access Custom XML attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending Custom XML attachment {}'.format( + attachment.url(privacy=True))) + # Update our xml_attachments record: xml_attachments = \ '' + \ diff --git a/apprise/plugins/pushsafer.py b/apprise/plugins/pushsafer.py index 7d4052c0..48ae37d3 100644 --- a/apprise/plugins/pushsafer.py +++ b/apprise/plugins/pushsafer.py @@ -26,11 +26,11 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import base64 import requests from json import loads from .base import NotifyBase +from .. import exception from ..common import NotifyType from ..utils import parse_list from ..utils import validate_regex @@ -553,7 +553,7 @@ class NotifyPushSafer(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access PushSafer attachment {}.'.format( attachment.url(privacy=True))) return False @@ -569,24 +569,26 @@ class NotifyPushSafer(NotifyBase): attachment.url(privacy=True))) try: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachment = ( - attachment.name, - 'data:{};base64,{}'.format( - attachment.mimetype, - base64.b64encode(f.read()))) + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append(( + attachment.name, + 'data:{};base64,{}'.format( + attachment.mimetype, + attachment.base64, + ) + )) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access PushSafer attachment {}.'.format( + attachment.url(privacy=True))) return False - # Save our pre-prepared payload for attachment posting - attachments.append(attachment) + self.logger.debug( + 'Appending PushSafer attachment {}'.format( + attachment.url(privacy=True))) # Create a copy of the targets list targets = list(self.targets) diff --git a/apprise/plugins/sendgrid.py b/apprise/plugins/sendgrid.py index 627815c7..b4f92e9f 100644 --- a/apprise/plugins/sendgrid.py +++ b/apprise/plugins/sendgrid.py @@ -342,11 +342,19 @@ class NotifySendGrid(NotifyBase): # Send our attachments 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 SendGrid attachment {}.'.format( + attachment.url(privacy=True))) + return False + try: attachments.append({ "content": attachment.base64(), "filename": attachment.name - if attachment.name else f'attach{no:03}.dat', + if attachment.name else f'file{no:03}.dat', "type": "application/octet-stream", "disposition": "attachment" }) @@ -354,7 +362,7 @@ class NotifySendGrid(NotifyBase): except exception.AppriseException: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access SendGrid attachment {}.'.format( attachment.url(privacy=True))) return False diff --git a/apprise/plugins/signal_api.py b/apprise/plugins/signal_api.py index 5795e0cf..9a019123 100644 --- a/apprise/plugins/signal_api.py +++ b/apprise/plugins/signal_api.py @@ -29,10 +29,10 @@ import re import requests from json import dumps -import base64 from .base import NotifyBase from ..common import NotifyType +from .. import exception from ..utils import is_phone_no from ..utils import parse_phone_no from ..utils import parse_bool @@ -239,23 +239,24 @@ class NotifySignalAPI(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Signal API attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Prepare our Attachment in Base64 - attachments.append( - base64.b64encode(f.read()).decode('utf-8')) + attachments.append(attachment.base64()) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access Signal API attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending Signal API attachment {}'.format( + attachment.url(privacy=True))) + # Prepare our headers headers = { 'User-Agent': self.app_id, diff --git a/apprise/plugins/smseagle.py b/apprise/plugins/smseagle.py index 81308345..15943310 100644 --- a/apprise/plugins/smseagle.py +++ b/apprise/plugins/smseagle.py @@ -29,11 +29,11 @@ import re import requests from json import dumps, loads -import base64 from itertools import chain from .base import NotifyBase from ..common import NotifyType +from .. import exception from ..utils import validate_regex from ..utils import is_phone_no from ..utils import parse_phone_no @@ -345,7 +345,7 @@ class NotifySMSEagle(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access SMSEagle attachment {}.'.format( attachment.url(privacy=True))) return False @@ -357,21 +357,23 @@ class NotifySMSEagle(NotifyBase): continue try: - with open(attachment.path, 'rb') as f: - # Prepare our Attachment in Base64 - attachments.append({ - 'content_type': attachment.mimetype, - 'content': base64.b64encode( - f.read()).decode('utf-8'), - }) + # Prepare our Attachment in Base64 + attachments.append({ + 'content_type': attachment.mimetype, + 'content': attachment.base64(), + }) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access SMSEagle attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending SMSEagle attachment {}'.format( + attachment.url(privacy=True))) + # Prepare our headers headers = { 'User-Agent': self.app_id, diff --git a/apprise/plugins/smtp2go.py b/apprise/plugins/smtp2go.py index cb8c71ff..2781baa4 100644 --- a/apprise/plugins/smtp2go.py +++ b/apprise/plugins/smtp2go.py @@ -45,11 +45,11 @@ # the email will be transmitted from. If no email address is specified # then it will also become the 'to' address as well. # -import base64 import requests from json import dumps from email.utils import formataddr from .base import NotifyBase +from .. import exception from ..common import NotifyType from ..common import NotifyFormat from ..utils import parse_emails @@ -299,28 +299,29 @@ class NotifySMTP2Go(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access SMTP2Go attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'fileblob': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) + # Format our attachment + attachments.append({ + 'filename': attachment.name, + 'fileblob': attachment.base64(), + 'mimetype': attachment.mimetype, + }) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access SMTP2Go attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending SMTP2Go attachment {}'.format( + attachment.url(privacy=True))) + sender = formataddr( (self.from_name if self.from_name else False, self.from_addr), charset='utf-8') diff --git a/apprise/plugins/sparkpost.py b/apprise/plugins/sparkpost.py index b1fb7bca..13e081cf 100644 --- a/apprise/plugins/sparkpost.py +++ b/apprise/plugins/sparkpost.py @@ -55,10 +55,10 @@ # API Documentation: https://developers.sparkpost.com/api/ # Specifically: https://developers.sparkpost.com/api/transmissions/ import requests -import base64 from json import loads from json import dumps from .base import NotifyBase +from .. import exception from ..common import NotifyType from ..common import NotifyFormat from ..utils import is_email @@ -500,7 +500,7 @@ class NotifySparkPost(NotifyBase): if not self.targets: # There is no one to email; we're done self.logger.warning( - 'There are no Email recipients to notify') + 'There are no SparkPost Email recipients to notify') return False # Initialize our has_error flag @@ -551,30 +551,29 @@ class NotifySparkPost(NotifyBase): if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access SparkPost attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + # Prepare API Upload Payload + payload['content']['attachments'].append({ + 'name': attachment.name, + 'type': attachment.mimetype, + 'data': attachment.base64(), + }) + + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access SparkPost attachment {}.'.format( attachment.url(privacy=True))) return False self.logger.debug( - 'Preparing SparkPost attachment {}'.format( + 'Appending SparkPost attachment {}'.format( attachment.url(privacy=True))) - try: - with open(attachment.path, 'rb') as fp: - # Prepare API Upload Payload - payload['content']['attachments'].append({ - 'name': attachment.name, - 'type': attachment.mimetype, - 'data': base64.b64encode(fp.read()).decode("ascii") - }) - - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) - return False - # Take a copy of our token dictionary tokens = self.tokens.copy() diff --git a/test/test_attach_http.py b/test/test_attach_http.py index 36ecbad5..6caf8d62 100644 --- a/test/test_attach_http.py +++ b/test/test_attach_http.py @@ -29,6 +29,8 @@ import re from unittest import mock +import pytest +from apprise import exception import requests import mimetypes from os.path import join @@ -481,3 +483,33 @@ def test_attach_http(mock_get, mock_post): assert mock_post.call_count == 30 # We only fetched once and re-used the same fetch for all posts assert mock_get.call_count == 1 + + # + # We will test our base64 handling now + # + mock_get.reset_mock() + mock_post.reset_mock() + + AttachHTTP.max_file_size = getsize(path) + # Set ourselves a Content-Disposition (providing a filename) + dummy_response.headers['Content-Disposition'] = \ + 'attachment; filename="myimage.gif"' + results = AttachHTTP.parse_url('http://user@localhost/filename.gif') + assert isinstance(results, dict) + obj = AttachHTTP(**results) + + # now test our base64 output + assert isinstance(obj.base64(), str) + # No encoding if we choose + assert isinstance(obj.base64(encoding=None), bytes) + + # Error cases: + 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): + obj.base64() + + mock_file.side_effect = OSError + with pytest.raises(exception.AppriseDiskIOError): + obj.base64() diff --git a/test/test_attach_memory.py b/test/test_attach_memory.py index a4bec417..d5f4c52d 100644 --- a/test/test_attach_memory.py +++ b/test/test_attach_memory.py @@ -30,6 +30,7 @@ import re import urllib import pytest +from apprise import exception from apprise.attachment.base import AttachBase from apprise.attachment.memory import AttachMemory from apprise import AppriseAttachment @@ -203,3 +204,12 @@ def test_attach_memory(): # Test hosted configuration and that we can't add a valid memory file aa = AppriseAttachment(location=ContentLocation.HOSTED) assert aa.add(response) is False + + # now test our base64 output + assert isinstance(response.base64(), str) + # No encoding if we choose + assert isinstance(response.base64(encoding=None), bytes) + + response.invalidate() + with pytest.raises(exception.AppriseFileNotFound): + response.base64()