Skip to content

Commit

Permalink
Support for PGP Email Support
Browse files Browse the repository at this point in the history
  • Loading branch information
caronc committed Sep 17, 2024
1 parent f35145e commit e8b2d42
Show file tree
Hide file tree
Showing 3 changed files with 340 additions and 14 deletions.
3 changes: 3 additions & 0 deletions all-plugin-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ gntp
# Provides mqtt:// support
# use v1.x due to https://github.com/eclipse/paho.mqtt.python/issues/814
paho-mqtt < 2.0.0

# Pretty Good Privacy (PGP) Provides mailto:// and deltachat:// support
PGPy
275 changes: 262 additions & 13 deletions apprise/plugins/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
# POSSIBILITY OF SUCH DAMAGE.

import dataclasses
import os
import re
import smtplib
import typing as t
Expand All @@ -36,19 +37,30 @@
from email.utils import formataddr, make_msgid
from email.header import Header
from email import charset
import hashlib

from socket import error as SocketError
from datetime import datetime
from datetime import timedelta
from datetime import timezone

from .base import NotifyBase
from ..url import PrivacyMode
from ..common import NotifyFormat, NotifyType
from ..common import NotifyFormat, NotifyType, PersistentStoreMode
from ..conversion import convert_between
from ..utils import is_ipaddr, is_email, parse_emails, is_hostname
from ..utils import is_ipaddr, is_email, parse_emails, is_hostname, parse_bool
from ..locale import gettext_lazy as _
from ..logger import logger

try:
import pgpy
# Pretty Good Privacy (PGP) Support enabled
PGP_SUPPORT = True

except ImportError:
# Pretty Good Privacy (PGP) Support disabled
PGP_SUPPORT = False

# Globally Default encoding mode set to Quoted Printable.
charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8')

