atr/mail.py (115 lines of code) (raw):

# Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information # regarding copyright ownership. The ASF licenses this file # to you under the Apache License, Version 2.0 (the # "License"); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, # software distributed under the License is distributed on an # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. import dataclasses import email.utils as utils import logging import os.path import ssl import time import uuid from typing import Final import aiofiles import aiosmtplib import dkim _LOGGER: Final = logging.getLogger(__name__) # TODO: We should choose a pattern for globals # We could e.g. use uppercase instead of global_ # It's not always worth identifying globals as globals # But in many cases we should do so # TODO: Get at least global_dkim_domain from configuration # And probably global_dkim_selector too global_dkim_selector: str = "mail" global_dkim_domain: str = "apache.org" global_secret_key: str | None = None _MAIL_RELAY: Final[str] = "mail-relay.apache.org" _SMTP_PORT: Final[int] = 587 _SMTP_TIMEOUT: Final[int] = 30 @dataclasses.dataclass class Message: email_sender: str email_recipient: str subject: str body: str in_reply_to: str | None = None async def send(message: Message) -> str: """Send an email notification about an artifact or a vote.""" _LOGGER.info(f"Sending email for event: {message}") from_addr = message.email_sender if not from_addr.endswith(f"@{global_dkim_domain}"): raise ValueError(f"from_addr must end with @{global_dkim_domain}, got {from_addr}") to_addr = message.email_recipient _validate_recipient(to_addr) # UUID4 is entirely random, with no timestamp nor namespace # It does have 6 version and variant bits, so only 122 bits are random mid = f"{uuid.uuid4()}@{global_dkim_domain}" headers = [ f"From: {from_addr}", f"To: {to_addr}", f"Subject: {message.subject}", f"Date: {utils.formatdate(localtime=True)}", f"Message-ID: <{mid}>", ] if message.in_reply_to is not None: headers.append(f"In-Reply-To: <{message.in_reply_to}>") # TODO: Add message.references if necessary headers.append(f"References: <{message.in_reply_to}>") # Normalise the body padding and ensure that line endings are CRLF body = message.body.strip() body = body.replace("\r\n", "\n") body = body.replace("\n", "\r\n") body = body + "\r\n" # Construct the message msg_text = "\r\n".join(headers) + "\r\n\r\n" + body start = time.perf_counter() _LOGGER.info(f"sending message: {msg_text}") try: await _send_many(from_addr, [to_addr], msg_text) except Exception as e: _LOGGER.error(f"send error: {e}") raise e else: _LOGGER.info(f"sent to {to_addr}") elapsed = time.perf_counter() - start _LOGGER.info(f" send_many took {elapsed:.3f}s") return mid def set_secret_key(key: str) -> None: """Set the secret key for DKIM signing.""" global global_secret_key global_secret_key = key async def set_secret_key_default() -> None: # TODO: Document this, or improve it project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) dkim_path = os.path.join(project_root, "state", "dkim.private") async with aiofiles.open(dkim_path) as f: dkim_key = await f.read() set_secret_key(dkim_key.strip()) _LOGGER.info("DKIM key loaded and set successfully") async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> None: """Send an email to multiple recipients with DKIM signing.""" message_bytes = bytes(msg_text, "utf-8") if global_secret_key is None: raise ValueError("global_secret_key is not set") # DKIM sign the message private_key = bytes(global_secret_key, "utf-8") # Create a DKIM signature sig = dkim.sign( message=message_bytes, selector=bytes(global_dkim_selector, "utf-8"), domain=bytes(global_dkim_domain, "utf-8"), privkey=private_key, include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"], ) # Prepend the DKIM signature to the message dkim_msg = sig + message_bytes _LOGGER.info("email_send_many") errors = [] for addr in to_addrs: try: await _send_via_relay(from_addr, addr, dkim_msg) except Exception as e: _LOGGER.exception(f"Failed to send to {addr}:") errors.append(f"failed to send to {addr}: {e}") if errors: # Raising an exception will ensure that any calling task is marked as failed raise Exception("Failed to send to one or more recipients: " + "; ".join(errors)) async def _send_via_relay(from_addr: str, to_addr: str, dkim_msg_bytes: bytes) -> None: """Send a DKIM signed email to a single recipient via the ASF mail relay.""" _validate_recipient(to_addr) # Connect to the ASF mail relay # NOTE: Our code is very different from the asfpy code: # - Uses types # - Uses asyncio # - Performs DKIM signing # Due to the divergence, we should probably not contribute upstream # In effect, these are two different "packages" of functionality # We can't even sign it first and pass it to asfpy, due to its different design _LOGGER.info(f"Connecting async to {_MAIL_RELAY}:{_SMTP_PORT}") context = ssl.create_default_context() context.minimum_version = ssl.TLSVersion.TLSv1_2 smtp = aiosmtplib.SMTP(hostname=_MAIL_RELAY, port=_SMTP_PORT, timeout=_SMTP_TIMEOUT, tls_context=context) await smtp.connect() _LOGGER.info(f"Connected to {smtp.hostname}:{smtp.port}") await smtp.ehlo() await smtp.sendmail(from_addr, [to_addr], dkim_msg_bytes) await smtp.quit() def _split_address(addr: str) -> tuple[str, str]: """Split an email address into local and domain parts.""" parts = addr.split("@", 1) if len(parts) != 2: raise ValueError("Invalid mail address") return parts[0], parts[1] def _validate_recipient(to_addr: str) -> None: # Ensure recipient is @apache.org or @tooling.apache.org _, domain = _split_address(to_addr) if domain not in ("apache.org", "tooling.apache.org"): error_msg = f"Email recipient must be @apache.org or @tooling.apache.org, got {to_addr}" _LOGGER.error(error_msg) raise ValueError(error_msg)