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]

Reply via email to