Improve delivery error-handling

- Introduce exceptions to be raised upon transient and permanent delivery
failures, as specified by SMTP RFC.  Depending on type of failure, return
either 451 or 554 reply code.

- When serialising a message, treat ValueError as a serialisation issue (and
try again to deliver in cleartext).
This commit is contained in:
Piotr F. Mieszkowski 2024-03-13 21:55:34 +01:00
parent 8955cf6c9d
commit 09d7a498df
Signed by: pfm
GPG key ID: BDE5BC1FA5DC53D5
5 changed files with 62 additions and 16 deletions

View file

@ -6,6 +6,7 @@ configuration.
from enum import Enum, auto
from configparser import RawConfigParser
from collections import namedtuple
import os
@ -128,9 +129,11 @@ def validate_config(*, additional=None):
# High level access to configuration.
#
def relay_params():
"""Return a (HOST, PORT) tuple identifying the mail relay."""
return (cfg["relay"]["host"], int(cfg["relay"]["port"]))
Host = namedtuple('Host', ['name', 'port'])
def relay_params() -> Host:
"""Return a Host named tuple identifying the mail relay."""
return Host(name = cfg["relay"]["host"], port = int(cfg["relay"]["port"]))
def daemon_params():

View file

@ -55,11 +55,19 @@ class MailEncryptionProxy:
LOG.error('Unable to encrypt message, delivering in cleartext: %s', e)
self._send_unencrypted(operation, envelope, send)
except:
LOG.exception('Unexpected exception caught, bouncing message')
except xport.TransientFailure:
LOG.info('Bouncing message')
return xport.RESULT_ABORT
except xport.PermanentFailure:
LOG.exception('Permanent failure')
return xport.RESULT_PERM_FAIL
except:
if conf.should_log_headers():
LOG.error('Erroneous message headers: %s', self._beginning(envelope))
LOG.exception('Unexpected exception caught, bouncing message. Erroneous message headers: %s', self._beginning(envelope))
else:
LOG.exception('Unexpected exception caught, bouncing message')
return xport.RESULT_ABORT

View file

@ -131,7 +131,7 @@ class KeepIntact(MailOperation):
"""Return MESSAGE unmodified."""
try:
return message.as_bytes(policy=SMTPUTF8)
except (IndexError, UnicodeEncodeError) as e:
except (IndexError, UnicodeEncodeError, ValueError) as e:
raise MailSerialisationException(e)
def __repr__(self):

View file

@ -45,8 +45,8 @@ def notify(mailsubject, messagefile, recipients = None):
msg.attach(MIMEText(markdown.markdown(mailbody), 'html'))
if conf.config_item_set('relay', 'host') and conf.config_item_set('relay', 'enc_port'):
(host, port) = conf.relay_params()
smtp = smtplib.SMTP(host, port)
host = conf.relay_params()
smtp = smtplib.SMTP(host.name, host.port)
_authenticate_maybe(smtp)
LOG.info('Delivering notification: %s', recipients)
smtp.sendmail(conf.get_item('cron', 'notification_email'), recipients, msg.as_string())

View file

@ -5,13 +5,19 @@ import logging
from typing import AnyStr, List
import lacre.config as conf
from lacre.mailop import MailSerialisationException
# Mail status constants.
#
# These are the only values that our mail handler is allowed to return.
RESULT_OK = '250 OK' # delivered
RESULT_ABORT = '451 Aborted: error in processing' # aborted, retry later
RESULT_FAILED = '554 Transaction failed' # failed
RESULT_OK = '250 OK'
RESULT_TRANS_FAIL = '451 Aborted: error in processing'
RESULT_PERM_FAIL = '554 Transaction failed'
# See RFC 5321, section 4.2.1 "Reply Code Severities and Theory" for more
# information on SMTP reply codes.
RESP_TRANSIENT_NEG = 4
RESP_PERMANENT_NEG = 5
LOG = logging.getLogger(__name__)
@ -35,7 +41,7 @@ def send_msg(message: AnyStr, recipients: List[str]):
if recipients:
LOG.info(f"Sending email to: {recipients!r}")
relay = conf.relay_params()
smtp = smtplib.SMTP(relay[0], relay[1])
smtp = smtplib.SMTP(relay.name, relay.port)
if conf.flag_enabled('relay', 'starttls'):
smtp.starttls()
smtp.sendmail(from_addr, recipients, message)
@ -43,6 +49,19 @@ def send_msg(message: AnyStr, recipients: List[str]):
LOG.info("No recipient found")
class TransientFailure(BaseException):
"""Signals a transient delivery failure (4xx SMTP reply).
Message should be bounced and re-sent later.
"""
pass
class PermanentFailure(BaseException):
"""Signals a permanent delivery failure (5xx SMTP reply)."""
pass
class SendFrom:
"""A class wrapping the transport process."""
@ -64,12 +83,28 @@ class SendFrom:
LOG.info("Sending email to: %s", recipients)
relay = conf.relay_params()
smtp = smtplib.SMTP(relay[0], relay[1])
smtp = smtplib.SMTP(relay.name, relay.port)
if conf.flag_enabled('relay', 'starttls'):
smtp.starttls()
try:
smtp.sendmail(self._from_addr, recipients, message)
except smtplib.SMTPException:
LOG.exception('Failed to deliver message')
except smtplib.SMTPResponseException as re:
resp_class = self._get_class(re.smtp_code)
if resp_class == RESP_TRANSIENT_NEG:
LOG.warning('Transient delivery failure: %s', re)
raise TransientFailure()
elif resp_class == RESP_PERMANENT_NEG:
LOG.error('Permanent delivery failure: %s', re)
raise PermanentFailure()
except smtplib.SMTPException as err:
LOG.error('Failed to deliver message: %s', err)
raise PermanentFailure()
except UnicodeEncodeError as uee:
LOG.error('Failed to deliver for non-SMTP reason', uee)
raise MailSerialisationException(uee)
def _get_class(self, resp_code):
return int(resp_code / 100)