Split the code into smaller modules

Introduce modules:
- lacre.transport - for actual delivery via SMTP
- lacre.smime - to take care of S/MIME stuff

Implement lacre.transport.SendFrom class that does a almost exactly the same
thing as the original send_msg function, but without using global variable to
store original message sender.
This commit is contained in:
Piotr F. Mieszkowski 2023-04-10 11:34:02 +02:00
parent ff6e0bfbdd
commit 682de14630
6 changed files with 237 additions and 135 deletions

View File

@ -64,6 +64,6 @@ if not delivered:
# silly message-encoding issue that shouldn't bounce the message, we just # silly message-encoding issue that shouldn't bounce the message, we just
# try recoding the message body and delivering it. # try recoding the message body and delivering it.
try: try:
core.failover_delivery(raw_message, to_addrs) core.failover_delivery(raw_message, to_addrs, from_addr)
except: except:
LOG.exception('Failover delivery failed too') LOG.exception('Failover delivery failed too')

View File

@ -30,19 +30,16 @@ from email.message import EmailMessage, MIMEPart
import email.utils import email.utils
from email.policy import SMTPUTF8 from email.policy import SMTPUTF8
import GnuPG import GnuPG
import os
import smtplib
import asyncio import asyncio
from typing import Tuple, List, AnyStr from typing import Tuple
# imports for S/MIME
from M2Crypto import BIO, SMIME, X509
import logging import logging
import lacre.text as text import lacre.text as text
import lacre.config as conf import lacre.config as conf
import lacre.keyring as kcache import lacre.keyring as kcache
import lacre.recipients as recpt import lacre.recipients as recpt
import lacre.smime as smime
from lacre.transport import send_msg, register_sender, SendFrom
from lacre.mailop import KeepIntact, InlineOpenPGPEncrypt, MimeOpenPGPEncrypt from lacre.mailop import KeepIntact, InlineOpenPGPEncrypt, MimeOpenPGPEncrypt
@ -289,92 +286,6 @@ def _append_gpg_extension(attachment):
attachment.set_param('name', pgpFilename) attachment.set_param('name', pgpFilename)
def _smime_encrypt(raw_message, recipients):
global LOG
global from_addr
if not conf.config_item_set('smime', 'cert_path'):
LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.")
return recipients
cert_path = conf.get_item('smime', 'cert_path')+"/"
s = SMIME.SMIME()
sk = X509.X509_Stack()
smime_to = list()
unsmime_to = list()
for addr in recipients:
cert_and_email = _get_cert_for_email(addr, cert_path)
if not (cert_and_email is None):
(to_cert, normal_email) = cert_and_email
LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email)
smime_to.append(addr)
x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM)
sk.push(x509)
else:
unsmime_to.append(addr)
if smime_to:
s.set_x509_stack(sk)
s.set_cipher(SMIME.Cipher('aes_192_cbc'))
p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string()))
# Output p7 in mail-friendly format.
out = BIO.MemoryBuffer()
out.write('From: ' + from_addr + text.EOL_S)
out.write('To: ' + raw_message['To'] + text.EOL_S)
if raw_message['Cc']:
out.write('Cc: ' + raw_message['Cc'] + text.EOL_S)
if raw_message['Bcc']:
out.write('Bcc: ' + raw_message['Bcc'] + text.EOL_S)
if raw_message['Subject']:
out.write('Subject: ' + raw_message['Subject'] + text.EOL_S)
if conf.config_item_equals('default', 'add_header', 'yes'):
out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate' + text.EOL_S)
s.write(out, p7)
LOG.debug(f"Sending message from {from_addr} to {smime_to}")
send_msg(out.read(), smime_to)
if unsmime_to:
LOG.debug(f"Unable to find valid S/MIME certificates for {unsmime_to}")
return unsmime_to
def _get_cert_for_email(to_addr, cert_path):
insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes')
LOG.info(f'Retrieving certificate for {to_addr!r} from {cert_path!r}, sensitivity={insensitive!r}')
files_in_directory = os.listdir(cert_path)
for filename in files_in_directory:
file_path = os.path.join(cert_path, filename)
if not os.path.isfile(file_path):
continue
if insensitive:
if filename.casefold() == to_addr:
return (file_path, to_addr)
else:
if filename == to_addr:
return (file_path, to_addr)
# support foo+ignore@bar.com -> foo@bar.com
LOG.info(f"An email with topic? {to_addr}")
(fixed_up_email, topic) = text.parse_delimiter(to_addr)
LOG.info(f'Got {fixed_up_email!r} and {topic!r}')
if topic is None:
# delimiter not used
LOG.info('Topic not found')
return None
else:
LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}")
return _get_cert_for_email(fixed_up_email, cert_path)
def _generate_message_from_payloads(payloads, message=None): def _generate_message_from_payloads(payloads, message=None):
if message is None: if message is None:
message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype()) message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype())
@ -395,46 +306,28 @@ def _get_first_payload(payloads):
return payloads return payloads
def send_msg(message: AnyStr, recipients: List[str], fromaddr=None):
"""Send MESSAGE to RECIPIENTS to the mail relay."""
global from_addr
if fromaddr is not None:
from_addr = fromaddr
recipients = [_f for _f in recipients if _f]
if recipients:
LOG.info(f"Sending email to: {recipients!r}")
relay = conf.relay_params()
smtp = smtplib.SMTP(relay[0], relay[1])
if conf.flag_enabled('relay', 'starttls'):
smtp.starttls()
smtp.sendmail(from_addr, recipients, message)
else:
LOG.info("No recipient found")
def _recode(m: EmailMessage): def _recode(m: EmailMessage):
payload = m.get_payload() payload = m.get_payload()
m.set_content(payload) m.set_content(payload)
def failover_delivery(message: EmailMessage, recipients): def failover_delivery(message: EmailMessage, recipients, from_address):
"""Try delivering message just one last time.""" """Try delivering message just one last time."""
LOG.debug('Failover delivery') LOG.debug('Failover delivery')
send = SendFrom(from_address)
if message.get_content_maintype() == 'text': if message.get_content_maintype() == 'text':
LOG.debug('Flat text message, adjusting coding') LOG.debug('Flat text message, adjusting coding')
_recode(message) _recode(message)
b = message.as_bytes(policy=SMTPUTF8) b = message.as_bytes(policy=SMTPUTF8)
send_msg(b, recipients) send(b, recipients)
elif message.get_content_maintype() == 'multipart': elif message.get_content_maintype() == 'multipart':
LOG.debug('Multipart message, adjusting coding of text entities') LOG.debug('Multipart message, adjusting coding of text entities')
for part in message.iter_parts(): for part in message.iter_parts():
if part.get_content_maintype() == 'text': if part.get_content_maintype() == 'text':
_recode(part) _recode(part)
b = message.as_bytes(policy=SMTPUTF8) b = message.as_bytes(policy=SMTPUTF8)
send_msg(b, recipients) send(b, recipients)
else: else:
LOG.warning('No failover strategy, giving up') LOG.warning('No failover strategy, giving up')
@ -475,19 +368,19 @@ def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache)
def deliver_message(raw_message: EmailMessage, from_address, to_addrs): def deliver_message(raw_message: EmailMessage, from_address, to_addrs):
"""Send RAW_MESSAGE to all TO_ADDRS using the best encryption method available.""" """Send RAW_MESSAGE to all TO_ADDRS using the best encryption method available."""
global from_addr
# Ugly workaround to keep the code working without too many changes. # Ugly workaround to keep the code working without too many changes.
from_addr = from_address register_sender(from_address)
sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive')) sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive'))
recipients_left = [sanitize(recipient) for recipient in to_addrs] recipients_left = [sanitize(recipient) for recipient in to_addrs]
send = SendFrom(from_address)
# There is no need for nested encryption # There is no need for nested encryption
LOG.debug("Seeing if it's already encrypted") LOG.debug("Seeing if it's already encrypted")
if _is_encrypted(raw_message): if _is_encrypted(raw_message):
LOG.debug("Message is already encrypted. Encryption aborted.") LOG.debug("Message is already encrypted. Encryption aborted.")
send_msg(raw_message.as_string(), recipients_left) send(raw_message.as_string(), recipients_left)
return return
# Encrypt mails for recipients with known public PGP keys # Encrypt mails for recipients with known public PGP keys
@ -498,10 +391,10 @@ def deliver_message(raw_message: EmailMessage, from_address, to_addrs):
# Encrypt mails for recipients with known S/MIME certificate # Encrypt mails for recipients with known S/MIME certificate
LOG.debug("Encrypting with S/MIME") LOG.debug("Encrypting with S/MIME")
recipients_left = _smime_encrypt(raw_message, recipients_left) recipients_left = smime.encrypt(raw_message, recipients_left, from_address)
if not recipients_left: if not recipients_left:
return return
# Send out mail to recipients which are left # Send out mail to recipients which are left
LOG.debug("Sending the rest as text/plain") LOG.debug("Sending the rest as text/plain")
send_msg(raw_message.as_string(), recipients_left) send(raw_message.as_string(), recipients_left)

