This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch arm in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 54921a6982695f0e3d6ab4901f6a2ba64e9d9fee Author: Alastair McFarlane <[email protected]> AuthorDate: Mon Mar 23 13:39:16 2026 +0000 #910 - emails support CC and BCC, and enum for footer to be appended. --- atr/mail.py | 78 +++++++++--- atr/storage/writers/announce.py | 2 + atr/storage/writers/mail.py | 4 +- atr/storage/writers/tokens.py | 4 +- atr/storage/writers/vote.py | 4 + atr/tasks/message.py | 17 ++- atr/tasks/vote.py | 2 +- tests/unit/test_mail.py | 268 +++++++++++++++++++++++++++++++++++++--- tests/unit/test_message.py | 6 +- 9 files changed, 342 insertions(+), 43 deletions(-) diff --git a/atr/mail.py b/atr/mail.py index 7811f74c..7d59d33c 100644 --- a/atr/mail.py +++ b/atr/mail.py @@ -20,6 +20,7 @@ import email.headerregistry as headerregistry import email.message as message import email.policy as policy import email.utils as utils +import enum import ssl import time import uuid @@ -41,21 +42,31 @@ _SMTP_PORT: Final[int] = 587 _SMTP_TIMEOUT: Final[int] = 30 +class MailFooterCategory(enum.StrEnum): + NONE = "none" + USER = "user" + AUTO = "auto" + + @dataclasses.dataclass class Message: email_sender: str email_recipient: str subject: str body: str + email_cc: str = "" + email_bcc: str = "" in_reply_to: str | None = None -async def send(msg_data: Message) -> tuple[str, list[str]]: +async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, list[str]]: """Send an email notification about an artifact or a vote.""" log.info(f"Sending email for event: {msg_data}") _reject_null_bytes( msg_data.email_sender, msg_data.email_recipient, + msg_data.email_cc, + msg_data.email_bcc, msg_data.subject, msg_data.body, msg_data.in_reply_to, @@ -63,8 +74,11 @@ async def send(msg_data: Message) -> tuple[str, list[str]]: from_addr = msg_data.email_sender if not from_addr.endswith(f"@{global_domain}"): raise ValueError(f"from_addr must end with @{global_domain}, got {from_addr}") - to_addr = msg_data.email_recipient - _validate_recipient(to_addr) + to_addrs = _validate_recipients(msg_data.email_recipient) + if len(to_addrs) < 1: + raise ValueError("email_recipient must contain at least one email address") + cc_addrs = _validate_recipients(msg_data.email_cc) + bcc_addrs = _validate_recipients(msg_data.email_bcc) # UUID4 is entirely random, with no timestamp nor namespace # It does have 6 version and variant bits, so only 122 bits are random @@ -75,10 +89,17 @@ async def send(msg_data: Message) -> tuple[str, list[str]]: try: from_local, from_domain = _split_address(from_addr) - to_local, to_domain = _split_address(to_addr) msg["From"] = headerregistry.Address(username=from_local, domain=from_domain) - msg["To"] = headerregistry.Address(username=to_local, domain=to_domain) + msg["To"] = tuple( + headerregistry.Address(username=local, domain=domain) + for local, domain in [_split_address(addr) for addr in to_addrs] + ) + if cc_addrs: + msg["Cc"] = tuple( + headerregistry.Address(username=local, domain=domain) + for local, domain in [_split_address(addr) for addr in cc_addrs] + ) msg["Subject"] = msg_data.subject msg["Date"] = utils.formatdate(usegmt=True) msg["Message-ID"] = f"<{mid}>" @@ -92,19 +113,21 @@ async def send(msg_data: Message) -> tuple[str, list[str]]: return mid, [f"CRLF injection detected: {e}"] # Set the email body (handles RFC-compliant line endings automatically) - msg.set_content(msg_data.body.strip()) + msg.set_content(_body_with_footer(msg_data.body.strip(), category, from_addr)) start = time.perf_counter() # Convert to string to satisfy the existing _send_many function signature msg_text = msg.as_string() log.info(f"sending message: {msg_text}") - errors = await _send_many(from_addr, [to_addr], msg_text) + # BCC recipients go in the SMTP envelope only, never in message headers + all_envelope_recipients = to_addrs + cc_addrs + bcc_addrs + errors = await _send_many(from_addr, all_envelope_recipients, msg_text) if not errors: - log.info(f"Sent to {to_addr} successfully") + log.info(f"Sent to {all_envelope_recipients} successfully") else: - log.warning(f"Errors sending to {to_addr}: {errors}") + log.warning(f"Errors sending to {all_envelope_recipients}: {errors}") elapsed = time.perf_counter() - start log.info(f"Time taken to _send_many: {elapsed:.3f}s") @@ -112,6 +135,18 @@ async def send(msg_data: Message) -> tuple[str, list[str]]: return mid, errors +def _body_with_footer(body: str, category: MailFooterCategory, from_addr: str) -> str: + # TODO: AM need to get the domain but we don't have that apart from in a request context currently. + match category: + case MailFooterCategory.NONE: + return body + case MailFooterCategory.USER: + asf_uid, _ = _split_address(from_addr) + return f"{body}\n\nThis email was sent by {asf_uid}@apache.org on the Apache Trusted Releases platform" + case MailFooterCategory.AUTO: + return f"{body}\n\nThis email was sent from automation on the Apache Trusted Releases platform" + + def _reject_null_bytes(*values: str | None) -> None: for value in values: if (value is not None) and ("\x00" in value): @@ -135,8 +170,6 @@ async def _send_many(from_addr: str, to_addrs: list[str], msg_text: str) -> list async def _send_via_relay(from_addr: str, to_addr: str, msg_bytes: bytes) -> None: """Send an 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 @@ -170,12 +203,19 @@ def _split_address(addr: str) -> tuple[str, str]: return parts[0], parts[1] -def _validate_recipient(to_addr: str) -> None: +def _validate_recipients(to_addr: str) -> list[str]: # Ensure recipient is @apache.org or @tooling.apache.org - _, domain = _split_address(to_addr) - domain_is_apache = domain == "apache.org" - domain_is_subdomain = domain.endswith(".apache.org") - if not (domain_is_apache or domain_is_subdomain): - error_msg = f"Email recipient must be @apache.org or @*.apache.org, got {to_addr}" - log.error(error_msg) - raise ValueError(error_msg) + if not to_addr.strip(): + return [] + emails: list[str] = [] + for mail in to_addr.split(","): + mail = mail.strip() + _, domain = _split_address(mail) + domain_is_apache = domain == "apache.org" + domain_is_subdomain = domain.endswith(".apache.org") + if not (domain_is_apache or domain_is_subdomain): + error_msg = f"Email recipient must be @apache.org or @*.apache.org, got {mail}" + log.error(error_msg) + raise ValueError(error_msg) + emails.append(mail) + return emails diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py index c1390220..b410c774 100644 --- a/atr/storage/writers/announce.py +++ b/atr/storage/writers/announce.py @@ -29,6 +29,7 @@ import sqlmodel import atr.construct as construct import atr.db as db +import atr.mail as mail import atr.models.safe as safe import atr.models.sql as sql import atr.paths as paths @@ -234,6 +235,7 @@ class CommitteeMember(CommitteeParticipant): subject=subject, body=body, in_reply_to=None, + footer_category=mail.MailFooterCategory.NONE, ).model_dump(), asf_uid=asf_uid, project_key=str(project_key), diff --git a/atr/storage/writers/mail.py b/atr/storage/writers/mail.py index d80dcc09..78b98033 100644 --- a/atr/storage/writers/mail.py +++ b/atr/storage/writers/mail.py @@ -52,7 +52,7 @@ class FoundationCommitter(GeneralPublic): raise storage.AccessError("Not authorized") self.__asf_uid = asf_uid - async def send(self, message: mail.Message) -> tuple[str, list[str]]: + async def send(self, message: mail.Message, category: mail.MailFooterCategory) -> tuple[str, list[str]]: is_dev = util.is_dev_environment() if is_dev: @@ -60,7 +60,7 @@ class FoundationCommitter(GeneralPublic): mid = util.DEV_TEST_MID errors: list[str] = [] else: - mid, errors = await mail.send(message) + mid, errors = await mail.send(message, category) self.__write_as.append_to_audit_log( sent=not is_dev, diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py index 278820b1..0a211bf6 100644 --- a/atr/storage/writers/tokens.py +++ b/atr/storage/writers/tokens.py @@ -83,7 +83,7 @@ class FoundationCommitter(GeneralPublic): body=f"In ATR a new API token called '{label}' was created for your account. " "If you did not create this token, please revoke it immediately.", ) - await self.__write_as.mail.send(message) + await self.__write_as.mail.send(message, mail.MailFooterCategory.AUTO) return types.PersonalAccessTokenSafe.from_sql(pat) async def delete_token(self, token_id: int) -> None: @@ -108,7 +108,7 @@ class FoundationCommitter(GeneralPublic): body=f"In ATR an API token called '{label}' was deleted from your account. " "If you did not delete this token, please check your account immediately.", ) - await self.__write_as.mail.send(message) + await self.__write_as.mail.send(message, mail.MailFooterCategory.AUTO) async def issue_jwt(self, pat_text: str) -> str: pat_hash = hashlib.sha3_256(pat_text.encode()).hexdigest() diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py index 52407cd4..ae5f69da 100644 --- a/atr/storage/writers/vote.py +++ b/atr/storage/writers/vote.py @@ -25,6 +25,7 @@ import atr.construct as construct import atr.db as db import atr.db.interaction as interaction import atr.log as log +import atr.mail as mail import atr.models.results as results import atr.models.safe as safe import atr.models.sql as sql @@ -123,6 +124,7 @@ class CommitteeParticipant(FoundationCommitter): subject=subject, body=body_text, in_reply_to=in_reply_to, + footer_category=mail.MailFooterCategory.USER, ).model_dump(), asf_uid=self.__asf_uid, project_key=release.project.key, @@ -467,6 +469,7 @@ class CommitteeMember(CommitteeParticipant): subject=subject, body=body, in_reply_to=in_reply_to, + footer_category=mail.MailFooterCategory.USER, ).model_dump(), asf_uid=asf_uid, project_key=release.project.key, @@ -483,6 +486,7 @@ class CommitteeMember(CommitteeParticipant): subject=subject, body=body, in_reply_to=extra_destination[1], + footer_category=mail.MailFooterCategory.USER, ).model_dump(), asf_uid=asf_uid, project_key=release.project.key, diff --git a/atr/tasks/message.py b/atr/tasks/message.py index 2cf33f04..1fb716e6 100644 --- a/atr/tasks/message.py +++ b/atr/tasks/message.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +from typing import Annotated, Any import pydantic @@ -26,6 +27,15 @@ import atr.storage as storage import atr.tasks.checks as checks +def _ensure_footer_enum(value: Any) -> mail.MailFooterCategory | None: + if isinstance(value, mail.MailFooterCategory): + return value + if isinstance(value, str): + return mail.MailFooterCategory(value) + else: + return None + + class Send(schema.Strict): """Arguments for the task to send an email.""" @@ -34,6 +44,9 @@ class Send(schema.Strict): subject: str = schema.description("The subject of the email") body: str = schema.description("The body of the email") in_reply_to: str | None = schema.description("The message ID of the email to reply to") + footer_category: Annotated[mail.MailFooterCategory, pydantic.BeforeValidator(_ensure_footer_enum)] = ( + schema.description("The category of email footer to include") + ) class SendError(Exception): @@ -71,9 +84,11 @@ async def send(args: Send) -> results.Results | None: in_reply_to=args.in_reply_to, ) + footer_category = mail.MailFooterCategory(args.footer_category) + async with storage.write(sender_asf_uid) as write: wafc = write.as_foundation_committer() - mid, mail_errors = await wafc.mail.send(message) + mid, mail_errors = await wafc.mail.send(message, footer_category) if mail_errors: log.warning(f"Mail sending to {args.email_recipient} for subject '{args.subject}' encountered errors:") diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py index d30db898..d126135a 100644 --- a/atr/tasks/vote.py +++ b/atr/tasks/vote.py @@ -125,7 +125,7 @@ async def _initiate_core_logic(args: Initiate) -> results.Results | None: async with storage.write(args.initiator_id) as write: wafc = write.as_foundation_committer() - mid, mail_errors = await wafc.mail.send(message) + mid, mail_errors = await wafc.mail.send(message, mail.MailFooterCategory.USER) # Original success message structure result = results.VoteInitiate( diff --git a/tests/unit/test_mail.py b/tests/unit/test_mail.py index 14b2fa76..5957527e 100644 --- a/tests/unit/test_mail.py +++ b/tests/unit/test_mail.py @@ -43,7 +43,7 @@ async def test_address_objects_used_for_from_to_headers(monkeypatch: "MonkeyPatc body="Test body", ) - _, errors = await mail.send(legitimate_message) + _, errors = await mail.send(legitimate_message, mail.MailFooterCategory.NONE) # Verify the message was sent successfully assert len(errors) == 0 @@ -73,7 +73,7 @@ async def test_send_accepts_legitimate_message(monkeypatch: "MonkeyPatch") -> No ) # Call send - mid, errors = await mail.send(legitimate_message) + mid, errors = await mail.send(legitimate_message, mail.MailFooterCategory.NONE) # Assert that no errors were returned assert len(errors) == 0 @@ -107,7 +107,7 @@ async def test_send_accepts_message_with_reply_to(monkeypatch: "MonkeyPatch") -> ) # Call send - mid, errors = await mail.send(legitimate_message) + mid, errors = await mail.send(legitimate_message, mail.MailFooterCategory.NONE) # Assert that no errors were returned assert len(errors) == 0 @@ -134,7 +134,7 @@ async def test_send_handles_non_ascii_headers(monkeypatch: "MonkeyPatch") -> Non ) # Call send - _mid, errors = await mail.send(message_with_unicode) + _mid, errors = await mail.send(message_with_unicode, mail.MailFooterCategory.NONE) # Assert that no errors were returned assert len(errors) == 0 @@ -164,7 +164,7 @@ async def test_send_rejects_bcc_header_injection(monkeypatch: "MonkeyPatch") -> ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -189,7 +189,7 @@ async def test_send_rejects_content_type_injection(monkeypatch: "MonkeyPatch") - ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -214,7 +214,7 @@ async def test_send_rejects_cr_only_injection(monkeypatch: "MonkeyPatch") -> Non ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -244,7 +244,7 @@ async def test_send_rejects_crlf_in_from_address(monkeypatch: "MonkeyPatch") -> # Call send and expect it to raise ValueError due to invalid from_addr format with pytest.raises(ValueError, match=r"from_addr must end with @apache.org"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that _send_many was never called mock_send_many.assert_not_called() @@ -266,7 +266,7 @@ async def test_send_rejects_crlf_in_reply_to(monkeypatch: "MonkeyPatch") -> None ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -292,7 +292,7 @@ async def test_send_rejects_crlf_in_subject(monkeypatch: "MonkeyPatch") -> None: ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -322,7 +322,7 @@ async def test_send_rejects_crlf_in_to_address(monkeypatch: "MonkeyPatch") -> No # Call send and expect it to raise ValueError due to invalid recipient format with pytest.raises(ValueError, match=r"CR/LF"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that _send_many was never called mock_send_many.assert_not_called() @@ -343,7 +343,7 @@ async def test_send_rejects_lf_only_injection(monkeypatch: "MonkeyPatch") -> Non ) # Call send and expect it to catch the injection - _, errors = await mail.send(malicious_message) + _, errors = await mail.send(malicious_message, mail.MailFooterCategory.NONE) # Assert that the function returned an error assert len(errors) == 1 @@ -367,7 +367,7 @@ async def test_send_rejects_null_byte_in_body(monkeypatch: "MonkeyPatch") -> Non ) with pytest.raises(ValueError, match=r"null bytes"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) mock_send_many.assert_not_called() @@ -386,7 +386,7 @@ async def test_send_rejects_null_byte_in_from_address(monkeypatch: "MonkeyPatch" ) with pytest.raises(ValueError, match=r"null bytes"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) mock_send_many.assert_not_called() @@ -406,7 +406,7 @@ async def test_send_rejects_null_byte_in_reply_to(monkeypatch: "MonkeyPatch") -> ) with pytest.raises(ValueError, match=r"null bytes"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) mock_send_many.assert_not_called() @@ -425,7 +425,7 @@ async def test_send_rejects_null_byte_in_subject(monkeypatch: "MonkeyPatch") -> ) with pytest.raises(ValueError, match=r"null bytes"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) mock_send_many.assert_not_called() @@ -444,7 +444,7 @@ async def test_send_rejects_null_byte_in_to_address(monkeypatch: "MonkeyPatch") ) with pytest.raises(ValueError, match=r"null bytes"): - await mail.send(malicious_message) + await mail.send(malicious_message, mail.MailFooterCategory.NONE) mock_send_many.assert_not_called() @@ -497,3 +497,237 @@ def test_split_address_rejects_null_byte() -> None: """Test that _split_address rejects addresses containing null bytes.""" with pytest.raises(ValueError, match=r"null bytes"): mail._split_address("user\[email protected]") + + [email protected] +async def test_send_rejects_null_byte_in_cc(monkeypatch: "MonkeyPatch") -> None: + """Test that null bytes in the CC field are rejected.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + malicious_message = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Test Subject", + body="This is a test message", + email_cc="cc\[email protected]", + ) + + with pytest.raises(ValueError, match=r"null bytes"): + await mail.send(malicious_message, mail.MailFooterCategory.NONE) + + mock_send_many.assert_not_called() + + [email protected] +async def test_send_rejects_null_byte_in_bcc(monkeypatch: "MonkeyPatch") -> None: + """Test that null bytes in the BCC field are rejected.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + malicious_message = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Test Subject", + body="This is a test message", + email_bcc="bcc\[email protected]", + ) + + with pytest.raises(ValueError, match=r"null bytes"): + await mail.send(malicious_message, mail.MailFooterCategory.NONE) + + mock_send_many.assert_not_called() + + [email protected] +async def test_send_rejects_crlf_in_cc_address(monkeypatch: "MonkeyPatch") -> None: + """Test that CRLF injection in the CC address field is rejected. + + An attacker supplying CC addresses controls what goes into the Cc header, + so CR/LF must be caught before address objects are constructed. + """ + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + malicious_message = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Test Subject", + body="This is a test message", + email_cc="[email protected]\r\nBcc: [email protected]", + ) + + with pytest.raises(ValueError, match=r"CR/LF"): + await mail.send(malicious_message, mail.MailFooterCategory.NONE) + + mock_send_many.assert_not_called() + + [email protected] +async def test_send_rejects_crlf_in_bcc_address(monkeypatch: "MonkeyPatch") -> None: + """Test that CRLF injection in the BCC address field is rejected.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + malicious_message = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Test Subject", + body="This is a test message", + email_bcc="[email protected]\r\nTo: [email protected]", + ) + + with pytest.raises(ValueError, match=r"CR/LF"): + await mail.send(malicious_message, mail.MailFooterCategory.NONE) + + mock_send_many.assert_not_called() + + [email protected] +async def test_send_multiple_to_addresses(monkeypatch: "MonkeyPatch") -> None: + """Test that multiple comma-separated To addresses all appear in the header and envelope.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected], [email protected]", + subject="Multi-recipient test", + body="Hello both.", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.NONE) + + assert len(errors) == 0 + call_args = mock_send_many.call_args + envelope_recipients = call_args[0][1] + msg_text = call_args[0][2] + + # Both addresses must be in the SMTP envelope + assert "[email protected]" in envelope_recipients + assert "[email protected]" in envelope_recipients + + # Both addresses must appear in the To header + assert "[email protected]" in msg_text + assert "[email protected]" in msg_text + + [email protected] +async def test_send_cc_appears_in_header_and_envelope(monkeypatch: "MonkeyPatch") -> None: + """Test that CC addresses appear in the Cc header and the SMTP envelope.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="CC test", + body="Hello.", + email_cc="[email protected]", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.NONE) + + assert len(errors) == 0 + call_args = mock_send_many.call_args + envelope_recipients = call_args[0][1] + msg_text = call_args[0][2] + + # CC must be in the SMTP envelope + assert "[email protected]" in envelope_recipients + + # CC must appear in a Cc header, not only To + assert "Cc: [email protected]" in msg_text + + [email protected] +async def test_send_bcc_in_envelope_not_in_headers(monkeypatch: "MonkeyPatch") -> None: + """Test that BCC addresses are in the SMTP envelope but absent from all message headers.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="BCC test", + body="Hello.", + email_bcc="[email protected]", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.NONE) + + assert len(errors) == 0 + call_args = mock_send_many.call_args + envelope_recipients = call_args[0][1] + msg_text = call_args[0][2] + + # BCC must be in the SMTP envelope + assert "[email protected]" in envelope_recipients + + # BCC must not appear anywhere in the message headers + assert "[email protected]" not in msg_text + + [email protected] +async def test_send_empty_cc_bcc_omits_cc_header(monkeypatch: "MonkeyPatch") -> None: + """Test that omitting CC/BCC produces no Cc header and only To recipients in envelope.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="No CC/BCC test", + body="Hello.", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.NONE) + + assert len(errors) == 0 + call_args = mock_send_many.call_args + envelope_recipients = call_args[0][1] + msg_text = call_args[0][2] + + assert envelope_recipients == ["[email protected]"] + assert "Cc:" not in msg_text + assert "Bcc:" not in msg_text + + [email protected] +async def test_footer_user_appended_to_body(monkeypatch: "MonkeyPatch") -> None: + """Test that USER category appends a footer attributing the sending user.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Footer test", + body="Hello.", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.USER) + + assert len(errors) == 0 + msg_text = mock_send_many.call_args[0][2] + assert "This email was sent by [email protected] on the Apache Trusted Releases platform" in msg_text + + [email protected] +async def test_footer_auto_appended_to_body(monkeypatch: "MonkeyPatch") -> None: + """Test that AUTO category appends an automation footer without a user attribution.""" + mock_send_many = mock.AsyncMock(return_value=[]) + monkeypatch.setattr("atr.mail._send_many", mock_send_many) + + msg = mail.Message( + email_sender="[email protected]", + email_recipient="[email protected]", + subject="Footer test", + body="Hello.", + ) + + _, errors = await mail.send(msg, mail.MailFooterCategory.AUTO) + + assert len(errors) == 0 + msg_text = mock_send_many.call_args[0][2] + assert "This email was sent from automation on the Apache Trusted Releases platform" in msg_text diff --git a/tests/unit/test_message.py b/tests/unit/test_message.py index 6f742dac..4abbd64a 100644 --- a/tests/unit/test_message.py +++ b/tests/unit/test_message.py @@ -25,6 +25,7 @@ import pydantic import pytest import atr.ldap as ldap +import atr.mail as mail import atr.tasks.message as message if TYPE_CHECKING: @@ -107,6 +108,9 @@ async def test_send_succeeds_with_valid_asf_id(monkeypatch: "MonkeyPatch") -> No mock_mail_send.assert_called_once() +"""Build an argument dict matching the Send schema.""" + + def _send_args( email_sender: str = "[email protected]", email_recipient: str = "[email protected]", @@ -114,11 +118,11 @@ def _send_args( body: str = "Test body", in_reply_to: str | None = None, ) -> dict[str, str | None]: - """Build an argument dict matching the Send schema.""" return { "email_sender": email_sender, "email_recipient": email_recipient, "subject": subject, "body": body, "in_reply_to": in_reply_to, + "footer_category": mail.MailFooterCategory.NONE, } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