Expand Down Expand Up @@ -439,6 +451,12 @@ class NotifyEmail(NotifyBase):
'type': 'string',
'map_to': 'smtp_host',
},
'pgp': {
'name': _('PGP Encryption'),
'type': 'bool',
'map_to': 'use_pgp',
'default': False,
},
'mode': {
'name': _('Secure Mode'),
'type': 'choice:string',
Expand All @@ -463,7 +481,7 @@ class NotifyEmail(NotifyBase):

def __init__(self, smtp_host=None, from_addr=None, secure_mode=None,
targets=None, cc=None, bcc=None, reply_to=None, headers=None,
**kwargs):
use_pgp=None, **kwargs):
"""
Initialize Email Object
Expand Down Expand Up @@ -500,6 +518,17 @@ def __init__(self, smtp_host=None, from_addr=None, secure_mode=None,
self.smtp_host = \
smtp_host if isinstance(smtp_host, str) else ''

# pgp hash
self.pgp_public_keys = {}

self.use_pgp = use_pgp if not None \
else self.template_args['pgp']['default']

if self.use_pgp and not PGP_SUPPORT:
self.logger.warning(

Check warning on line 528 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L528

Added line #L528 was not covered by tests
'PGP Support is not available on this installation; '
'ask admin to install PGPy')

# Now detect secure mode
if secure_mode:
self.secure_mode = None \
Expand Down Expand Up @@ -831,6 +860,12 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
mixed.attach(app)
base = mixed

if self.use_pgp:
# Apply our encryption
encrypted_content = self.pgp_encrypt_message(base.as_string())

Check warning on line 865 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L865

Added line #L865 was not covered by tests
if encrypted_content:
base = MIMEText(encrypted_content, "plain")

Check warning on line 867 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L867

Added line #L867 was not covered by tests

# Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, self._get_charset(v))
Expand Down Expand Up @@ -901,20 +936,21 @@ def submit(self, messages: t.List[EmailMessage]):
message.to_addrs,
message.body)

self.logger.info(
f'Sent Email notification to "{message.recipient}".')
self.logger.info('Sent Email to %s', message.recipient)

except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Sending email to "{message.recipient}" failed. '
f'Reason: {e}')
'Sending email to "%s" failed.', message.recipient)
self.logger.debug(f'Socket Exception: {e}')

# Mark as failure
has_error = True

except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Connection error while submitting email to {self.smtp_host}.'
f' Reason: {e}')
'Connection error while submitting email to "%s"',
self.smtp_host)
self.logger.debug(f'Socket Exception: {e}')

# Mark as failure
has_error = True
Expand All @@ -924,15 +960,224 @@ def submit(self, messages: t.List[EmailMessage]):
if socket is not None: # pragma: no branch
socket.quit()

# Reduce our dictionary (eliminate expired keys if any)
self.pgp_public_keys = {
key: value for key, value in self.pgp_public_keys.items()
if value['expires'] > datetime.now(timezone.utc)}

return not has_error

def pgp_generate_keys(self, path=None):
"""
Generates a set of keys based on email configured
"""
if path is None:
if self.store.mode == PersistentStoreMode.MEMORY:
# Not possible - no write permissions
return False

# Set our path
path = self.store.path

try:
# Create a new RSA key pair with 2048-bit strength
key = pgpy.PGPKey.new(
pgpy.constants.PubKeyAlgorithm.RSAEncryptOrSign, 2048)

except NameError:

Check warning on line 987 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L987

Added line #L987 was not covered by tests
# PGPy not installed
self.logger.debug('PGPy not installed; ignoring PGP file: %s')
return False

Check warning on line 990 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L989-L990

Added lines #L989 - L990 were not covered by tests

# Prepare our uid
name, email = self.names[self.from_addr[1]], self.from_addr[1]
uid = pgpy.PGPUID.new(name, email=email)

# Filenames
file_prefix = email.split('@')[0].lower()
pub_path = os.path.join(path, f'{file_prefix}-pub.asc')
prv_path = os.path.join(path, f'{file_prefix}-prv.asc')

# Add the user ID to the key
key.add_uid(uid, usage={
pgpy.constants.KeyFlags.Sign,
pgpy.constants.KeyFlags.EncryptCommunications},
hashes=[pgpy.constants.HashAlgorithm.SHA256],
ciphers=[pgpy.constants.SymmetricKeyAlgorithm.AES256],
compression=[pgpy.constants.CompressionAlgorithm.ZLIB])

try:
# Write our keys to disk
with open(pub_path, 'w') as f:
f.write(str(key.pubkey))

except OSError as e:
self.logger.warning('Error writing PGP file %s', pub_path)
self.logger.debug(f'I/O Exception: {e}')

Check warning on line 1016 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1014-L1016

Added lines #L1014 - L1016 were not covered by tests

# Cleanup
try:
os.unlink(pub_path)
self.logger.trace('Removed %s', pub_path)

Check warning on line 1021 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1019-L1021

Added lines #L1019 - L1021 were not covered by tests

except OSError:
pass

Check warning on line 1024 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1023-L1024

Added lines #L1023 - L1024 were not covered by tests

try:

with open(prv_path, 'w') as f:
f.write(str(key))

except OSError as e:
self.logger.warning('Error writing PGP file %s', prv_path)
self.logger.debug(f'I/O Exception: {e}')
try:
os.unlink(pub_path)
self.logger.trace('Removed %s', pub_path)

Check warning on line 1036 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1031-L1036

Added lines #L1031 - L1036 were not covered by tests

except OSError:
pass

Check warning on line 1039 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1038-L1039

Added lines #L1038 - L1039 were not covered by tests

try:
os.unlink(prv_path)
self.logger.trace('Removed %s', prv_path)

Check warning on line 1043 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1041-L1043

Added lines #L1041 - L1043 were not covered by tests

except OSError:
pass

Check warning on line 1046 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1045-L1046

Added lines #L1045 - L1046 were not covered by tests

return False

Check warning on line 1048 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1048

Added line #L1048 was not covered by tests

self.logger.info(
'Wrote PGP Keys for %s/%s',
os.path.dirname(pub_path),
os.path.basename(pub_path))
return True

@property
def pgp_fnames(self):
"""
Returns a list of filenames worth scanning for
"""
fnames = [
'pgp-public.asc',
'pgp-pub.asc',
'public.asc',
'pub.asc',
]

# Prepare our key files:
email = self.from_addr[1]

_entry = email.split('@')[0].lower()
if _entry not in fnames:
fnames.insert(0, f'{_entry}-pub.asc')

# Lowercase email (Highest Priority)
_entry = email.lower()
if _entry not in fnames:
fnames.insert(0, f'{_entry}-pub.asc')

return fnames

def pgp_public_key(self, path=None):
"""
Opens a spcified pgp public file and returns the key from it which
is used to encrypt the message
"""
if path is None:
path = next(
(os.path.join(self.store.path, fname)
for fname in self.pgp_fnames
if os.path.isfile(os.path.join(self.store.path, fname))),
None)

if not path:
if self.pgp_generate_keys(path=self.store.path):
path = next(
(os.path.join(self.store.path, fname)
for fname in self.pgp_fnames
if os.path.isfile(
os.path.join(self.store.path, fname))), None)

if path:
# We should get a hit now
return self.pgp_public_key(path=path)

self.logger.warning('No PGP Public Key could be loaded')
return None

Check warning on line 1107 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1106-L1107

Added lines #L1106 - L1107 were not covered by tests

if not isinstance(path, str):
raise AttributeError(

Check warning on line 1110 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1110

Added line #L1110 was not covered by tests
'Invalid path to PGP Public Key specified: %s: %s',
type(path), str(path))

# Persistent storage key:
ps_key = hashlib.sha1(
os.path.abspath(path).encode('utf-8')).hexdigest()
if ps_key in self.pgp_public_keys:
# Take an early exit
return self.pgp_public_keys[ps_key]['public_key']

try:
with open(path, 'r') as key_file:
public_key, _ = pgpy.PGPKey.from_blob(key_file.read())

except NameError:
# PGPy not installed
self.logger.debug(

Check warning on line 1127 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1127

Added line #L1127 was not covered by tests
'PGPy not installed; skipping PGP support: %s', path)
return None

Check warning on line 1129 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1129

Added line #L1129 was not covered by tests

except FileNotFoundError:
# Generate keys
self.logger.debug('PGP Public Key file not found: %s', path)
return None

Check warning on line 1134 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1133-L1134

Added lines #L1133 - L1134 were not covered by tests

except OSError as e:
self.logger.warning('Error accessing PGP Public Key file %s', path)
self.logger.debug(f'I/O Exception: {e}')
return None

Check warning on line 1139 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1136-L1139

Added lines #L1136 - L1139 were not covered by tests

self.store.set(ps_key, public_key, expires=86400)
self.pgp_public_keys[ps_key] = {
'public_key': public_key,
'expires':
datetime.now(timezone.utc) + timedelta(seconds=86400)
}
return public_key

# Encrypt message using the recipient's public key
def pgp_encrypt_message(self, message, path=None):
"""
If provided a path to a pgp-key, content is encrypted
"""

# Acquire our key
public_key = self.pgp_public_key(path=path)
if not public_key:
# Encryption not possible
return False

Check warning on line 1159 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1159

Added line #L1159 was not covered by tests

try:
message_object = pgpy.PGPMessage.new(message)
encrypted_message = public_key.encrypt(message_object)
return str(encrypted_message)

except NameError:

Check warning on line 1166 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1166

Added line #L1166 was not covered by tests
# PGPy not installed
self.logger.debug('PGPy not installed; Skipping PGP encryption')

Check warning on line 1168 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1168

Added line #L1168 was not covered by tests

return None

Check warning on line 1170 in apprise/plugins/email.py

View check run for this annotation

Codecov / codecov/patch

apprise/plugins/email.py#L1170

Added line #L1170 was not covered by tests

def url(self, privacy=False, *args, **kwargs):
"""
Returns the URL built dynamically based on specified arguments.
"""

# Define an URL parameters
params = {}
params = {
'pgp': 'yes' if self.use_pgp else 'no',
}

# Append our headers into our parameters
params.update({'+{}'.format(k): v for k, v in self.headers.items()})
Expand Down Expand Up @@ -1044,7 +1289,7 @@ def url_identifier(self):
"""
return (
self.secure_protocol if self.secure else self.protocol,
self.user, self.password, self.host,
self.user, self.password, self.host, self.smtp_host,
self.port if self.port
else SECURE_MODES[self.secure_mode]['default_port'],
)
Expand All @@ -1053,8 +1298,7 @@ def __len__(self):
"""
Returns the number of targets associated with this notification
"""
targets = len(self.targets)
return targets if targets > 0 else 1
return len(self.targets) if self.targets else 1

@staticmethod
def parse_url(url):
Expand Down Expand Up @@ -1086,6 +1330,11 @@ def parse_url(url):
# value if invalid; we'll attempt to figure this out later on
results['host'] = ''

# Get PGP Flag
results['use_pgp'] = \
parse_bool(results['qsd'].get(
'pgp', NotifyEmail.template_args['pgp']['default']))

# The From address is a must; either through the use of templates
# from= entry and/or merging the user and hostname together, this
# must be calculated or parse_url will fail.
Expand Down
Loading

0 comments on commit e8b2d42

Please sign in to comment.