Email deliverability improvement (#660)

pull/664/head
Dokime 2022-09-05 22:59:43 +00:00 committed by GitHub
parent 2d5ab59252
commit 6fbb2ba4b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 145 additions and 40 deletions

View File

@ -131,8 +131,14 @@ class HTMLConverter(HTMLParser, object):
# #
# This is required since the unescape() nbsp; with \xa0 when # This is required since the unescape() nbsp; with \xa0 when
# using Python 2.7 # using Python 2.7
try:
self.converted = self.converted.replace(u'\xa0', u' ') self.converted = self.converted.replace(u'\xa0', u' ')
except UnicodeDecodeError:
# Python v2.7 isn't the greatest for handling unicode
self.converted = \
self.converted.decode('utf-8').replace(u'\xa0', u' ')
def _finalize(self, result): def _finalize(self, result):
""" """
Combines and strips consecutive strings, then converts consecutive Combines and strips consecutive strings, then converts consecutive

View File

@ -29,7 +29,7 @@ import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.utils import formataddr from email.utils import formataddr, make_msgid
from email.header import Header from email.header import Header
from email import charset from email import charset
@ -38,10 +38,9 @@ from datetime import datetime
from .NotifyBase import NotifyBase from .NotifyBase import NotifyBase
from ..URLBase import PrivacyMode from ..URLBase import PrivacyMode
from ..common import NotifyFormat from ..common import NotifyFormat, NotifyType
from ..common import NotifyType from ..conversion import convert_between
from ..utils import is_email from ..utils import is_email, parse_emails
from ..utils import parse_emails
from ..AppriseLocale import gettext_lazy as _ from ..AppriseLocale import gettext_lazy as _
# Globally Default encoding mode set to Quoted Printable. # Globally Default encoding mode set to Quoted Printable.
@ -397,6 +396,11 @@ class NotifyEmail(NotifyBase):
'default': SecureMailMode.STARTTLS, 'default': SecureMailMode.STARTTLS,
'map_to': 'secure_mode', 'map_to': 'secure_mode',
}, },
'reply': {
'name': _('Reply To'),
'type': 'list:string',
'map_to': 'reply_to',
},
}) })
# Define any kwargs we're using # Define any kwargs we're using
@ -409,7 +413,7 @@ class NotifyEmail(NotifyBase):
def __init__(self, smtp_host=None, from_name=None, def __init__(self, smtp_host=None, from_name=None,
from_addr=None, secure_mode=None, targets=None, cc=None, from_addr=None, secure_mode=None, targets=None, cc=None,
bcc=None, headers=None, **kwargs): bcc=None, reply_to=None, headers=None, **kwargs):
""" """
Initialize Email Object Initialize Email Object
@ -435,6 +439,9 @@ class NotifyEmail(NotifyBase):
# Acquire Blind Carbon Copies # Acquire Blind Carbon Copies
self.bcc = set() self.bcc = set()
# Acquire Reply To
self.reply_to = set()
# For tracking our email -> name lookups # For tracking our email -> name lookups
self.names = {} self.names = {}
@ -467,6 +474,10 @@ class NotifyEmail(NotifyBase):
# Set our from name # Set our from name
self.from_name = from_name if from_name else result['name'] self.from_name = from_name if from_name else result['name']
# Store our lookup
self.names[self.from_addr] = \
self.from_name if self.from_name else False
# Now detect the SMTP Server # Now detect the SMTP Server
self.smtp_host = \ self.smtp_host = \
smtp_host if isinstance(smtp_host, six.string_types) else '' smtp_host if isinstance(smtp_host, six.string_types) else ''
@ -533,6 +544,26 @@ class NotifyEmail(NotifyBase):
'({}) specified.'.format(recipient), '({}) specified.'.format(recipient),
) )
if not reply_to:
# Add ourselves to the Reply-To directive
self.reply_to.add(self.from_addr)
# Validate recipients (reply-to:) and drop bad ones:
for recipient in parse_emails(reply_to):
email = is_email(recipient)
if email:
self.reply_to.add(email['full_email'])
# Index our name (if one exists)
self.names[email['full_email']] = \
email['name'] if email['name'] else False
continue
self.logger.warning(
'Dropped invalid Reply To email '
'({}) specified.'.format(recipient),
)
# Apply any defaults based on certain known configurations # Apply any defaults based on certain known configurations
self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs) self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs)
@ -612,6 +643,18 @@ class NotifyEmail(NotifyBase):
break break
def _get_charset(self, input_string):
"""
Get utf-8 charset if non ascii string only
Encode an ascii string to utf-8 is bad for email deliverability
because some anti-spam gives a bad score for that
like SUBJ_EXCESS_QP flag on Rspamd
"""
if not input_string:
return None
return 'utf-8' if not all(ord(c) < 128 for c in input_string) else None
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs): **kwargs):
""" """
@ -642,6 +685,9 @@ class NotifyEmail(NotifyBase):
# Strip target out of bcc list if in To # Strip target out of bcc list if in To
bcc = (self.bcc - set([to_addr])) bcc = (self.bcc - set([to_addr]))
# Strip target out of reply_to list if in To
reply_to = (self.reply_to - set([to_addr]))
try: try:
# Format our cc addresses to support the Name field # Format our cc addresses to support the Name field
cc = [formataddr( cc = [formataddr(
@ -653,6 +699,11 @@ class NotifyEmail(NotifyBase):
(self.names.get(addr, False), addr), charset='utf-8') (self.names.get(addr, False), addr), charset='utf-8')
for addr in bcc] for addr in bcc]
# Format our reply-to addresses to support the Name field
reply_to = [formataddr(
(self.names.get(addr, False), addr), charset='utf-8')
for addr in reply_to]
except TypeError: except TypeError:
# Python v2.x Support (no charset keyword) # Python v2.x Support (no charset keyword)
# Format our cc addresses to support the Name field # Format our cc addresses to support the Name field
@ -663,6 +714,10 @@ class NotifyEmail(NotifyBase):
bcc = [formataddr( # pragma: no branch bcc = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in bcc] (self.names.get(addr, False), addr)) for addr in bcc]
# Format our reply-to addresses to support the Name field
reply_to = [formataddr( # pragma: no branch
(self.names.get(addr, False), addr)) for addr in reply_to]
self.logger.debug( self.logger.debug(
'Email From: {} <{}>'.format(from_name, self.from_addr)) 'Email From: {} <{}>'.format(from_name, self.from_addr))
self.logger.debug('Email To: {}'.format(to_addr)) self.logger.debug('Email To: {}'.format(to_addr))
@ -670,45 +725,29 @@ class NotifyEmail(NotifyBase):
self.logger.debug('Email Cc: {}'.format(', '.join(cc))) self.logger.debug('Email Cc: {}'.format(', '.join(cc)))
if bcc: if bcc:
self.logger.debug('Email Bcc: {}'.format(', '.join(bcc))) self.logger.debug('Email Bcc: {}'.format(', '.join(bcc)))
if reply_to:
self.logger.debug(
'Email Reply-To: {}'.format(', '.join(reply_to))
)
self.logger.debug('Login ID: {}'.format(self.user)) self.logger.debug('Login ID: {}'.format(self.user))
self.logger.debug( self.logger.debug(
'Delivery: {}:{}'.format(self.smtp_host, self.port)) 'Delivery: {}:{}'.format(self.smtp_host, self.port))
# Prepare Email Message # Prepare Email Message
if self.notify_format == NotifyFormat.HTML: if self.notify_format == NotifyFormat.HTML:
content = MIMEText(body, 'html', 'utf-8') base = MIMEMultipart("alternative")
base.attach(MIMEText(
convert_between(
NotifyFormat.HTML, NotifyFormat.TEXT, body),
'plain', 'utf-8')
)
base.attach(MIMEText(body, 'html', 'utf-8'))
else: else:
content = MIMEText(body, 'plain', 'utf-8') base = MIMEText(body, 'plain', 'utf-8')
base = MIMEMultipart() if attach else content
# Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, 'utf-8')
base['Subject'] = Header(title, 'utf-8')
try:
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr),
charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr))
base['To'] = formataddr((to_name, to_addr))
base['Cc'] = ','.join(cc)
base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if attach: if attach:
# First attach our body to our content as the first element mixed = MIMEMultipart("mixed")
base.attach(content) mixed.attach(base)
# Now store our attachments # Now store our attachments
for attachment in attach: for attachment in attach:
if not attachment: if not attachment:
@ -735,8 +774,41 @@ class NotifyEmail(NotifyBase):
'attachment; filename="{}"'.format( 'attachment; filename="{}"'.format(
Header(attachment.name, 'utf-8')), Header(attachment.name, 'utf-8')),
) )
mixed.attach(app)
base = mixed
base.attach(app) # Apply any provided custom headers
for k, v in self.headers.items():
base[k] = Header(v, self._get_charset(v))
base['Subject'] = Header(title, self._get_charset(title))
try:
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr),
charset='utf-8')
base['To'] = formataddr((to_name, to_addr), charset='utf-8')
except TypeError:
# Python v2.x Support (no charset keyword)
base['From'] = formataddr(
(from_name if from_name else False, self.from_addr))
base['To'] = formataddr((to_name, to_addr))
try:
base['Message-ID'] = make_msgid(domain=self.smtp_host)
except TypeError:
# Python v2.x Support (no domain keyword)
base['Message-ID'] = make_msgid()
base['Date'] = \
datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
base['X-Application'] = self.app_id
if cc:
base['Cc'] = ','.join(cc)
base['Reply-To'] = ','.join(reply_to)
# bind the socket variable to the current namespace # bind the socket variable to the current namespace
socket = None socket = None
@ -829,6 +901,12 @@ class NotifyEmail(NotifyBase):
'' if not e not in self.names '' if not e not in self.names
else '{}:'.format(self.names[e]), e) for e in self.bcc]) else '{}:'.format(self.names[e]), e) for e in self.bcc])
# Handle our Reply-To Addresses
params['reply'] = ','.join(
['{}{}'.format(
'' if not e not in self.names
else '{}:'.format(self.names[e]), e) for e in self.reply_to])
# pull email suffix from username (if present) # pull email suffix from username (if present)
user = None if not self.user else self.user.split('@')[0] user = None if not self.user else self.user.split('@')[0]
@ -923,6 +1001,10 @@ class NotifyEmail(NotifyBase):
if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): if 'bcc' in results['qsd'] and len(results['qsd']['bcc']):
results['bcc'] = results['qsd']['bcc'] results['bcc'] = results['qsd']['bcc']
# Handle Reply To Addresses
if 'reply' in results['qsd'] and len(results['qsd']['reply']):
results['reply_to'] = results['qsd']['reply']
results['from_addr'] = from_addr results['from_addr'] = from_addr
results['smtp_host'] = smtp_host results['smtp_host'] = smtp_host