View File

@ -13,12 +13,6 @@ from email.policy import SMTPUTF8
import time import time
from watchdog.observers import Observer from watchdog.observers import Observer
# Mail status constants.
#
# These are the only values that our mail handler is allowed to return.
RESULT_OK = '250 OK'
RESULT_ERROR = '500 Could not process your message'
# Load configuration and init logging, in this order. Only then can we load # Load configuration and init logging, in this order. Only then can we load
# the last Lacre module, i.e. lacre.mailgate. # the last Lacre module, i.e. lacre.mailgate.
conf.load_config() conf.load_config()
@ -28,6 +22,7 @@ LOG = logging.getLogger('lacre.daemon')
from GnuPG import EncryptionException from GnuPG import EncryptionException
import lacre.core as gate import lacre.core as gate
import lacre.keyring as kcache import lacre.keyring as kcache
import lacre.transport as xport
from lacre.mailop import KeepIntact from lacre.mailop import KeepIntact
@ -57,32 +52,33 @@ class MailEncryptionProxy:
if conf.flag_enabled('daemon', 'log_headers'): if conf.flag_enabled('daemon', 'log_headers'):
LOG.info('Message headers: %s', self._extract_headers(message)) LOG.info('Message headers: %s', self._extract_headers(message))
send = xport.SendFrom(envelope.mail_from)
for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys): for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys):
LOG.debug(f"Sending mail via {operation!r}") LOG.debug(f"Sending mail via {operation!r}")
try: try:
new_message = operation.perform(message) new_message = operation.perform(message)
gate.send_msg(new_message, operation.recipients(), envelope.mail_from) send(new_message, operation.recipients())
except EncryptionException: except EncryptionException:
# If the message can't be encrypted, deliver cleartext. # If the message can't be encrypted, deliver cleartext.
LOG.exception('Unable to encrypt message, delivering in cleartext') LOG.exception('Unable to encrypt message, delivering in cleartext')
if not isinstance(operation, KeepIntact): if not isinstance(operation, KeepIntact):
self._send_unencrypted(operation, message, envelope) self._send_unencrypted(operation, message, envelope, send)
else: else:
LOG.error(f'Cannot perform {operation}') LOG.error(f'Cannot perform {operation}')
except: except:
LOG.exception('Unexpected exception caught, bouncing message') LOG.exception('Unexpected exception caught, bouncing message')
return RESULT_ERROR return xport.RESULT_ERROR
ellapsed = (time.process_time() - start) * 1000 ellapsed = (time.process_time() - start) * 1000
LOG.info(f'Message delivered in {ellapsed:.2f} ms') LOG.info(f'Message delivered in {ellapsed:.2f} ms')
return RESULT_OK return xport.RESULT_OK
def _send_unencrypted(self, operation, message, envelope): def _send_unencrypted(self, operation, message, envelope, send: xport.SendFrom):
keep = KeepIntact(operation.recipients()) keep = KeepIntact(operation.recipients())
new_message = keep.perform(message) new_message = keep.perform(message)
gate.send_msg(new_message, operation.recipients(), envelope.mail_from) send(new_message, operation.recipients(), envelope.mail_from)
def _beginning(self, e: Envelope) -> bytes: def _beginning(self, e: Envelope) -> bytes:
double_eol_pos = e.original_content.find(DOUBLE_EOL_BYTES) double_eol_pos = e.original_content.find(DOUBLE_EOL_BYTES)

