* [PATCH b4 v2 1/5] misc/send-receive: support BCC in web submission endpoint
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 ` Dmitry Torokhov
2026-10-12 5:58 ` [PATCH b4 v2 2/5] b4: implement --bcc and send-series-bcc config option Dmitry Torokhov
` (4 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-10-12 5:58 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
Support receiving and processing client-specified BCC envelope
destinations in the web submission endpoint.
Extract optional top-level "bcc" list from JSON request payload in
receive(), validate email address format, enforce max-client-bcc
limit (default: 10) to guard against relay abuse, and merge validated
addresses into destaddrs for SMTP delivery.
Assisted-by: LLM
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
misc/default.conf | 2 ++
misc/send-receive.py | 32 +++++++++++++++++++++++++++++++-
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/misc/default.conf b/misc/default.conf
index 100c8cd..2aab3c0 100644
--- a/misc/default.conf
+++ b/misc/default.conf
@@ -29,6 +29,8 @@
# Always bcc the address(es) listed here, separated by comma
# Useful during initial testing
#alwaysbcc = one@example.com, two@example.com
+ # Maximum number of BCC recipients allowed in client submissions (default: 10)
+ #max-client-bcc = 10
# If dryrun is set, the messages are printed to stdout instead of
# being actually sent out. Useful for testing.
#dryrun = false
diff --git a/misc/send-receive.py b/misc/send-receive.py
index e1cf98e..22e049b 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -18,7 +18,7 @@ import textwrap
from configparser import ConfigParser, ExtendedInterpolation
from email import charset, utils
from string import Template
-from typing import List, Mapping, Optional, Sequence, Tuple, Union
+from typing import List, Mapping, Optional, Sequence, Set, Tuple, Union
import ezpi
import falcon
@@ -519,6 +519,33 @@ class SendReceiveListener(object):
return
logger.debug('Received a request for %s messages', len(umsgs))
+ client_bcc: Set[str] = set()
+ if 'bcc' in jdata:
+ jbcc = jdata.get('bcc')
+ if not isinstance(jbcc, list):
+ self.send_error(resp, message='Invalid bcc payload format')
+ return
+ max_bcc = self._config['main'].getint('max-client-bcc', fallback=10)
+ for entry in jbcc:
+ if not isinstance(entry, str):
+ self.send_error(resp, message='Invalid bcc address format')
+ return
+ parsed = utils.getaddresses([entry])
+ if not parsed:
+ self.send_error(resp, message='Invalid bcc address format')
+ return
+ for _, addr in parsed:
+ local, _, domain = addr.partition('@')
+ if not (local and domain and not any(c.isspace() for c in addr)):
+ self.send_error(resp, message='Invalid bcc address format')
+ return
+ client_bcc.add(addr.lower())
+ if len(client_bcc) > max_bcc:
+ self.send_error(
+ resp, message=f'Too many BCC recipients (max: {max_bcc})'
+ )
+ return
+
diffre = re.compile(
rb'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)',
flags=re.M | re.I,
@@ -761,6 +788,9 @@ class SendReceiveListener(object):
msg.set_charset('utf-8')
msg.replace_header('Content-Transfer-Encoding', '8bit')
+ if client_bcc and not reflect:
+ destaddrs.update(client_bcc)
+
if bccaddrs:
destaddrs.update(bccaddrs)
--
2.56.0.385.gd3acb90ef8-goog
^ permalink raw reply related [flat|nested] 7+ messages in thread* [PATCH b4 v2 2/5] b4: implement --bcc and send-series-bcc config option
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 ` Dmitry Torokhov
2026-10-12 5:58 ` [PATCH b4 v2 3/5] b4: handle per-message Bcc headers in send_mail and TUIs Dmitry Torokhov
` (3 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-10-12 5:58 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
Allow using blind carbon copy when sending entire series (no per-patch
Bcc).
Handle BCC addresses directly in send_mail() as envelope recipients
without adding and stripping temporary Bcc: message headers, and include
BCC recipients in JSON submission payload when sending via the web
endpoint. Also display configured BCC recipients in review TUI
confirmation dialog.
Assisted-by: LLM
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
docs/config.rst | 13 +++++-
docs/contributor/send.rst | 11 ++++-
src/b4/__init__.py | 36 ++++++++++++----
src/b4/command.py | 3 ++
src/b4/ez.py | 27 +++++++++++-
src/b4/review_tui/_modals.py | 15 +++++++
src/tests/test___init__.py | 99 ++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 192 insertions(+), 12 deletions(-)
diff --git a/docs/config.rst b/docs/config.rst
index 32d1dc1..6b606b6 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -46,8 +46,8 @@ unrestricted:
on the series you are preparing yourself
- ``prep-pre-flight-checks`` -- which pre-flight checks are enabled
- ``send-*`` -- recipient and addressing options, such as
- ``send-series-to``, ``send-series-cc``, ``send-auto-to-cmd`` and
- ``send-auto-cc-cmd``
+ ``send-series-to``, ``send-series-cc``, ``send-series-bcc``,
+ ``send-auto-to-cmd`` and ``send-auto-cc-cmd``
- ``*mask`` -- URL templates such as ``midmask`` and ``linkmask``
- ``*template*`` -- message templates
- ``trailer*`` -- trailer-handling options
@@ -1117,6 +1117,15 @@ Contributor-oriented settings
.. versionchanged:: v0.15
Added ``shallow`` config value.
+ :term:`b4.send-series-bcc`
+ A comma-separated list of addresses to always add to the "Bcc:" list.
+ Recipients in this list will always receive the series regardless of
+ any exclusion filters (such as the ``--not-me-too`` flag or the
+ :term:`b4.email-exclude` configuration setting).
+ See :ref:`prep_recipients`.
+
+ Default: ``None``
+
:term:`b4.send-series-cc`
A comma-separated list of addresses to always add to the "Cc:" header.
See :ref:`prep_recipients`.
diff --git a/docs/contributor/send.rst b/docs/contributor/send.rst
index 52d2da5..7138569 100644
--- a/docs/contributor/send.rst
+++ b/docs/contributor/send.rst
@@ -338,8 +338,17 @@ Command line flags
configuration file using the :term:`b4.send-series-cc` option (see
:ref:`contributor_settings`).
+``--bcc``
+ Additional email addresses to include into the Bcc: list. Recipients
+ in this list will always receive the series regardless of any
+ exclusion filters (such as the ``--not-me-too`` flag or the
+ :term:`b4.email-exclude` configuration setting). Separate multiple
+ entries with a comma. You can also set this in the configuration
+ file using the :term:`b4.send-series-bcc` option (see
+ :ref:`contributor_settings`).
+
``--not-me-too``
- Removes your own email address from the recipients. This can be set in
+ Removes your own email address from the To: or Cc: recipients. This can be set in
the configuration using the :term:`b4.send-me-too`, setting to ``no``.
(see :ref:`contributor_settings`).
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index 3a3d12a..2c0d3f1 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -5826,6 +5826,7 @@ def send_mail(
msgs: Sequence[EmailMessage],
fromaddr: Optional[str],
destaddrs: Optional[Union[Set[str], List[str]]] = None,
+ bccaddrs: Optional[Union[Set[str], List[str]]] = None,
patatt_sign: bool = False,
dryrun: bool = False,
output_dir: Optional[str] = None,
@@ -5836,6 +5837,20 @@ def send_mail(
if output_dir is not None:
dryrun = True
+ bcc_set: Set[str] = set()
+ if bccaddrs is not None:
+ bcc_set = set(bccaddrs)
+ else:
+ config = get_main_config()
+ cfg_bcc = config.get('send-series-bcc')
+ if isinstance(cfg_bcc, str) and cfg_bcc:
+ bcc_set = {
+ x[1]
+ for x in cleanup_email_addrs(
+ email.utils.getaddresses([cfg_bcc]), set(), None
+ )
+ }
+
if patatt_sign:
# Signing is the one step here that can block indefinitely: with a PGP
# key patatt shells out to gpg, which may be waiting on a pinentry
@@ -5843,6 +5858,16 @@ def send_mail(
logger.info('Signing %s messages with patatt', len(msgs))
for msg in msgs:
+ 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', [])])
+ myaddrs = {x[1] for x in alldests}
+ else:
+ myaddrs = set(destaddrs)
+
+ if bcc_set:
+ myaddrs.update(bcc_set)
+
if not msg.get('X-Mailer'):
msg.add_header('X-Mailer', f'b4 {__VERSION__}')
msg.set_charset('utf-8')
@@ -5885,14 +5910,6 @@ def send_mail(
logger.info(' | %s', bdata.decode().rstrip().replace('\n', '\n | '))
logger.info(' --- DRYRUN: message ends ---')
continue
- 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', [])]
- )
- myaddrs = {x[1] for x in alldests}
- else:
- myaddrs = set(destaddrs)
tosend.append((myaddrs, bdata, ls))
@@ -5911,6 +5928,9 @@ def send_mail(
'action': wpaction,
'messages': [x[1].decode() for x in tosend],
}
+ if bcc_set and not reflect:
+ req['bcc'] = list(bcc_set)
+
ses = get_requests_session()
res = ses.post(web_endpoint, json=req)
try:
diff --git a/src/b4/command.py b/src/b4/command.py
index 596ae41..1d57f12 100644
--- a/src/b4/command.py
+++ b/src/b4/command.py
@@ -1244,6 +1244,9 @@ def setup_parser() -> argparse.ArgumentParser:
sp_send.add_argument(
'--cc', nargs='+', metavar='ADDR', help='Addresses to add to the Cc: list'
)
+ sp_send.add_argument(
+ '--bcc', nargs='+', metavar='ADDR', help='Addresses to add to the Bcc: list'
+ )
sp_send.add_argument(
'--not-me-too',
action='store_true',
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 618abb9..71b4648 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -2927,6 +2927,8 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
excludes: Set[str] = set()
pccs: defaultdict[str, List[Tuple[str, str]]] = defaultdict(list)
+ bccdests = list()
+
if cmdargs.preview_to or cmdargs.no_trailer_to_cc:
todests = list()
ccdests = list()
@@ -2965,6 +2967,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
tos: Set[str] = set()
ccs: Set[str] = set()
+ bccs: Set[str] = set()
if cmdargs.preview_to:
tos.update(cmdargs.preview_to)
else:
@@ -2978,6 +2981,18 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
scc = config.get('send-series-cc')
if isinstance(scc, str) and scc:
ccs.add(scc)
+ if cmdargs.bcc:
+ bccs.update(cmdargs.bcc)
+ sbcc = config.get('send-series-bcc')
+ if isinstance(sbcc, str) and sbcc:
+ bccs.add(sbcc)
+ if bccs:
+ seen_bcc: Set[str] = set()
+ for pair in email.utils.getaddresses(list(bccs)):
+ if pair[1] in seen_bcc:
+ continue
+ seen_bcc.add(pair[1])
+ bccdests.append(pair)
if ccs:
for pair in email.utils.getaddresses(list(ccs)):
if pair[1] in seen:
@@ -2993,6 +3008,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
allto = list()
allcc = list()
+ allbcc = list()
alldests = set()
if todests:
@@ -3003,6 +3019,10 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
allcc = b4.cleanup_email_addrs(ccdests, excludes, None)
logger.debug('allcc: %s', allcc)
alldests.update(set([x[1] for x in allcc]))
+ if bccdests:
+ allbcc = b4.cleanup_email_addrs(bccdests, set(), None)
+ logger.debug('allbcc: %s', allbcc)
+ alldests.update(set([x[1] for x in allbcc]))
logger.debug('alldests: %s', alldests)
@@ -3042,7 +3062,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
# Give the user the last opportunity to bail out
if not cmdargs.dryrun:
- if not len(alldests):
+ if not len(allto):
logger.critical('CRITICAL: Could not find any destination addresses')
logger.critical(' try b4 prep --auto-to-cc or b4 send --to addr')
sys.exit(1)
@@ -3116,6 +3136,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
logger.info('---')
b4.print_pretty_addrs(allto, 'To')
b4.print_pretty_addrs(allcc, 'Cc')
+ b4.print_pretty_addrs(allbcc, 'Bcc')
logger.info('---')
for commit, msg in patches:
if not msg:
@@ -3272,6 +3293,8 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
)
sys.exit(1)
+ send_bcc = [x[1] for x in allbcc]
+
if endpoint:
# Web endpoint always requires signing
if not sign:
@@ -3286,6 +3309,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
None,
send_msgs,
fromaddr=None,
+ bccaddrs=send_bcc,
patatt_sign=True,
dryrun=cmdargs.dryrun,
output_dir=cmdargs.output_dir,
@@ -3308,6 +3332,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
smtp,
send_msgs,
fromaddr=fromaddr,
+ bccaddrs=send_bcc,
patatt_sign=sign,
dryrun=cmdargs.dryrun,
output_dir=cmdargs.output_dir,
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 8704e87..13e3464 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -642,6 +642,19 @@ class SendScreen(ModalScreen[bool]):
self._msgs = msgs
def compose(self) -> ComposeResult:
+ config = b4.get_main_config()
+ cfg_bcc = config.get('send-series-bcc')
+ series_bcc = (
+ {
+ x[1]
+ for x in b4.cleanup_email_addrs(
+ email.utils.getaddresses([cfg_bcc]), set(), None
+ )
+ }
+ 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')
@@ -657,6 +670,8 @@ class SendScreen(ModalScreen[bool]):
recip_parts.append(f'{to_count} To')
if cc_count:
recip_parts.append(f'{cc_count} Cc')
+ if bcc_count:
+ recip_parts.append(f'{bcc_count} Bcc')
if recip_parts:
text.append(f'\n {", ".join(recip_parts)}')
text.append('\n')
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index 96bea89..85d580c 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -2355,6 +2355,105 @@ class TestSignBeforeConnect:
assert smtp is None
+class TestSendMailBcc:
+ def test_send_mail_bcc_envelope(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['Cc'] = 'carol@example.org'
+ msg['Subject'] = 'Test patch'
+ msg.set_content('Diff content')
+
+ sent = b4.send_mail(
+ b4.SMTPConnector(_connect),
+ [msg],
+ fromaddr='alice@example.org',
+ bccaddrs=['dave@example.org', 'eve@example.org'],
+ )
+ assert sent == 1
+ assert len(seen_dests) == 1
+ assert set(seen_dests[0]) == {'bob@example.org', 'carol@example.org', 'dave@example.org', 'eve@example.org'}
+ assert b'Bcc:' not in seen_bodies[0]
+
+ def test_send_mail_bcc_web_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ posted_payload: Dict[str, Any] = {}
+
+ class _FakeResponse:
+ def json(self) -> Dict[str, Any]:
+ return {'result': 'success'}
+
+ class _FakeSession:
+ def post(self, url: str, json: Dict[str, Any]) -> _FakeResponse:
+ posted_payload.update(json)
+ return _FakeResponse()
+
+ monkeypatch.setattr(b4, 'get_requests_session', lambda: _FakeSession())
+
+ msg = email.message.EmailMessage()
+ msg['From'] = 'alice@example.org'
+ msg['To'] = 'bob@example.org'
+ msg['Subject'] = 'Test patch'
+ msg.set_content('Diff content')
+
+ sent = b4.send_mail(
+ None,
+ [msg],
+ fromaddr='alice@example.org',
+ bccaddrs=['dave@example.org'],
+ web_endpoint='https://example.org/_submit',
+ )
+ assert sent == 1
+ assert posted_payload.get('action') == 'receive'
+ assert posted_payload.get('bcc') == ['dave@example.org']
+
+ def test_send_mail_bcc_from_config(self, monkeypatch: pytest.MonkeyPatch) -> 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()
+
+ monkeypatch.setitem(b4.MAIN_CONFIG, 'send-series-bcc', 'dave@example.org, eve@example.org')
+
+ msg = email.message.EmailMessage()
+ msg['From'] = 'alice@example.org'
+ msg['To'] = 'bob@example.org'
+ msg['Cc'] = 'carol@example.org'
+ msg['Subject'] = 'Test patch'
+ msg.set_content('Diff content')
+
+ sent = b4.send_mail(
+ b4.SMTPConnector(_connect),
+ [msg],
+ fromaddr='alice@example.org',
+ bccaddrs=None,
+ )
+ assert sent == 1
+ assert len(seen_dests) == 1
+ assert set(seen_dests[0]) == {
+ 'bob@example.org',
+ 'carol@example.org',
+ 'dave@example.org',
+ 'eve@example.org',
+ }
+ assert b'Bcc:' not in seen_bodies[0]
+
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
^ permalink raw reply related [flat|nested] 7+ messages in thread* [PATCH b4 v2 3/5] b4: handle per-message Bcc headers in send_mail and TUIs
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
2026-10-12 5:58 ` [PATCH b4 v2 4/5] b4: reject Bcc trailers in patches and cover letter Dmitry Torokhov
` (2 subsequent siblings)
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-10-12 5:58 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
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
^ permalink raw reply related [flat|nested] 7+ messages in thread* [PATCH b4 v2 4/5] b4: reject Bcc trailers in patches and cover letter
2026-10-12 5:58 [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
` (2 preceding siblings ...)
2026-10-12 5:58 ` [PATCH b4 v2 3/5] b4: handle per-message Bcc headers in send_mail and TUIs Dmitry Torokhov
@ 2026-10-12 5:58 ` 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
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-10-12 5:58 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
b4 send does not support per-patch or cover-letter Bcc: trailers.
Without an explicit check, a Bcc: trailer containing an email address
is parsed as a person trailer and silently added to the public Cc:
header, leaking the intended blind recipient.
Explicitly abort with a critical error in get_cover_dests() and
cmd_send() when a person Bcc: trailer is encountered, checking before
commit-only, deduplication, or --no-trailer-to-cc / --preview-to
filters while stripping the patch diff first so diff lines and
non-person prose do not trigger false positives.
Assisted-by: LLM
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
src/b4/ez.py | 62 +++++++++++++++++++++++++---------------
src/tests/test_ez.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 120 insertions(+), 23 deletions(-)
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 71b4648..b2c33fa 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -2278,6 +2278,12 @@ def get_cover_dests(
elif mtr.lname == 'cc' and mtr.addr is not None:
ccs.append(mtr.addr)
mtrs.remove(mtr)
+ elif mtr.lname == 'bcc' and mtr.addr is not None:
+ logger.critical('CRITICAL: Bcc trailers in patches are not supported.')
+ logger.critical(
+ ' Please remove them and use --bcc or send-series-bcc configuration.'
+ )
+ sys.exit(1)
cbody = b4.LoreMessage.rebuild_message(htrs, cmsg, mtrs, basement, sig)
return tos, ccs, cbody
@@ -2935,31 +2941,41 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
else:
seen.update([x[1] for x in todests])
seen.update([x[1] for x in ccdests])
- # Go through the messages to make to/cc headers
- for commit, msg in patches:
- if not msg:
+
+ # Go through the messages to make to/cc headers
+ for commit, msg in patches:
+ if not msg:
+ continue
+ body, _ = b4.LoreMessage.get_payload(msg)
+ btrs, _ = b4.LoreMessage.find_trailers(b4.DIFF_RE.split(body, maxsplit=1)[0])
+ for btr in btrs:
+ if btr.type != 'person':
continue
- body, _ = b4.LoreMessage.get_payload(msg)
- btrs, _ = b4.LoreMessage.find_trailers(body)
- for btr in btrs:
- if btr.type != 'person':
- continue
- if btr.addr is None:
- logger.debug('Skipping trailer %s with no address', btr.lname)
- continue
- if btr.addr[1] in seen:
- continue
- if commit:
- cpccs = pccs[commit]
- if btr.addr not in cpccs:
- cpccs.append(btr.addr)
- continue
- seen.add(btr.addr[1])
- if btr.lname == 'to':
- todests.append(btr.addr)
- continue
- ccdests.append(btr.addr)
+ if btr.lname == 'bcc':
+ logger.critical('CRITICAL: Bcc trailers in patches are not supported.')
+ logger.critical(
+ ' Please remove them and use --bcc or send-series-bcc configuration.'
+ )
+ sys.exit(1)
+ if cmdargs.preview_to or cmdargs.no_trailer_to_cc:
+ continue
+ if btr.addr is None:
+ logger.debug('Skipping trailer %s with no address', btr.lname)
+ continue
+ if btr.addr[1] in seen:
+ continue
+ if commit:
+ cpccs = pccs[commit]
+ if btr.addr not in cpccs:
+ cpccs.append(btr.addr)
+ continue
+ seen.add(btr.addr[1])
+ if btr.lname == 'to':
+ todests.append(btr.addr)
+ continue
+ ccdests.append(btr.addr)
+ if not (cmdargs.preview_to or cmdargs.no_trailer_to_cc):
excludes = b4.get_excluded_addrs()
conf_me_too = str(config.get('send-me-too', 'yes')).lower()
if cmdargs.not_me_too or conf_me_too in {'no', 'n', 'false', '0'}:
diff --git a/src/tests/test_ez.py b/src/tests/test_ez.py
index ccfe0dc..6a9a883 100644
--- a/src/tests/test_ez.py
+++ b/src/tests/test_ez.py
@@ -1483,3 +1483,84 @@ def test_prep_cover_and_deps_flags_are_mutually_exclusive() -> None:
parser.parse_args(['prep', '--edit-cover', '--cover-from-file', '-'])
with pytest.raises(SystemExit):
parser.parse_args(['prep', '--show-deps', '--deps-from-file', '-'])
+
+
+def test_send_rejects_per_patch_bcc_trailer(
+ prepdir_commit: str, caplog: pytest.LogCaptureFixture
+) -> None:
+ """A Bcc: trailer in a patch commit must be rejected by `b4 send` rather
+ than silently added to the per-patch / cover-letter Cc list.
+ """
+ with open(os.path.join(prepdir_commit, 'file1.txt'), 'a') as fh:
+ fh.write('diff context line\nBcc: diff-context@example.com\n')
+ b4.git_run_command(prepdir_commit, ['add', 'file1.txt'])
+ valid_commit_msg = (
+ 'feat: test bcc false positives\n\n'
+ 'Bcc: headers are stripped before signing.\n\n'
+ 'Signed-off-by: Test Author <author@example.com>\n'
+ )
+ ecode, _ = b4.git_run_command(prepdir_commit, ['commit', '-m', valid_commit_msg])
+ assert ecode == 0
+
+ parser = b4.command.setup_parser()
+ cmdargs = parser.parse_args(
+ [
+ '--no-stdin',
+ '--no-interactive',
+ '--offline-mode',
+ 'send',
+ '--dry-run',
+ '--to',
+ 'list@example.com',
+ ]
+ )
+ b4.ez.cmd_send(cmdargs)
+
+ with open(os.path.join(prepdir_commit, 'file1.txt'), 'a') as fh:
+ fh.write('bcc trailer test\n')
+ b4.git_run_command(prepdir_commit, ['add', 'file1.txt'])
+ commit_msg = (
+ 'feat: test bcc trailer rejection\n\n'
+ 'Bcc: Secret Person <secret@example.com>\n'
+ 'Signed-off-by: Test Override <test-override@example.com>\n'
+ )
+ ecode, _ = b4.git_run_command(prepdir_commit, ['commit', '-m', commit_msg])
+ assert ecode == 0
+
+ flag_sets = [
+ ['--dry-run'],
+ ['--dry-run', '--no-trailer-to-cc'],
+ ['--preview-to', 'preview@example.com'],
+ ]
+ for extra_flags in flag_sets:
+ caplog.clear()
+ cmdargs = parser.parse_args(
+ [
+ '--no-stdin',
+ '--no-interactive',
+ '--offline-mode',
+ 'send',
+ '--to',
+ 'list@example.com',
+ ]
+ + extra_flags
+ )
+ with caplog.at_level(logging.CRITICAL, logger='b4'):
+ with pytest.raises(SystemExit) as ex:
+ b4.ez.cmd_send(cmdargs)
+ assert ex.value.code == 1
+ assert 'Bcc trailers in patches are not supported' in caplog.text
+
+
+def test_cover_rejects_bcc_trailer(caplog: pytest.LogCaptureFixture) -> None:
+ cbody = (
+ 'Subject: [PATCH 0/1] Cover letter\n\n'
+ 'Cover letter body\n\n'
+ 'Bcc: Secret Person <secret@example.com>\n'
+ 'Signed-off-by: Test Author <author@example.com>\n'
+ )
+ with caplog.at_level(logging.CRITICAL, logger='b4'):
+ with pytest.raises(SystemExit) as ex:
+ b4.ez.get_cover_dests(cbody)
+ assert ex.value.code == 1
+ assert 'Bcc trailers in patches are not supported' in caplog.text
--
2.56.0.385.gd3acb90ef8-goog
^ permalink raw reply related [flat|nested] 7+ messages in thread* [PATCH b4 v2 5/5] misc/send-receive: require messages payload to be a list
2026-10-12 5:58 [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
` (3 preceding siblings ...)
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 ` Dmitry Torokhov
2026-11-13 2:00 ` [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-10-12 5:58 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
In Python, str is a subclass of collections.abc.Sequence, so checking
isinstance(umsgs, Sequence) accepts a bare string value for "messages"
and iterates over individual characters during validation.
Check isinstance(umsgs, list) directly so non-array JSON values for
"messages" immediately fail with "Invalid messages array".
Assisted-by: LLM
Signed-off-by: Dmitry Torokhov <dmitry.torokhov@gmail.com>
---
misc/send-receive.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/misc/send-receive.py b/misc/send-receive.py
index 22e049b..96eb3fb 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -514,7 +514,7 @@ class SendReceiveListener(object):
if not umsgs:
self.send_error(resp, message='Missing the messages array')
return
- if not isinstance(umsgs, Sequence):
+ if not isinstance(umsgs, list):
self.send_error(resp, message='Invalid messages array')
return
logger.debug('Received a request for %s messages', len(umsgs))
--
2.56.0.385.gd3acb90ef8-goog
^ permalink raw reply related [flat|nested] 7+ messages in thread* Re: [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review
2026-10-12 5:58 [PATCH b4 v2 0/5] b4: Implement BCC support for patch submissions and review Dmitry Torokhov
` (4 preceding siblings ...)
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 ` Dmitry Torokhov
5 siblings, 0 replies; 7+ messages in thread
From: Dmitry Torokhov @ 2026-11-13 2:00 UTC (permalink / raw)
To: Konstantin Ryabitsev; +Cc: Kernel.org Tools
Hi Konstantin,
On Sun, Oct 11, 2026 at 10:58:25PM -0700, Dmitry Torokhov wrote:
> This series implements support for sending patch series and review
> replies to blind carbon copy (BCC) recipients.
>
> The work is split into four parts:
> 1. Server-side support in misc/send-receive.py to receive optional BCC
> envelope destinations via the JSON submission payload and deliver
> them over SMTP while enforcing anti-abuse limits (max-client-bcc)
> and public mailing list destination checks (mustdest).
> 2. Client-side support in b4 to inject series-wide BCC addresses
> directly into envelope recipients (destaddrs) in send_mail() without
> adding or stripping temporary Bcc: headers. This extends BCC
> capability across sending tools (b4 send, b4 review, b4 ty) and
> supports web endpoint submission.
> 3. Per-message Bcc: header handling in send_mail() and the review/bugs
> TUIs, extracting Bcc: headers into envelope recipients and stripping
> them before signing and sending while rejecting them on the web
> endpoint.
> 4. Safety guardrails in ez.py to reject Bcc: trailers in patches and
> cover letters so they are never treated as person trailers and
> leaked into public Cc: headers.
Gentle ping on this series. Have you had a chance to take a look or do
you have any thoughts on this approach? Give a holler if you would like
me to send a v3 rebased onto current master (no functional changes, just
minor conflict resolutions in send-receive.py and ez.py).
Thanks.
--
Dmitry
^ permalink raw reply [flat|nested] 7+ messages in thread