View File

@ -160,6 +160,20 @@ TEST_URLS = (
'instance': plugins.NotifyEmail, 'instance': plugins.NotifyEmail,
}, },
), ),
(
# Test Reply To
'mailtos://user:pass@example.com?smtp=smtp.example.com'
'&name=l2g&reply=test@example.com,test2@example.com', {
'instance': plugins.NotifyEmail,
},
),
(
# Test Reply To with bad email
'mailtos://user:pass@example.com?smtp=smtp.example.com'
'&name=l2g&reply=test@example.com,@', {
'instance': plugins.NotifyEmail,
},
),
# headers # headers
('mailto://user:pass@localhost.localdomain' ('mailto://user:pass@localhost.localdomain'
'?+X-Customer-Campaign-ID=Apprise', { '?+X-Customer-Campaign-ID=Apprise', {
@ -233,6 +247,9 @@ TEST_URLS = (
('mailto://user:pass@localhost/?cc=test2@,$@!/', { ('mailto://user:pass@localhost/?cc=test2@,$@!/', {
'instance': plugins.NotifyEmail, 'instance': plugins.NotifyEmail,
}), }),
('mailto://user:pass@localhost/?reply=test2@,$@!/', {
'instance': plugins.NotifyEmail,
}),
) )
@ -788,7 +805,7 @@ def test_plugin_email_url_parsing(mock_smtp, mock_smtp_ssl):
assert isinstance(_to, list) assert isinstance(_to, list)
assert len(_to) == 1 assert len(_to) == 1
assert _to[0] == 'user2@yahoo.com' assert _to[0] == 'user2@yahoo.com'
assert _msg.endswith('test') assert _msg.split('\n')[-3] == 'test'
# Our URL port was over-ridden (on template) to use 444 # Our URL port was over-ridden (on template) to use 444
# We can verify that this was correctly saved # We can verify that this was correctly saved
@ -835,7 +852,7 @@ def test_plugin_email_url_parsing(mock_smtp, mock_smtp_ssl):
assert isinstance(_to, list) assert isinstance(_to, list)
assert len(_to) == 1 assert len(_to) == 1
assert _to[0] == 'user2@yahoo.com' assert _to[0] == 'user2@yahoo.com'
assert _msg.endswith('test') assert _msg.split('\n')[-3] == 'test'
assert obj.url().startswith( assert obj.url().startswith(
'mailtos://user:pass123@hotmail.com/user2%40yahoo.com') 'mailtos://user:pass123@hotmail.com/user2%40yahoo.com')