View File

@ -32,7 +32,23 @@ import lacre.text as text
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
class GpgRecipient: class Recipient:
"""Wraps recipient's email."""
def __init__(self, email):
"""Initialise the recipient."""
self._email = email
def email(self) -> str:
"""Return email address of this recipient."""
return self._email
def __str__(self):
"""Return string representation of this recipient: the email address."""
return self._email
class GpgRecipient(Recipient):
"""A tuple-like object that contains GPG recipient data.""" """A tuple-like object that contains GPG recipient data."""
def __init__(self, left, right): def __init__(self, left, right):
@ -53,7 +69,7 @@ class GpgRecipient:
"""Return textual representation of this GPG Recipient.""" """Return textual representation of this GPG Recipient."""
return f"GpgRecipient({self._left!r}, {self._right!r})" return f"GpgRecipient({self._left!r}, {self._right!r})"
def email(self): def email(self) -> str:
"""Return this recipient's email address.""" """Return this recipient's email address."""
return self._left return self._left

126
lacre/smime.py Normal file
View File

@ -0,0 +1,126 @@
#
# gpg-mailgate
#
# This file is part of the gpg-mailgate source code.
#
# gpg-mailgate is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# gpg-mailgate source code is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
#
"""S/MIME handling module."""
import os
from M2Crypto import BIO, SMIME, X509
import logging
import lacre.text as text
import lacre.config as conf
import lacre.transport as xport
LOG = logging.getLogger(__name__)
#
# WARNING: This file is not covered with E2E tests.
#
def encrypt(raw_message, recipients, from_addr):
"""Encrypt with S/MIME."""
if not conf.config_item_set('smime', 'cert_path'):
LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.")
return recipients
cert_path = conf.get_item('smime', 'cert_path')+"/"
s = SMIME.SMIME()
sk = X509.X509_Stack()
smime_to = list()
cleartext_to = list()
for addr in recipients:
cert_and_email = _get_cert_for_email(addr, cert_path)
if not (cert_and_email is None):
(to_cert, normal_email) = cert_and_email
LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email)
smime_to.append(addr)
x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM)
sk.push(x509)
else:
cleartext_to.append(addr)
if smime_to:
s.set_x509_stack(sk)
s.set_cipher(SMIME.Cipher('aes_192_cbc'))
p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string()))
# Output p7 in mail-friendly format.
out = BIO.MemoryBuffer()
out.write('From: ' + from_addr + text.EOL_S)
out.write('To: ' + raw_message['To'] + text.EOL_S)
if raw_message['Cc']:
out.write('Cc: ' + raw_message['Cc'] + text.EOL_S)
if raw_message['Bcc']:
out.write('Bcc: ' + raw_message['Bcc'] + text.EOL_S)
if raw_message['Subject']:
out.write('Subject: ' + raw_message['Subject'] + text.EOL_S)
if conf.config_item_equals('default', 'add_header', 'yes'):
out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate' + text.EOL_S)
s.write(out, p7)
LOG.debug(f"Sending message from {from_addr} to {smime_to}")
send_msg = xport.SendFrom(from_addr)
send_msg(out.read(), smime_to)
if cleartext_to:
LOG.debug(f"Unable to find valid S/MIME certificates for {cleartext_to}")
return cleartext_to
def _path_comparator(insensitive: bool):
if insensitive:
return lambda filename, recipient: filename.casefold() == recipient
else:
return lambda filename, recipient: filename == recipient
def _get_cert_for_email(to_addr, cert_path):
insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes')
paths_equal = _path_comparator(insensitive)
LOG.info('Retrieving certificate for %s from %s, insensitive=%s',
to_addr, cert_path, insensitive)
files_in_directory = os.listdir(cert_path)
for filename in files_in_directory:
file_path = os.path.join(cert_path, filename)
if not os.path.isfile(file_path):
continue
if paths_equal(file_path, to_addr):
return (file_path, to_addr)
# support foo+ignore@bar.com -> foo@bar.com
LOG.info(f"An email with topic? {to_addr}")
(fixed_up_email, topic) = text.parse_delimiter(to_addr)
LOG.info(f'Got {fixed_up_email!r} and {topic!r}')
if topic is None:
# delimiter not used
LOG.info('Topic not found')
return None
else:
LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}")
return _get_cert_for_email(fixed_up_email, cert_path)

