You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/03/24 16:28:32 UTC
(tooling-trusted-releases) 01/01: Allow the use of CC and BCC for voting and announcing
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 7a3dc4d368e010e18fa824a1633c881f1c26b603
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue Mar 24 16:27:44 2026 +0000
Allow the use of CC and BCC for voting and announcing
---
atr/api/__init__.py | 4 +-
atr/db/interaction.py | 4 +-
atr/get/announce.py | 67 ++---
atr/get/voting.py | 22 +-
atr/mail.py | 66 ++---
atr/models/results.py | 2 +-
atr/post/announce.py | 18 +-
atr/post/vote.py | 7 +-
atr/post/voting.py | 20 +-
atr/render.py | 55 ++++
atr/shared/announce.py | 15 +-
atr/shared/voting.py | 19 +-
atr/static/css/atr.css | 5 +
atr/storage/writers/announce.py | 20 +-
atr/storage/writers/mail.py | 4 +-
atr/storage/writers/tokens.py | 4 +-
atr/storage/writers/vote.py | 44 ++-
atr/tasks/message.py | 24 +-
atr/tasks/vote.py | 30 +-
atr/util.py | 33 ++-
tests/unit/test_mail.py | 601 +++++++++++++++++++++++-----------------
tests/unit/test_message.py | 6 +-
22 files changed, 653 insertions(+), 417 deletions(-)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 0c348c6e..206830d5 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -889,7 +889,7 @@ async def publisher_release_announce(
project_key=project.safe_key,
version_key=data.version,
preview_revision_number=data.revision,
- recipient=data.email_to,
+ email_to=data.email_to,
body=data.body,
download_path_suffix=data.path_suffix,
asf_uid=asf_uid,
@@ -995,7 +995,7 @@ async def release_announce(
project_key=data.project,
version_key=data.version,
preview_revision_number=data.revision,
- recipient=data.email_to,
+ email_to=data.email_to,
body=data.body,
download_path_suffix=data.path_suffix,
asf_uid=asf_uid,
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index f18a1a93..006e1d8d 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -399,7 +399,9 @@ def task_recipient_get(latest_vote_task: sql.Task) -> str | None:
result = latest_vote_task.result
if not isinstance(result, results.VoteInitiate):
return None
- return result.email_to
+ if not result.email_to:
+ return None
+ return result.email_to[0]
async def tasks_ongoing(
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 5a8055a0..32e765c9 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -90,11 +90,10 @@ async def selected(
description_download_prefix += f"/{committee.key}"
permitted_recipients = util.permitted_announce_recipients(session.uid)
- mailing_list_choices = sorted([(recipient, recipient) for recipient in permitted_recipients])
content = await _render_page(
release=release,
- mailing_list_choices=mailing_list_choices,
+ permitted_recipients=permitted_recipients,
default_subject=default_subject,
subject_template_hash=subject_template_hash,
default_body=default_body,
@@ -157,48 +156,9 @@ def _render_download_path_field(default_value: str, description: str) -> htm.Ele
]
-def _render_mailing_list_with_warning(choices: list[tuple[str, str]], default_value: str) -> htm.Element:
- """Render the mailing list radio buttons with a warning card."""
- container = htm.Block(htm.div)
-
- # Radio buttons
- radio_container = htm.div(".d-flex.flex-wrap.gap-2.mb-3")
- radio_buttons = []
- for value, label in choices:
- radio_id = f"mailing_list_{value}"
- radio_attrs = {
- "type": "radio",
- "name": "mailing_list",
- "value": value,
- }
- if value == default_value:
- radio_attrs["checked"] = ""
-
- radio_buttons.append(
- htm.div(".form-check")[
- htpy.input(f"#{radio_id}.form-check-input", **radio_attrs),
- htpy.label(".form-check-label", for_=radio_id)[label],
- ]
- )
- container.append(radio_container[radio_buttons])
-
- # Warning card
- warning_card = htm.div(".card.bg-warning-subtle.mb-3")[
- htm.span(".card-body.p-3")[
- htpy.i(".bi.bi-exclamation-triangle.me-1"),
- htm.strong["TODO: "],
- "The limited options above are provided for testing purposes. In the finished version of ATR, "
- "you will be able to send to your own specified mailing lists.",
- ]
- ]
- container.append(warning_card)
-
- return container.collect()
-
-
async def _render_page(
release: sql.Release,
- mailing_list_choices: list[tuple[str, str]],
+ permitted_recipients: list[str],
default_subject: str,
subject_template_hash: str,
default_body: str,
@@ -246,11 +206,27 @@ async def _render_page(
}"
if not announce_msg:
- page.p[f"This form will send an announcement to the ASF {util.USER_TESTS_ADDRESS} mailing list."]
+ page.p["This form will send an announcement to the selected recipients."]
custom_subject_widget = _render_subject_field(default_subject, release.project.key)
custom_body_widget = _render_body_field(default_body, release.project.key)
- custom_mailing_list_widget = _render_mailing_list_with_warning(mailing_list_choices, util.USER_TESTS_ADDRESS)
+ default_to = permitted_recipients[0] if permitted_recipients else None
+ to_radios = htm.div[
+ render.html_recipients_to_radios(
+ permitted_recipients,
+ default_to=default_to,
+ documentation=(
+ "Note: The options to send to the user-tests "
+ "mailing list and yourself are provided for "
+ "testing purposes only, and will not be "
+ "available in the finished version of ATR."
+ ),
+ ),
+ htpy.details(".mt-2")[
+ htpy.summary["Select CC and BCC recipients"],
+ render.html_recipients_cc_bcc_table(permitted_recipients),
+ ],
+ ]
# Custom widget for download_path_suffix with custom documentation
download_path_widget = _render_download_path_field(default_download_path_suffix, download_path_description)
@@ -268,11 +244,12 @@ async def _render_page(
submit_label="Send announcement email",
defaults=defaults_dict,
custom={
+ "email_to": to_radios,
"subject": custom_subject_widget,
"body": custom_body_widget,
- "mailing_list": custom_mailing_list_widget,
"download_path_suffix": download_path_widget,
},
+ skip=["email_cc", "email_bcc"],
form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
border=True,
wider_widgets=True,
diff --git a/atr/get/voting.py b/atr/get/voting.py
index f742252c..22c4ca3a 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -192,21 +192,41 @@ async def _render_page(
custom_subject_widget = _render_subject_field(default_subject, release.project.key)
custom_body_widget = _render_body_field(default_body, release.project.key)
+ default_to = permitted_recipients[0] if permitted_recipients else None
+ to_radios = htm.div[
+ render.html_recipients_to_radios(
+ permitted_recipients,
+ default_to=default_to,
+ documentation=(
+ "Note: The options to send to the user-tests "
+ "mailing list and yourself are provided for "
+ "testing purposes only, and will not be "
+ "available in the finished version of ATR. "
+ "If the option you pick is not a mailing list, "
+ "you will not be able to use vote tabulation."
+ ),
+ ),
+ htpy.details(".mt-2")[
+ htpy.summary["Select CC and BCC recipients"],
+ render.html_recipients_cc_bcc_table(permitted_recipients),
+ ],
+ ]
vote_form = form.render(
model_cls=shared.voting.StartVotingForm,
submit_label="Send vote email",
cancel_url=cancel_url,
defaults={
- "mailing_list": permitted_recipients,
"vote_duration": min_hours,
"subject_template_hash": subject_template_hash,
"body": default_body,
},
custom={
+ "email_to": to_radios,
"subject": custom_subject_widget,
"body": custom_body_widget,
},
+ skip=["email_cc", "email_bcc"],
)
page.append(vote_form)
diff --git a/atr/mail.py b/atr/mail.py
index 7d59d33c..3988a21c 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -30,6 +30,7 @@ import aiosmtplib
# import dkim
import atr.log as log
+import atr.util as util
# TODO: We should choose a pattern for globals
# We could e.g. use uppercase instead of global_
@@ -51,12 +52,12 @@ class MailFooterCategory(enum.StrEnum):
@dataclasses.dataclass
class Message:
email_sender: str
- email_recipient: str
+ email_to: str
subject: str
body: str
- email_cc: str = ""
- email_bcc: str = ""
in_reply_to: str | None = None
+ email_cc: list[str] = dataclasses.field(default_factory=list)
+ email_bcc: list[str] = dataclasses.field(default_factory=list)
async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, list[str]]:
@@ -64,9 +65,9 @@ async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, li
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.email_to,
+ *msg_data.email_cc,
+ *msg_data.email_bcc,
msg_data.subject,
msg_data.body,
msg_data.in_reply_to,
@@ -74,11 +75,10 @@ async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, li
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_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)
+ util.validate_email_recipients(msg_data)
+ all_envelope_recipients = [msg_data.email_to, *msg_data.email_cc, *msg_data.email_bcc]
+ for addr in all_envelope_recipients:
+ _validate_recipient(addr)
# UUID4 is entirely random, with no timestamp nor namespace
# It does have 6 version and variant bits, so only 122 bits are random
@@ -89,17 +89,11 @@ async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, li
try:
from_local, from_domain = _split_address(from_addr)
-
msg["From"] = headerregistry.Address(username=from_local, domain=from_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]
- )
+ to_local, to_domain = _split_address(msg_data.email_to)
+ msg["To"] = headerregistry.Address(username=to_local, domain=to_domain)
+ if msg_data.email_cc:
+ msg["Cc"] = _address_header(msg_data.email_cc)
msg["Subject"] = msg_data.subject
msg["Date"] = utils.formatdate(usegmt=True)
msg["Message-ID"] = f"<{mid}>"
@@ -121,7 +115,6 @@ async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, li
log.info(f"sending message: {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:
@@ -135,6 +128,11 @@ async def send(msg_data: Message, category: MailFooterCategory) -> tuple[str, li
return mid, errors
+def _address_header(addresses: list[str]) -> str:
+ parts = [headerregistry.Address(username=local, domain=domain) for local, domain in map(_split_address, addresses)]
+ return ", ".join(str(a) for a in parts)
+
+
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:
@@ -203,19 +201,11 @@ def _split_address(addr: str) -> tuple[str, str]:
return parts[0], parts[1]
-def _validate_recipients(to_addr: str) -> list[str]:
- # Ensure recipient is @apache.org or @tooling.apache.org
- 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
+def _validate_recipient(addr: str) -> None:
+ _, domain = _split_address(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 {addr}"
+ log.error(error_msg)
+ raise ValueError(error_msg)
diff --git a/atr/models/results.py b/atr/models/results.py
index b3f14a7f..acdf0078 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -218,7 +218,7 @@ class VoteInitiate(schema.Strict):
kind: Literal["vote_initiate"] = schema.Field(alias="kind")
message: str = schema.description("The message from the vote initiation")
- email_to: str = schema.description("The email address the vote was sent to")
+ email_to: str = schema.description("The email To address the vote was sent to")
vote_end: str = schema.description("The date and time the vote ends")
subject: str = schema.description("The subject of the vote email")
mid: str | None = schema.description("The message ID of the vote email")
diff --git a/atr/post/announce.py b/atr/post/announce.py
index 4c625d91..5bafcc59 100644
--- a/atr/post/announce.py
+++ b/atr/post/announce.py
@@ -44,12 +44,14 @@ async def selected(
"""
permitted_recipients = util.permitted_announce_recipients(session.uid)
- # Validate that the recipient is permitted
- if announce_form.mailing_list not in permitted_recipients:
- return await session.form_error(
- "mailing_list",
- f"You are not permitted to send announcements to {announce_form.mailing_list}",
- )
+ # Validate that the recipients are permitted
+ all_addrs = [announce_form.email_to, *announce_form.email_cc, *announce_form.email_bcc]
+ for addr in all_addrs:
+ if addr not in permitted_recipients:
+ return await session.form_error(
+ "email_to",
+ f"You are not permitted to send announcements to {addr}",
+ )
# Get the release to find the revision number
release = await session.release(
@@ -106,12 +108,14 @@ async def selected(
project_key=project_key,
version_key=version_key,
preview_revision_number=preview_revision_number,
- recipient=announce_form.mailing_list,
+ email_to=announce_form.email_to,
body=announce_form.body,
download_path_suffix=announce_form.download_path_suffix,
asf_uid=session.uid,
fullname=session.fullname,
subject_template_hash=announce_form.subject_template_hash,
+ email_cc=announce_form.email_cc,
+ email_bcc=announce_form.email_bcc,
)
except storage.AccessError as e:
return await session.redirect(
diff --git a/atr/post/vote.py b/atr/post/vote.py
index 1812befd..74a5ca40 100644
--- a/atr/post/vote.py
+++ b/atr/post/vote.py
@@ -25,6 +25,7 @@ import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
import atr.user as user
+import atr.util as util
import atr.web as web
@@ -52,14 +53,12 @@ async def selected_post(
is_binding, _binding_committee = await shared.vote.is_binding(release.committee, is_pmc_member)
async with storage.write_as_committee_participant(release.committee.key) as wacm:
- email_recipient, error_message = await wacm.vote.send_user_vote(
- release, vote, comment, session.fullname, is_binding
- )
+ email_to, error_message = await wacm.vote.send_user_vote(release, vote, comment, session.fullname, is_binding)
if error_message:
await quart.flash(error_message, "error")
return await session.redirect(get.vote.selected, project_key=str(project_key), version_key=str(version_key))
- success_message = f"Sending your vote to {email_recipient}."
+ success_message = f"Sending your vote to {util.conjunction(email_to)}."
await quart.flash(success_message, "success")
return await session.redirect(get.vote.selected, project_key=str(project_key), version_key=str(version_key))
diff --git a/atr/post/voting.py b/atr/post/voting.py
index 17f4e102..d7bbd818 100644
--- a/atr/post/voting.py
+++ b/atr/post/voting.py
@@ -95,11 +95,13 @@ async def selected_revision(
pass
permitted_recipients = util.permitted_voting_recipients(session.uid, committee.key)
- if start_voting_form.mailing_list not in permitted_recipients:
- return await session.form_error(
- "mailing_list",
- f"Invalid mailing list selection: {start_voting_form.mailing_list}",
- )
+ all_addrs = [start_voting_form.email_to, *start_voting_form.email_cc, *start_voting_form.email_bcc]
+ for addr in all_addrs:
+ if addr not in permitted_recipients:
+ return await session.form_error(
+ "email_to",
+ f"Invalid recipient selection: {addr}",
+ )
subject_template = await construct.start_vote_subject_default(project_key)
current_hash = construct.template_hash(subject_template)
@@ -122,7 +124,7 @@ async def selected_revision(
async with storage.write_as_committee_participant(committee.key) as wacp:
_task = await wacp.vote.start(
- start_voting_form.mailing_list,
+ start_voting_form.email_to,
project_key,
version_key,
revision,
@@ -134,12 +136,14 @@ async def selected_revision(
release=release,
promote=True,
permitted_recipients=permitted_recipients,
+ email_cc=start_voting_form.email_cc,
+ email_bcc=start_voting_form.email_bcc,
)
- log.info(f"Vote email will be sent to: {start_voting_form.mailing_list}")
+ log.info(f"Vote email will be sent to: {all_addrs}")
return await session.redirect(
get.vote.selected,
- success=f"The vote announcement email will soon be sent to {start_voting_form.mailing_list}.",
+ success=f"The vote announcement email will soon be sent to {start_voting_form.email_to}.",
project_key=str(project_key),
version_key=str(version_key),
)
diff --git a/atr/render.py b/atr/render.py
index 8c8a1eda..4b96dd67 100644
--- a/atr/render.py
+++ b/atr/render.py
@@ -17,6 +17,8 @@
from typing import Literal
+import htpy
+
import atr.get as get
import atr.htm as htm
import atr.util as util
@@ -69,3 +71,56 @@ def html_nav_phase(block: htm.Block, project: str, version: str, staging: bool)
back_anchor=f"{label.title()} {project} {version}",
phase=label,
)
+
+
+def html_recipients_cc_bcc_table(recipients: list[str]) -> htm.Element:
+ header = htpy.thead[
+ htpy.tr[
+ htpy.th(".text-center.atr-checkbox-col")["CC"],
+ htpy.th(".text-center.atr-checkbox-col")["BCC"],
+ htpy.th["Recipient"],
+ ]
+ ]
+ rows = []
+ for recipient in recipients:
+ rows.append(
+ htpy.tr[
+ htpy.td(".text-center.atr-checkbox-col")[
+ htpy.input(type="checkbox", name="email_cc", value=recipient, class_="form-check-input")
+ ],
+ htpy.td(".text-center.atr-checkbox-col")[
+ htpy.input(type="checkbox", name="email_bcc", value=recipient, class_="form-check-input")
+ ],
+ htpy.td[recipient],
+ ]
+ )
+ return htpy.table(".table.table-bordered.mb-0")[header, htpy.tbody[rows]]
+
+
+def html_recipients_to_radios(
+ recipients: list[str],
+ default_to: str | None = None,
+ documentation: str | None = None,
+) -> htm.Element:
+ radios = []
+ for recipient in recipients:
+ radio_id = f"email_to_{recipient.replace('@', '_').replace('.', '_')}"
+ radio_attrs: dict[str, str] = {
+ "type": "radio",
+ "name": "email_to",
+ "id": radio_id,
+ "value": recipient,
+ "class_": "form-check-input",
+ }
+ if recipient == default_to:
+ radio_attrs["checked"] = ""
+ radios.append(
+ htpy.div(".form-check")[
+ htpy.input(**radio_attrs),
+ htpy.label(".form-check-label", for_=radio_id)[recipient],
+ ]
+ )
+ container = htm.div[radios]
+ if documentation is None:
+ return container
+ return htm.div[container, htm.div(".text-muted.mt-1.form-text")[documentation]]
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
index e156cdbe..0490e17f 100644
--- a/atr/shared/announce.py
+++ b/atr/shared/announce.py
@@ -17,18 +17,20 @@
from typing import Literal
+import pydantic
+
import atr.form as form
import atr.models.safe as safe
+import atr.util as util
class AnnounceForm(form.Form):
"""Form for announcing a release preview."""
revision_number: safe.RevisionNumber = form.label("Revision number", widget=form.Widget.HIDDEN)
- mailing_list: str = form.label(
- "Send vote email to",
- widget=form.Widget.CUSTOM,
- )
+ email_to: str = form.label("To", widget=form.Widget.CUSTOM)
+ email_cc: form.StrList = form.label("CC")
+ email_bcc: form.StrList = form.label("BCC")
subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
body: str = form.label("Body", widget=form.Widget.CUSTOM)
@@ -37,3 +39,8 @@ class AnnounceForm(form.Form):
"Confirm",
"Type CONFIRM (in capitals) to enable the submit button.",
)
+
+ @pydantic.model_validator(mode="after")
+ def _validate_recipients(self) -> "AnnounceForm":
+ util.validate_email_recipients(self)
+ return self
diff --git a/atr/shared/voting.py b/atr/shared/voting.py
index eb14347d..dd44e20d 100644
--- a/atr/shared/voting.py
+++ b/atr/shared/voting.py
@@ -15,18 +15,16 @@
# specific language governing permissions and limitations
# under the License.
+import pydantic
+
import atr.form as form
+import atr.util as util
class StartVotingForm(form.Form):
- mailing_list: str = form.label(
- "Send vote email to",
- "Note: The options to send to the user-tests "
- "mailing list and yourself are provided for "
- "testing purposes only, and will not be "
- "available in the finished version of ATR.",
- widget=form.Widget.RADIO,
- )
+ email_to: str = form.label("To (mailing list)", widget=form.Widget.CUSTOM)
+ email_cc: form.StrList = form.label("CC")
+ email_bcc: form.StrList = form.label("BCC")
vote_duration: form.Int = form.label(
"Minimum vote duration",
"Minimum number of hours the vote will be open for.",
@@ -35,3 +33,8 @@ class StartVotingForm(form.Form):
subject: str = form.label("Subject", widget=form.Widget.CUSTOM)
subject_template_hash: str = form.label("Subject template hash", widget=form.Widget.HIDDEN)
body: str = form.label("Body", widget=form.Widget.CUSTOM)
+
+ @pydantic.model_validator(mode="after")
+ def _validate_recipients(self) -> "StartVotingForm":
+ util.validate_email_recipients(self)
+ return self
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 1d3313d9..f0ac4417 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -569,6 +569,11 @@ td.atr-shrink {
white-space: nowrap;
}
+th.atr-checkbox-col,
+td.atr-checkbox-col {
+ max-width: 25px;
+}
+
.atr-stripe-odd {
background-color: #f8f9fa;
}
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index b410c774..31345604 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -30,6 +30,7 @@ import sqlmodel
import atr.construct as construct
import atr.db as db
import atr.mail as mail
+import atr.models.basic as basic
import atr.models.safe as safe
import atr.models.sql as sql
import atr.paths as paths
@@ -108,15 +109,20 @@ class CommitteeMember(CommitteeParticipant):
project_key: safe.ProjectKey,
version_key: safe.VersionKey,
preview_revision_number: safe.RevisionNumber,
- recipient: str,
+ email_to: str,
body: str,
download_path_suffix: safe.RelPath | None,
asf_uid: str,
fullname: str,
subject_template_hash: str | None = None,
+ email_cc: list[str] | None = None,
+ email_bcc: list[str] | None = None,
) -> None:
- if recipient not in util.permitted_announce_recipients(asf_uid):
- raise storage.AccessError(f"You are not permitted to send announcements to {recipient}")
+ permitted = util.permitted_announce_recipients(asf_uid)
+ all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
+ for addr in all_addrs:
+ if addr not in permitted:
+ raise storage.AccessError(f"You are not permitted to send announcements to {addr}")
unfinished_dir: str = ""
finished_dir: str = ""
@@ -203,7 +209,9 @@ class CommitteeMember(CommitteeParticipant):
revision_number=str(preview_revision_number),
source_directory=unfinished_dir,
target_directory=finished_dir,
- email_recipient=recipient,
+ email_to=email_to,
+ email_cc=basic.as_json(email_cc or []),
+ email_bcc=basic.as_json(email_bcc or []),
)
if unfinished_revisions_path:
# This removes all of the prior revisions
@@ -231,10 +239,12 @@ class CommitteeMember(CommitteeParticipant):
task_type=sql.TaskType.MESSAGE_SEND,
task_args=message.Send(
email_sender=f"{asf_uid}@apache.org",
- email_recipient=recipient,
+ email_to=email_to,
subject=subject,
body=body,
in_reply_to=None,
+ email_cc=email_cc or [],
+ email_bcc=email_bcc or [],
footer_category=mail.MailFooterCategory.NONE,
).model_dump(),
asf_uid=asf_uid,
diff --git a/atr/storage/writers/mail.py b/atr/storage/writers/mail.py
index 78b98033..729d1276 100644
--- a/atr/storage/writers/mail.py
+++ b/atr/storage/writers/mail.py
@@ -56,7 +56,7 @@ class FoundationCommitter(GeneralPublic):
is_dev = util.is_dev_environment()
if is_dev:
- log.info(f"Dev environment detected, not sending email to {message.email_recipient}")
+ log.info(f"Dev environment detected, not sending email to {message.email_to}")
mid = util.DEV_TEST_MID
errors: list[str] = []
else:
@@ -65,7 +65,7 @@ class FoundationCommitter(GeneralPublic):
self.__write_as.append_to_audit_log(
sent=not is_dev,
email_sender=message.email_sender,
- email_recipient=message.email_recipient,
+ email_to=message.email_to,
subject=message.subject,
mid=mid,
in_reply_to=message.in_reply_to,
diff --git a/atr/storage/writers/tokens.py b/atr/storage/writers/tokens.py
index 0a211bf6..d835325a 100644
--- a/atr/storage/writers/tokens.py
+++ b/atr/storage/writers/tokens.py
@@ -78,7 +78,7 @@ class FoundationCommitter(GeneralPublic):
await self.__data.commit()
message = mail.Message(
email_sender=NOREPLY_EMAIL_ADDRESS,
- email_recipient=f"{self.__asf_uid}@apache.org",
+ email_to=f"{self.__asf_uid}@apache.org",
subject="ATR - New API Token Created",
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.",
@@ -103,7 +103,7 @@ class FoundationCommitter(GeneralPublic):
label = pat.label or "[unlabeled]"
message = mail.Message(
email_sender=NOREPLY_EMAIL_ADDRESS,
- email_recipient=f"{self.__asf_uid}@apache.org",
+ email_to=f"{self.__asf_uid}@apache.org",
subject="ATR - Deleted API Token",
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.",
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index ae5f69da..5eaf8418 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -85,25 +85,27 @@ class CommitteeParticipant(FoundationCommitter):
comment: str,
fullname: str,
is_binding: bool = False,
- ) -> tuple[str, str]:
+ ) -> tuple[list[str], str]:
# Get the email thread
latest_vote_task = await interaction.release_latest_vote_task(release)
if latest_vote_task is None:
- return "", "No vote task found."
+ return [], "No vote task found."
vote_thread_mid = interaction.task_mid_get(latest_vote_task)
if vote_thread_mid is None:
- return "", "No vote thread found."
+ return [], "No vote thread found."
# Construct the reply email
vote_result = latest_vote_task.result
if vote_result is None:
- return "", "Vote task has not completed yet."
+ return [], "Vote task has not completed yet."
if not isinstance(vote_result, results.VoteInitiate):
- return "", "Vote task result is not a VoteInitiate result."
+ return [], "Vote task result is not a VoteInitiate result."
original_subject = vote_result.subject
# Arguments for the task to cast a vote
- email_recipient = latest_vote_task.task_args["email_to"]
+ email_to: str = latest_vote_task.task_args["email_to"]
+ email_cc: list[str] = latest_vote_task.task_args.get("email_cc", [])
+ email_bcc: list[str] = latest_vote_task.task_args.get("email_bcc", [])
email_sender = f"{self.__asf_uid}@apache.org"
subject = f"Re: {original_subject}"
body_text = format_vote_email_body(
@@ -120,10 +122,12 @@ class CommitteeParticipant(FoundationCommitter):
task_type=sql.TaskType.MESSAGE_SEND,
task_args=message.Send(
email_sender=email_sender,
- email_recipient=email_recipient,
+ email_to=email_to,
subject=subject,
body=body_text,
in_reply_to=in_reply_to,
+ email_cc=email_cc,
+ email_bcc=email_bcc,
footer_category=mail.MailFooterCategory.USER,
).model_dump(),
asf_uid=self.__asf_uid,
@@ -134,7 +138,7 @@ class CommitteeParticipant(FoundationCommitter):
await self.__data.flush()
await self.__data.commit()
- return email_recipient, ""
+ return [email_to], ""
async def start(
self,
@@ -150,6 +154,8 @@ class CommitteeParticipant(FoundationCommitter):
release: sql.Release | None = None,
promote: bool = True,
permitted_recipients: list[str] | None = None,
+ email_cc: list[str] | None = None,
+ email_bcc: list[str] | None = None,
) -> sql.Task:
if release is None:
release = await self.__data.release(
@@ -160,10 +166,12 @@ class CommitteeParticipant(FoundationCommitter):
).demand(storage.AccessError("Release not found"))
if permitted_recipients is None:
permitted_recipients = util.permitted_voting_recipients(asf_uid, self.__committee_key)
- if email_to not in permitted_recipients:
- # This will be checked again by tasks/vote.py for extra safety
- log.info(f"Invalid mailing list choice: {email_to} not in {permitted_recipients}")
- raise storage.AccessError("Invalid mailing list choice")
+ all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
+ for addr in all_addrs:
+ if addr not in permitted_recipients:
+ # This will be checked again by tasks/vote.py for extra safety
+ log.info(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
+ raise storage.AccessError("Invalid mailing list choice")
if await interaction.has_blocker_checks(release, selected_revision_number, caller_data=self.__data):
raise storage.AccessError(
@@ -197,6 +205,8 @@ class CommitteeParticipant(FoundationCommitter):
initiator_fullname=asf_fullname,
subject=subject,
body=body_data,
+ email_cc=email_cc or [],
+ email_bcc=email_bcc or [],
).model_dump(),
asf_uid=asf_uid,
project_key=str(project_key),
@@ -443,7 +453,9 @@ class CommitteeMember(CommitteeParticipant):
# original_subject = latest_vote_task.task_args["subject"]
# Arguments for the task to cast a vote
- email_recipient = latest_vote_task.task_args["email_to"]
+ email_to: str = latest_vote_task.task_args["email_to"]
+ email_cc: list[str] = latest_vote_task.task_args.get("email_cc", [])
+ email_bcc: list[str] = latest_vote_task.task_args.get("email_bcc", [])
email_sender = f"{asf_uid}@apache.org"
subject = f"[VOTE] [RESULT] Release {release.project.display_name} {release.version} {resolution.upper()}"
# TODO: This duplicates atr/tabulate.py code
@@ -465,10 +477,12 @@ class CommitteeMember(CommitteeParticipant):
task_type=sql.TaskType.MESSAGE_SEND,
task_args=message.Send(
email_sender=email_sender,
- email_recipient=email_recipient,
+ email_to=email_to,
subject=subject,
body=body,
in_reply_to=in_reply_to,
+ email_cc=email_cc,
+ email_bcc=email_bcc,
footer_category=mail.MailFooterCategory.USER,
).model_dump(),
asf_uid=asf_uid,
@@ -482,7 +496,7 @@ class CommitteeMember(CommitteeParticipant):
task_type=sql.TaskType.MESSAGE_SEND,
task_args=message.Send(
email_sender=email_sender,
- email_recipient=extra_destination[0],
+ email_to=extra_destination[0],
subject=subject,
body=body,
in_reply_to=extra_destination[1],
diff --git a/atr/tasks/message.py b/atr/tasks/message.py
index 1fb716e6..7e3f59d9 100644
--- a/atr/tasks/message.py
+++ b/atr/tasks/message.py
@@ -40,10 +40,12 @@ class Send(schema.Strict):
"""Arguments for the task to send an email."""
email_sender: pydantic.EmailStr = schema.description("The email address of the sender")
- email_recipient: pydantic.EmailStr = schema.description("The email address of the recipient")
+ email_to: pydantic.EmailStr = schema.description("The email To address")
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")
+ email_cc: list[pydantic.EmailStr] = schema.factory(list)
+ email_bcc: list[pydantic.EmailStr] = schema.factory(list)
footer_category: Annotated[mail.MailFooterCategory, pydantic.BeforeValidator(_ensure_footer_enum)] = (
schema.description("The category of email footer to include")
)
@@ -69,19 +71,23 @@ async def send(args: Send) -> results.Results | None:
if ldap.is_banned(sender_account):
raise SendError(f"Email account {args.email_sender} is banned")
- recipient_domain = args.email_recipient.split("@")[-1]
- sending_to_self = args.email_recipient == f"{sender_asf_uid}@apache.org"
- # audit_guidance this application intentionally allows users to send messages to committees they are not a part of
- sending_to_committee = recipient_domain.endswith(".apache.org")
- if not (sending_to_self or sending_to_committee):
- raise SendError(f"You are not permitted to send emails to {args.email_recipient}")
+ all_recipients = [args.email_to, *args.email_cc, *args.email_bcc]
+ for addr in all_recipients:
+ recipient_domain = addr.split("@")[-1]
+ sending_to_self = addr == f"{sender_asf_uid}@apache.org"
+ # audit_guidance we intentionally allow users to send messages to committees they are not a part of
+ sending_to_committee = recipient_domain.endswith(".apache.org")
+ if not (sending_to_self or sending_to_committee):
+ raise SendError(f"You are not permitted to send emails to {addr}")
message = mail.Message(
email_sender=args.email_sender,
- email_recipient=args.email_recipient,
+ email_to=args.email_to,
subject=args.subject,
body=args.body,
in_reply_to=args.in_reply_to,
+ email_cc=args.email_cc,
+ email_bcc=args.email_bcc,
)
footer_category = mail.MailFooterCategory(args.footer_category)
@@ -91,7 +97,7 @@ async def send(args: Send) -> results.Results | None:
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:")
+ log.warning(f"Mail sending to {args.email_to} for subject '{args.subject}' encountered errors:")
for error in mail_errors:
log.warning(f"- {error}")
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index d126135a..c4b93419 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -17,6 +17,8 @@
import datetime
+import pydantic
+
import atr.db as db
import atr.db.interaction as interaction
import atr.log as log
@@ -33,12 +35,14 @@ class Initiate(schema.Strict):
"""Arguments for the task to start a vote."""
release_key: str = schema.description("The name of the release to vote on")
- email_to: str = schema.description("The mailing list address to send the vote email to")
+ email_to: pydantic.EmailStr = schema.description("The mailing list To address")
vote_duration: int = schema.description("Duration of the vote in hours")
initiator_id: str = schema.description("ASF ID of the vote initiator")
initiator_fullname: str = schema.description("Full name of the vote initiator")
subject: str = schema.description("Subject line for the vote email")
body: str = schema.description("Body content for the vote email")
+ email_cc: list[pydantic.EmailStr] = schema.factory(list)
+ email_bcc: list[pydantic.EmailStr] = schema.factory(list)
class VoteInitiationError(Exception):
@@ -65,9 +69,11 @@ async def _initiate_core_logic(args: Initiate) -> results.Results | None:
safe.ReleaseKey(args.release_key)
# Validate arguments
- if not (args.email_to.endswith("@apache.org") or args.email_to.endswith(".apache.org")):
- log.error(f"Invalid destination email address: {args.email_to}")
- raise VoteInitiationError("Invalid destination email address")
+ all_addrs = [args.email_to, *args.email_cc, *args.email_bcc]
+ for addr in all_addrs:
+ if not (addr.endswith("@apache.org") or addr.endswith(".apache.org")):
+ log.error(f"Invalid destination email address: {addr}")
+ raise VoteInitiationError(f"Invalid destination email address: {addr}")
async with db.session() as data:
release = await data.release(key=args.release_key, _project=True, _committee=True).demand(
@@ -110,17 +116,20 @@ async def _initiate_core_logic(args: Initiate) -> results.Results | None:
body = args.body
permitted_recipients = util.permitted_voting_recipients(args.initiator_id, release.committee.key)
- if args.email_to not in permitted_recipients:
- log.error(f"Invalid mailing list choice: {args.email_to} not in {permitted_recipients}")
- raise VoteInitiationError("Invalid mailing list choice")
+ for addr in all_addrs:
+ if addr not in permitted_recipients:
+ log.error(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
+ raise VoteInitiationError("Invalid mailing list choice")
# Create mail message
log.info(f"Creating mail message for {args.email_to}")
message = mail.Message(
email_sender=f"{args.initiator_id}@apache.org",
- email_recipient=args.email_to,
+ email_to=args.email_to,
subject=subject,
body=body,
+ email_cc=args.email_cc,
+ email_bcc=args.email_bcc,
)
async with storage.write(args.initiator_id) as write:
@@ -128,6 +137,7 @@ async def _initiate_core_logic(args: Initiate) -> results.Results | None:
mid, mail_errors = await wafc.mail.send(message, mail.MailFooterCategory.USER)
# Original success message structure
+ all_destinations = [args.email_to, *args.email_cc, *args.email_bcc]
result = results.VoteInitiate(
kind="vote_initiate",
message="Vote announcement email sent successfully",
@@ -139,7 +149,7 @@ async def _initiate_core_logic(args: Initiate) -> results.Results | None:
)
if mail_errors:
- log.warning(f"Start vote for {args.release_key}: sending to {args.email_to} gave errors: {mail_errors}")
+ log.warning(f"Start vote for {args.release_key}: sending to {all_destinations} gave errors: {mail_errors}")
else:
- log.info(f"Vote email sent successfully to {args.email_to}")
+ log.info(f"Vote email sent successfully to {all_destinations}")
return result
diff --git a/atr/util.py b/atr/util.py
index fcbb1248..bcaf24a0 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -35,7 +35,7 @@ import urllib.parse
import uuid
import zipfile
from collections.abc import AsyncGenerator, Callable, Iterable, Sequence
-from typing import Any, Final
+from typing import Any, Final, Protocol
import aiofiles.os
import aiohttp
@@ -82,6 +82,12 @@ USER_TESTS_ADDRESS: Final[str] = "user-tests@tooling.apache.org"
NoneType: Final[type[None]] = type(None)
+class EmailRecipients(Protocol):
+ email_to: str
+ email_cc: list[str]
+ email_bcc: list[str]
+
+
@dataclasses.dataclass(frozen=True)
class NpmPackInfo:
name: str
@@ -247,6 +253,20 @@ def committee_is_standing(committee_key: str) -> bool:
return committee_key in registry.STANDING_COMMITTEES
+def conjunction(items: Sequence[str], empty: str | None = None) -> str:
+ match len(items):
+ case 0:
+ if empty is None:
+ raise ValueError("No items to join")
+ return empty
+ case 1:
+ return items[0]
+ case 2:
+ return f"{items[0]} and {items[1]}"
+ case _:
+ return ", ".join(items[:-1]) + f", and {items[-1]}"
+
+
async def content_list(
phase_subdir: pathlib.Path, project_key: str, version_key: str, revision_name: str | None = None
) -> AsyncGenerator[FileStat]:
@@ -1128,6 +1148,17 @@ def validate_as_type[T](value: Any, t: type[T]) -> T:
return value
+def validate_email_recipients(recipients: EmailRecipients) -> None:
+ if not recipients.email_to:
+ raise ValueError("At least one To recipient is required")
+ seen: set[str] = set()
+ for addr in [recipients.email_to, *recipients.email_cc, *recipients.email_bcc]:
+ lower = addr.lower()
+ if lower in seen:
+ raise ValueError(f"Duplicate recipient: {addr}")
+ seen.add(lower)
+
+
def validate_filename(filename: str) -> str:
return validate_path_segment(filename, "Filename")
diff --git a/tests/unit/test_mail.py b/tests/unit/test_mail.py
index 5957527e..dd3b59de 100644
--- a/tests/unit/test_mail.py
+++ b/tests/unit/test_mail.py
@@ -38,7 +38,7 @@ async def test_address_objects_used_for_from_to_headers(monkeypatch: "MonkeyPatc
legitimate_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="Test body",
)
@@ -58,6 +58,46 @@ async def test_address_objects_used_for_from_to_headers(monkeypatch: "MonkeyPatc
assert "To: recipient@apache.org" in msg_text
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ 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
+
+
+@pytest.mark.asyncio
+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="bob@apache.org",
+ email_to="recipient@apache.org",
+ 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 bob@apache.org on the Apache Trusted Releases platform" in msg_text
+
+
@pytest.mark.asyncio
async def test_send_accepts_legitimate_message(monkeypatch: "MonkeyPatch") -> None:
"""Test that a legitimate message without CRLF is accepted."""
@@ -67,7 +107,7 @@ async def test_send_accepts_legitimate_message(monkeypatch: "MonkeyPatch") -> No
# Create a legitimate message without any CRLF injection attempts
legitimate_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Legitimate Subject",
body="This is a legitimate test message with no injection attempts.",
)
@@ -100,7 +140,7 @@ async def test_send_accepts_message_with_reply_to(monkeypatch: "MonkeyPatch") ->
# Create a legitimate message with a valid in_reply_to
legitimate_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Re: Previous Subject",
body="This is a reply message.",
in_reply_to="previous-message-id@apache.org",
@@ -119,6 +159,87 @@ async def test_send_accepts_message_with_reply_to(monkeypatch: "MonkeyPatch") ->
assert "@apache.org" in mid
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="BCC test",
+ body="Hello.",
+ email_bcc=["secret@apache.org"],
+ )
+
+ _, 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 "secret@apache.org" in envelope_recipients
+
+ # BCC must not appear anywhere in the message headers
+ assert "secret@apache.org" not in msg_text
+
+
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="CC test",
+ body="Hello.",
+ email_cc=["cc@apache.org"],
+ )
+
+ _, 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 "cc@apache.org" in envelope_recipients
+
+ # CC must appear in a Cc header, not only To
+ assert "Cc: cc@apache.org" in msg_text
+
+
+@pytest.mark.asyncio
+async def test_send_empty_cc_bcc_omits_cc_header(monkeypatch: "MonkeyPatch") -> None:
+ """Test that omitting CC or 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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="No CC or 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 == ["recipient@apache.org"]
+ assert "Cc:" not in msg_text
+ assert "Bcc:" not in msg_text
+
+
@pytest.mark.asyncio
async def test_send_handles_non_ascii_headers(monkeypatch: "MonkeyPatch") -> None:
"""Test that non-ASCII characters in headers are handled correctly."""
@@ -128,7 +249,7 @@ async def test_send_handles_non_ascii_headers(monkeypatch: "MonkeyPatch") -> Non
# Create a message with non-ASCII characters in the subject
message_with_unicode = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test avec Accént",
body="This message has non-ASCII characters in the subject.",
)
@@ -149,6 +270,34 @@ async def test_send_handles_non_ascii_headers(monkeypatch: "MonkeyPatch") -> Non
assert "Subject: Test avec Accént" in msg_text
+@pytest.mark.asyncio
+async def test_send_to_with_cc_in_envelope(monkeypatch: "MonkeyPatch") -> None:
+ """Test that To and CC addresses both appear in the SMTP envelope."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="alice@apache.org",
+ subject="Multi-recipient test",
+ body="Hello both.",
+ email_cc=["bob@apache.org"],
+ )
+
+ _, 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 "alice@apache.org" in envelope_recipients
+ assert "bob@apache.org" in envelope_recipients
+
+ assert "alice@apache.org" in msg_text
+ assert "bob@apache.org" in msg_text
+
+
@pytest.mark.asyncio
async def test_send_rejects_bcc_header_injection(monkeypatch: "MonkeyPatch") -> None:
"""Test a realistic Bcc header injection attack scenario."""
@@ -158,7 +307,7 @@ async def test_send_rejects_bcc_header_injection(monkeypatch: "MonkeyPatch") ->
# Create a malicious message attempting to inject a Bcc header
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Important Notice\r\nBcc: attacker@malicious.com\r\nX-Priority: 1",
body="This message attempts to secretly copy an attacker.",
)
@@ -174,6 +323,26 @@ async def test_send_rejects_bcc_header_injection(monkeypatch: "MonkeyPatch") ->
mock_send_many.assert_not_called()
+@pytest.mark.asyncio
+async def test_send_rejects_case_insensitive_duplicate(monkeypatch: "MonkeyPatch") -> None:
+ """Test that duplicate detection is case-insensitive."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="Recipient@apache.org",
+ subject="Dup test",
+ body="Hello.",
+ email_bcc=["recipient@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"Duplicate recipient"):
+ await mail.send(msg, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_rejects_content_type_injection(monkeypatch: "MonkeyPatch") -> None:
"""Test injection attempting to override Content-Type header."""
@@ -183,7 +352,7 @@ async def test_send_rejects_content_type_injection(monkeypatch: "MonkeyPatch") -
# Create a malicious message attempting to inject Content-Type
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test\r\nContent-Type: text/html\r\n\r\n<html><script>alert('XSS')</script></html>",
body="Normal body",
)
@@ -208,7 +377,7 @@ async def test_send_rejects_cr_only_injection(monkeypatch: "MonkeyPatch") -> Non
# Create a malicious message with just CR (no LF)
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Legitimate Subject\rBcc: evil@example.com",
body="This is a test message",
)
@@ -224,6 +393,50 @@ async def test_send_rejects_cr_only_injection(monkeypatch: "MonkeyPatch") -> Non
mock_send_many.assert_not_called()
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Test Subject",
+ body="This is a test message",
+ email_bcc=["bcc@apache.org\r\nTo: interloper@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"CR/LF"):
+ await mail.send(malicious_message, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Test Subject",
+ body="This is a test message",
+ email_cc=["cc@apache.org\r\nBcc: interloper@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"CR/LF"):
+ await mail.send(malicious_message, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_rejects_crlf_in_from_address(monkeypatch: "MonkeyPatch") -> None:
"""Test that CRLF injection in from address field is rejected.
@@ -237,7 +450,7 @@ async def test_send_rejects_crlf_in_from_address(monkeypatch: "MonkeyPatch") ->
# Create a malicious message with CRLF in the from address
malicious_message = mail.Message(
email_sender="sender@apache.org\r\nBcc: evil@example.com",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="This is a test message",
)
@@ -259,7 +472,7 @@ async def test_send_rejects_crlf_in_reply_to(monkeypatch: "MonkeyPatch") -> None
# Create a malicious message with CRLF in the in_reply_to field
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="This is a test message",
in_reply_to="valid-id@apache.org\r\nBcc: evil@example.com",
@@ -286,7 +499,7 @@ async def test_send_rejects_crlf_in_subject(monkeypatch: "MonkeyPatch") -> None:
# Create a malicious message with CRLF in the subject
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Legitimate Subject\r\nBcc: evil@example.com",
body="This is a test message",
)
@@ -315,7 +528,7 @@ async def test_send_rejects_crlf_in_to_address(monkeypatch: "MonkeyPatch") -> No
# Create a malicious message with CRLF in the to address
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org\r\nBcc: interloper@apache.org",
+ email_to="recipient@apache.org\r\nBcc: interloper@apache.org",
subject="Test Subject",
body="This is a test message",
)
@@ -328,6 +541,86 @@ async def test_send_rejects_crlf_in_to_address(monkeypatch: "MonkeyPatch") -> No
mock_send_many.assert_not_called()
+@pytest.mark.asyncio
+async def test_send_rejects_duplicate_across_cc_and_bcc(monkeypatch: "MonkeyPatch") -> None:
+ """Test that the same address in CC and BCC is rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="sender@apache.org",
+ subject="Dup test",
+ body="Hello.",
+ email_cc=["other@apache.org"],
+ email_bcc=["other@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"Duplicate recipient"):
+ await mail.send(msg, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_send_rejects_duplicate_across_to_and_cc(monkeypatch: "MonkeyPatch") -> None:
+ """Test that the same address in To and CC is rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Dup test",
+ body="Hello.",
+ email_cc=["recipient@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"Duplicate recipient"):
+ await mail.send(msg, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_send_rejects_duplicate_across_to_and_bcc(monkeypatch: "MonkeyPatch") -> None:
+ """Test that the same address in To and BCC is rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Dup test",
+ body="Hello.",
+ email_bcc=["recipient@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"Duplicate recipient"):
+ await mail.send(msg, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_send_rejects_empty_to(monkeypatch: "MonkeyPatch") -> None:
+ """Test that an empty To list is rejected."""
+ mock_send_many = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr("atr.mail._send_many", mock_send_many)
+
+ msg = mail.Message(
+ email_sender="sender@apache.org",
+ email_to="",
+ subject="Empty To test",
+ body="Hello.",
+ )
+
+ with pytest.raises(ValueError, match=r"At least one To recipient is required"):
+ await mail.send(msg, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_rejects_lf_only_injection(monkeypatch: "MonkeyPatch") -> None:
"""Test that injection with LF only (\\n) is also rejected."""
@@ -337,7 +630,7 @@ async def test_send_rejects_lf_only_injection(monkeypatch: "MonkeyPatch") -> Non
# Create a malicious message with just LF (no CR)
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Legitimate Subject\nBcc: evil@example.com",
body="This is a test message",
)
@@ -353,6 +646,26 @@ async def test_send_rejects_lf_only_injection(monkeypatch: "MonkeyPatch") -> Non
mock_send_many.assert_not_called()
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Test Subject",
+ body="This is a test message",
+ email_bcc=["bcc\x00evil@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_rejects_null_byte_in_body(monkeypatch: "MonkeyPatch") -> None:
"""Test that null bytes in body field are rejected."""
@@ -361,7 +674,7 @@ async def test_send_rejects_null_byte_in_body(monkeypatch: "MonkeyPatch") -> Non
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="Normal start\x00injected content",
)
@@ -372,6 +685,26 @@ async def test_send_rejects_null_byte_in_body(monkeypatch: "MonkeyPatch") -> Non
mock_send_many.assert_not_called()
+@pytest.mark.asyncio
+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="sender@apache.org",
+ email_to="recipient@apache.org",
+ subject="Test Subject",
+ body="This is a test message",
+ email_cc=["cc\x00evil@apache.org"],
+ )
+
+ with pytest.raises(ValueError, match=r"null bytes"):
+ await mail.send(malicious_message, mail.MailFooterCategory.NONE)
+
+ mock_send_many.assert_not_called()
+
+
@pytest.mark.asyncio
async def test_send_rejects_null_byte_in_from_address(monkeypatch: "MonkeyPatch") -> None:
"""Test that null bytes in from address field are rejected."""
@@ -380,7 +713,7 @@ async def test_send_rejects_null_byte_in_from_address(monkeypatch: "MonkeyPatch"
malicious_message = mail.Message(
email_sender="sender\x00evil@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="This is a test message",
)
@@ -399,7 +732,7 @@ async def test_send_rejects_null_byte_in_reply_to(monkeypatch: "MonkeyPatch") ->
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Test Subject",
body="This is a test message",
in_reply_to="valid-id\x00injected@apache.org",
@@ -419,7 +752,7 @@ async def test_send_rejects_null_byte_in_subject(monkeypatch: "MonkeyPatch") ->
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient@apache.org",
+ email_to="recipient@apache.org",
subject="Legitimate Subject\x00Bcc: evil@example.com",
body="This is a test message",
)
@@ -438,7 +771,7 @@ async def test_send_rejects_null_byte_in_to_address(monkeypatch: "MonkeyPatch")
malicious_message = mail.Message(
email_sender="sender@apache.org",
- email_recipient="recipient\x00evil@apache.org",
+ email_to="recipient\x00evil@apache.org",
subject="Test Subject",
body="This is a test message",
)
@@ -497,237 +830,3 @@ 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\x00@apache.org")
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="Test Subject",
- body="This is a test message",
- email_cc="cc\x00evil@apache.org",
- )
-
- with pytest.raises(ValueError, match=r"null bytes"):
- await mail.send(malicious_message, mail.MailFooterCategory.NONE)
-
- mock_send_many.assert_not_called()
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="Test Subject",
- body="This is a test message",
- email_bcc="bcc\x00evil@apache.org",
- )
-
- with pytest.raises(ValueError, match=r"null bytes"):
- await mail.send(malicious_message, mail.MailFooterCategory.NONE)
-
- mock_send_many.assert_not_called()
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="Test Subject",
- body="This is a test message",
- email_cc="cc@apache.org\r\nBcc: interloper@apache.org",
- )
-
- with pytest.raises(ValueError, match=r"CR/LF"):
- await mail.send(malicious_message, mail.MailFooterCategory.NONE)
-
- mock_send_many.assert_not_called()
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="Test Subject",
- body="This is a test message",
- email_bcc="bcc@apache.org\r\nTo: interloper@apache.org",
- )
-
- with pytest.raises(ValueError, match=r"CR/LF"):
- await mail.send(malicious_message, mail.MailFooterCategory.NONE)
-
- mock_send_many.assert_not_called()
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="alice@apache.org, bob@apache.org",
- 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 "alice@apache.org" in envelope_recipients
- assert "bob@apache.org" in envelope_recipients
-
- # Both addresses must appear in the To header
- assert "alice@apache.org" in msg_text
- assert "bob@apache.org" in msg_text
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="CC test",
- body="Hello.",
- email_cc="cc@apache.org",
- )
-
- _, 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 "cc@apache.org" in envelope_recipients
-
- # CC must appear in a Cc header, not only To
- assert "Cc: cc@apache.org" in msg_text
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- subject="BCC test",
- body="Hello.",
- email_bcc="secret@apache.org",
- )
-
- _, 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 "secret@apache.org" in envelope_recipients
-
- # BCC must not appear anywhere in the message headers
- assert "secret@apache.org" not in msg_text
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- 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 == ["recipient@apache.org"]
- assert "Cc:" not in msg_text
- assert "Bcc:" not in msg_text
-
-
-@pytest.mark.asyncio
-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="bob@apache.org",
- email_recipient="recipient@apache.org",
- 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 bob@apache.org on the Apache Trusted Releases platform" in msg_text
-
-
-@pytest.mark.asyncio
-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="sender@apache.org",
- email_recipient="recipient@apache.org",
- 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 4abbd64a..7bb05be1 100644
--- a/tests/unit/test_message.py
+++ b/tests/unit/test_message.py
@@ -113,14 +113,14 @@ async def test_send_succeeds_with_valid_asf_id(monkeypatch: "MonkeyPatch") -> No
def _send_args(
email_sender: str = "validuser@apache.org",
- email_recipient: str = "dev@project.apache.org",
+ email_to: str = "dev@project.apache.org",
subject: str = "Test Subject",
body: str = "Test body",
in_reply_to: str | None = None,
-) -> dict[str, str | None]:
+) -> dict[str, object]:
return {
"email_sender": email_sender,
- "email_recipient": email_recipient,
+ "email_to": email_to,
"subject": subject,
"body": body,
"in_reply_to": in_reply_to,
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org