From: Dmitry Torokhov <dmitry.torokhov@gmail.com>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
Subject: [PATCH b4 v2 2/5] b4: implement --bcc and send-series-bcc config option
Date: Sun, 11 Oct 2026 22:58:27 -0700 [thread overview]
Message-ID: <20261011-send-series-bcc-v2-2-cd17584c40fb@gmail.com> (raw)
In-Reply-To: <20261011-send-series-bcc-v2-0-cd17584c40fb@gmail.com>
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
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 ` Dmitry Torokhov [this message]
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 ` [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-2-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