71
lacre/transport.py Normal file
View File

@ -0,0 +1,71 @@
"""SMTP transport module."""
import smtplib
import logging
from typing import AnyStr, List
import lacre.config as conf
# Mail status constants.
#
# These are the only values that our mail handler is allowed to return.
RESULT_OK = '250 OK'
RESULT_ERROR = '500 Could not process your message'
LOG = logging.getLogger(__name__)
# This is a left-over from old architecture.
from_addr = None
def register_sender(fromaddr):
"""Set module state: message sender address."""
global from_addr
LOG.warning('Setting global recipient: %s', fromaddr)
from_addr = fromaddr
def send_msg(message: AnyStr, recipients: List[str]):
"""Send MESSAGE to RECIPIENTS to the mail relay."""
global from_addr
LOG.debug('Delivery from %s to %s', from_addr, recipients)
recipients = [_f for _f in recipients if _f]
if recipients:
LOG.info(f"Sending email to: {recipients!r}")
relay = conf.relay_params()
smtp = smtplib.SMTP(relay[0], relay[1])
if conf.flag_enabled('relay', 'starttls'):
smtp.starttls()
smtp.sendmail(from_addr, recipients, message)
else:
LOG.info("No recipient found")
class SendFrom:
"""A class wrapping the transport process."""
def __init__(self, from_addr):
"""Initialise the transport."""
self._from_addr = from_addr
def __call__(self, message: AnyStr, recipients: List[str]):
"""Send the given message to all recipients from the list.
- Message is the email object serialised to str or bytes.
- Empty recipients are filtered out before communication.
"""
recipients = [_f for _f in recipients if _f]
if not recipients:
LOG.warning("No recipient found")
return
LOG.info("Sending email to: %s", recipients)
relay = conf.relay_params()
smtp = smtplib.SMTP(relay[0], relay[1])
if conf.flag_enabled('relay', 'starttls'):
smtp.starttls()
smtp.sendmail(self._from_addr, recipients, message)