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:
parent
ff6e0bfbdd
commit
682de14630
6 changed files with 237 additions and 135 deletions
|
@ -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')
|
||||
|
|
133
lacre/core.py
133
lacre/core.py
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
126
lacre/smime.py
Normal 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
71
lacre/transport.py
Normal 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)
|
Loading…
Reference in a new issue