From: Dmitry Torokhov <dmitry.torokhov@gmail.com>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
Subject: [PATCH b4 v2 3/5] b4: handle per-message Bcc headers in send_mail and TUIs
Date: Sun, 11 Oct 2026 22:58:28 -0700 [thread overview]
Message-ID: <20261011-send-series-bcc-v2-3-cd17584c40fb@gmail.com> (raw)
In-Reply-To: <20261011-send-series-bcc-v2-0-cd17584c40fb@gmail.com>
Interactive TUI workflows (b4 review and b4 bugs) allow setting
per-message Bcc: headers via ToCcScreen. However, send_mail() did not
extract Bcc: headers into the envelope recipient list or strip the
header from the MIME message before signing and sending, which both
failed to deliver to those Bcc recipients (unless destaddrs was passed)
and exposed the blind addresses to all recipients.
Extract any Bcc: headers in send_mail(), add their addresses to the
per-message envelope recipients, and strip the Bcc: header before
message serialization and patatt signing. Reject per-message Bcc:
headers when submitting via the web endpoint, which only supports a
series-wide Bcc list.
Complete per-message Bcc: handling in the TUIs by counting per-message
Bcc: headers alongside send-series-bcc in the review SendScreen
confirmation dialog, rendering Bcc: in the review and thank-you email
previews, and displaying and persisting Bcc: edits in the b4 bugs
ReplyPreviewScreen.
Assisted-by: LLM
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
src/b4/__init__.py | 19 +++++++++++++++
src/b4/bugs/_tui.py | 8 +++++--
src/b4/review_tui/_common.py | 4 ++--
src/b4/review_tui/_modals.py | 12 ++++++++--
src/tests/test___init__.py | 55 ++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 92 insertions(+), 6 deletions(-)
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index 2c0d3f1..8974867 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -5858,6 +5858,23 @@ def send_mail(
logger.info('Signing %s messages with patatt', len(msgs))
for msg in msgs:
+ msg_bcc_hdrs = msg.get_all('bcc', [])
+ msg_bcc: Set[str] = set()
+ if msg_bcc_hdrs:
+ if web_endpoint:
+ raise RuntimeError(
+ 'Per-patch Bcc headers are not supported with the web endpoint.'
+ )
+ msg_bcc = {
+ x[1]
+ for x in cleanup_email_addrs(
+ email.utils.getaddresses([str(x) for x in msg_bcc_hdrs]),
+ set(),
+ None,
+ )
+ }
+ del msg['Bcc']
+
if not destaddrs:
alldests = email.utils.getaddresses([str(x) for x in msg.get_all('to', [])])
alldests += email.utils.getaddresses([str(x) for x in msg.get_all('cc', [])])
@@ -5867,6 +5884,8 @@ def send_mail(
if bcc_set:
myaddrs.update(bcc_set)
+ if msg_bcc:
+ myaddrs.update(msg_bcc)
if not msg.get('X-Mailer'):
msg.add_header('X-Mailer', f'b4 {__VERSION__}')
diff --git a/src/b4/bugs/_tui.py b/src/b4/bugs/_tui.py
index bf8dda7..aa979c7 100644
--- a/src/b4/bugs/_tui.py
+++ b/src/b4/bugs/_tui.py
@@ -1257,7 +1257,7 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]):
if isinstance(app, BugListApp):
ts = app._ts
# Render email headers
- for hdr in ('From', 'To', 'Cc', 'Subject', 'In-Reply-To'):
+ for hdr in ('From', 'To', 'Cc', 'Bcc', 'Subject', 'In-Reply-To'):
val = self._msg.get(hdr)
if val:
hdr_text = Text()
@@ -1285,17 +1285,21 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]):
to_val = b4.LoreMessage.clean_header(self._msg.get('To', ''))
cc_val = b4.LoreMessage.clean_header(self._msg.get('Cc', ''))
- screen = ToCcScreen(to_val, cc_val, '', show_apply_all=False)
+ bcc_val = b4.LoreMessage.clean_header(self._msg.get('Bcc', ''))
+ screen = ToCcScreen(to_val, cc_val, bcc_val, show_apply_all=False)
def _on_result(saved: bool | None) -> None:
if not saved:
return
del self._msg['To']
del self._msg['Cc']
+ del self._msg['Bcc']
if screen.to_result:
self._msg['To'] = screen.to_result
if screen.cc_result:
self._msg['Cc'] = screen.cc_result
+ if screen.bcc_result:
+ self._msg['Bcc'] = screen.bcc_result
self._rerender()
self.app.push_screen(screen, callback=_on_result)
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 43db8c9..e1647d3 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -896,12 +896,12 @@ def _render_email_to_viewer(
separator lines (---) are dim.
*ts* is a resolved theme styles dict from :func:`resolve_styles`.
"""
- for hdr in ('From', 'Subject', 'To', 'Cc'):
+ for hdr in ('From', 'Subject', 'To', 'Cc', 'Bcc'):
val = msg[hdr]
if not val:
continue
val = str(val)
- if hdr.lower() in ('to', 'cc'):
+ if hdr.lower() in ('to', 'cc', 'bcc'):
wrapped = b4.LoreMessage.wrap_header((hdr, val), transform='decode').decode(
errors='replace'
)
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 13e3464..ea5011a 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -654,7 +654,6 @@ class SendScreen(ModalScreen[bool]):
if isinstance(cfg_bcc, str) and cfg_bcc
else set()
)
- bcc_count = len(series_bcc)
text = Text()
text.append(f'Send {len(self._msgs)} review email(s)?', style='bold')
text.append('\n')
@@ -665,6 +664,15 @@ class SendScreen(ModalScreen[bool]):
text.append(f' {subj}')
to_count = len(email.utils.getaddresses([msg['To']])) if msg['To'] else 0
cc_count = len(email.utils.getaddresses([msg['Cc']])) if msg['Cc'] else 0
+ msg_bcc = {
+ x[1]
+ for x in b4.cleanup_email_addrs(
+ email.utils.getaddresses([str(x) for x in msg.get_all('bcc', [])]),
+ set(),
+ None,
+ )
+ }
+ bcc_count = len(series_bcc | msg_bcc)
recip_parts: List[str] = []
if to_count:
recip_parts.append(f'{to_count} To')
@@ -1381,7 +1389,7 @@ class ThankScreen(ModalScreen[Optional[str]]):
def on_mount(self) -> None:
viewer = self.query_one('#thank-viewer', RichLog)
- for hdr in ('From', 'To', 'Cc', 'Subject', 'In-Reply-To'):
+ for hdr in ('From', 'To', 'Cc', 'Bcc', 'Subject', 'In-Reply-To'):
val = self._msg.get(hdr)
if val:
viewer.write(f'{hdr}: {val}')
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index 85d580c..d7d0250 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -2454,6 +2454,61 @@ class TestSendMailBcc:
}
assert b'Bcc:' not in seen_bodies[0]
+ def test_send_mail_per_message_bcc_header(self) -> None:
+ seen_dests: List[List[str]] = []
+ seen_bodies: List[bytes] = []
+
+ class _RecordingSMTP:
+ def sendmail(self, fromaddr: str, destaddrs: List[str], bdata: bytes) -> None:
+ seen_dests.append(list(destaddrs))
+ seen_bodies.append(bdata)
+
+ def _connect() -> Any:
+ return _RecordingSMTP()
+
+ msg = email.message.EmailMessage()
+ msg['From'] = 'alice@example.org'
+ msg['To'] = 'bob@example.org'
+ msg['Bcc'] = 'Secret Reviewer <secret@example.org>'
+ msg['Subject'] = 'Re: [PATCH] Test patch'
+ msg.set_content('Review reply')
+
+ sent = b4.send_mail(
+ b4.SMTPConnector(_connect),
+ [msg],
+ fromaddr='alice@example.org',
+ bccaddrs=['dave@example.org'],
+ )
+ assert sent == 1
+ assert len(seen_dests) == 1
+ assert set(seen_dests[0]) == {
+ 'bob@example.org',
+ 'dave@example.org',
+ 'secret@example.org',
+ }
+ assert msg.get_all('bcc') is None
+ assert b'Bcc:' not in seen_bodies[0]
+ assert b'secret@example.org' not in seen_bodies[0]
+
+ def test_send_mail_per_message_bcc_web_endpoint_rejected(self) -> None:
+ msg = email.message.EmailMessage()
+ msg['From'] = 'alice@example.org'
+ msg['To'] = 'bob@example.org'
+ msg['Bcc'] = 'secret@example.org'
+ msg['Subject'] = '[PATCH] Test patch'
+ msg.set_content('Diff content')
+
+ with pytest.raises(
+ RuntimeError,
+ match='Per-patch Bcc headers are not supported with the web endpoint',
+ ):
+ b4.send_mail(
+ None,
+ [msg],
+ fromaddr='alice@example.org',
+ web_endpoint='https://example.org/_submit',
+ )
+
def _fake_editor(tmp_path: pathlib.Path, body: str) -> str:
"""A stand-in $EDITOR that runs *body* and leaves the buffer alone."""
script = tmp_path / 'fake-editor.sh'
--
2.56.0.385.gd3acb90ef8-goog
next prev parent reply other threads:[~2026-10-12 5:58 UTC|newest]
Thread overview: 7+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-12 5:58 [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
2026-10-12 5:58 ` [PATCH b4 v2 1/5] misc/send-receive: support BCC in web submission endpoint Dmitry Torokhov
2026-10-12 5:58 ` [PATCH b4 v2 2/5] b4: implement --bcc and send-series-bcc config option Dmitry Torokhov
2026-10-12 5:58 ` Dmitry Torokhov [this message]
2026-10-12 5:58 ` [PATCH b4 v2 4/5] b4: reject Bcc trailers in patches and cover letter Dmitry Torokhov
2026-10-12 5:58 ` [PATCH b4 v2 5/5] misc/send-receive: require messages payload to be a list Dmitry Torokhov
2026-11-13 2:00 ` [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261011-send-series-bcc-v2-3-cd17584c40fb@gmail.com \
--to=dmitry.torokhov@gmail.com \
--cc=konstantin@linuxfoundation.org \
--cc=tools@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox