Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
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


  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