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
# try recoding the message body and delivering it.
try:
core.failover_delivery(raw_message, to_addrs)
core.failover_delivery(raw_message, to_addrs, from_addr)
except:
LOG.exception('Failover delivery failed too')

View File

@ -30,19 +30,16 @@ from email.message import EmailMessage, MIMEPart
import email.utils
from email.policy import SMTPUTF8
import GnuPG
import os
import smtplib
import asyncio
from typing import Tuple, List, AnyStr
# imports for S/MIME
from M2Crypto import BIO, SMIME, X509
from typing import Tuple
import logging
import lacre.text as text
import lacre.config as conf
import lacre.keyring as kcache
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
@ -289,92 +286,6 @@ def _append_gpg_extension(attachment):
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):
if message is None:
message = email.mime.multipart.MIMEMultipart(payloads.get_content_subtype())
@ -395,46 +306,28 @@ def _get_first_payload(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):
payload = m.get_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."""
LOG.debug('Failover delivery')
send = SendFrom(from_address)
if message.get_content_maintype() == 'text':
LOG.debug('Flat text message, adjusting coding')
_recode(message)
b = message.as_bytes(policy=SMTPUTF8)
send_msg(b, recipients)
send(b, recipients)
elif message.get_content_maintype() == 'multipart':
LOG.debug('Multipart message, adjusting coding of text entities')
for part in message.iter_parts():
if part.get_content_maintype() == 'text':
_recode(part)
b = message.as_bytes(policy=SMTPUTF8)
send_msg(b, recipients)
send(b, recipients)
else:
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):
"""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.
from_addr = from_address
register_sender(from_address)
sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive'))
recipients_left = [sanitize(recipient) for recipient in to_addrs]
send = SendFrom(from_address)
# There is no need for nested encryption
LOG.debug("Seeing if it's already encrypted")
if _is_encrypted(raw_message):
LOG.debug("Message is already encrypted. Encryption aborted.")
send_msg(raw_message.as_string(), recipients_left)
send(raw_message.as_string(), recipients_left)
return
# 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
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:
return
# Send out mail to recipients which are left
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
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
# the last Lacre module, i.e. lacre.mailgate.
conf.load_config()
@ -28,6 +22,7 @@ LOG = logging.getLogger('lacre.daemon')
from GnuPG import EncryptionException
import lacre.core as gate
import lacre.keyring as kcache
import lacre.transport as xport
from lacre.mailop import KeepIntact
@ -57,32 +52,33 @@ class MailEncryptionProxy:
if conf.flag_enabled('daemon', 'log_headers'):
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):
LOG.debug(f"Sending mail via {operation!r}")
try:
new_message = operation.perform(message)
gate.send_msg(new_message, operation.recipients(), envelope.mail_from)
send(new_message, operation.recipients())
except EncryptionException:
# If the message can't be encrypted, deliver cleartext.
LOG.exception('Unable to encrypt message, delivering in cleartext')
if not isinstance(operation, KeepIntact):
self._send_unencrypted(operation, message, envelope)
self._send_unencrypted(operation, message, envelope, send)
else:
LOG.error(f'Cannot perform {operation}')
except:
LOG.exception('Unexpected exception caught, bouncing message')
return RESULT_ERROR
return xport.RESULT_ERROR
ellapsed = (time.process_time() - start) * 1000
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())
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:
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__)
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."""
def __init__(self, left, right):
@ -53,7 +69,7 @@ class GpgRecipient:
"""Return textual representation of this GPG Recipient."""
return f"GpgRecipient({self._left!r}, {self._right!r})"
def email(self):
def email(self) -> str:
"""Return this recipient's email address."""
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)