aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-30 22:13:21 -0500
committerCraig Jennings <c@cjennings.net>2026-05-30 22:13:21 -0500
commite446dab251fb0889c516c491e0d73a6ec9f0b873 (patch)
tree681d95d6c75f0b4f5573ec9a55ba3e817ab84d3d
parent143feda0644d2289954b694f3ce4cee2fc74b808 (diff)
downloadrulesets-e446dab251fb0889c516c491e0d73a6ec9f0b873.tar.gz
rulesets-e446dab251fb0889c516c491e0d73a6ec9f0b873.zip
feat(cmail): add --cc/--bcc and threading headers to cmail-action send
cmail-action send couldn't do a proper reply (no Cc/Bcc, no In-Reply-To/References), so an org-drill session that needed to reply to an upstream maintainer hand-rolled a raw MIME message through msmtp instead. I extended build_message (the pure function) with cc, bcc, in_reply_to, and references, wired the matching --cc/--bcc (repeatable), --in-reply-to, and --references flags through cmd_send, and wrote the tests first. send_message derives recipients from the To/Cc/Bcc headers and strips Bcc, so no manual recipient list is needed.
-rwxr-xr-x.ai/scripts/cmail-action.py32
-rw-r--r--.ai/scripts/tests/test_cmail_action.py53
-rwxr-xr-xclaude-templates/.ai/scripts/cmail-action.py32
-rw-r--r--claude-templates/.ai/scripts/tests/test_cmail_action.py53
4 files changed, 160 insertions, 10 deletions
diff --git a/.ai/scripts/cmail-action.py b/.ai/scripts/cmail-action.py
index 10eb215..0acd82d 100755
--- a/.ai/scripts/cmail-action.py
+++ b/.ai/scripts/cmail-action.py
@@ -120,17 +120,31 @@ def extract_body(msg):
return msg.get_content()
-def build_message(from_addr, to_addr, subject, body, attachments=None):
+def build_message(from_addr, to_addr, subject, body, attachments=None,
+ cc=None, bcc=None, in_reply_to=None, references=None):
"""Construct an EmailMessage from the given fields and attachments.
attachments is a list of (filename, maintype, subtype, content_bytes)
- tuples — typically the return value of load_attachment per file. Pure
- function: no I/O, no SMTP.
+ tuples — typically the return value of load_attachment per file.
+
+ cc and bcc accept either a list of addresses or a single string; the
+ Cc/Bcc headers are set when present (smtplib.send_message reads them for
+ delivery and strips Bcc before sending). in_reply_to and references set
+ the In-Reply-To and References headers so a reply threads on the
+ recipient's end. Pure function: no I/O, no SMTP.
"""
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = to_addr
+ if cc:
+ msg["Cc"] = ", ".join(cc) if isinstance(cc, (list, tuple)) else cc
+ if bcc:
+ msg["Bcc"] = ", ".join(bcc) if isinstance(bcc, (list, tuple)) else bcc
msg["Subject"] = subject
+ if in_reply_to:
+ msg["In-Reply-To"] = in_reply_to
+ if references:
+ msg["References"] = references
msg.set_content(body)
for filename, maintype, subtype, content in (attachments or []):
msg.add_attachment(content, maintype=maintype, subtype=subtype,
@@ -310,7 +324,9 @@ def cmd_send(args):
body = Path(args.body_file).read_text()
else:
body = sys.stdin.read()
- msg = build_message(USER, args.to, args.subject, body, attachments)
+ msg = build_message(USER, args.to, args.subject, body, attachments,
+ cc=args.cc, bcc=args.bcc,
+ in_reply_to=args.in_reply_to, references=args.references)
smtp = smtp_connect()
try:
smtp.send_message(msg)
@@ -377,6 +393,14 @@ def main():
"contents become the body")
p_send.add_argument("--attach", action="append", default=[],
help="path to attach (repeatable)")
+ p_send.add_argument("--cc", action="append", default=[],
+ help="Cc address (repeatable)")
+ p_send.add_argument("--bcc", action="append", default=[],
+ help="Bcc address (repeatable)")
+ p_send.add_argument("--in-reply-to",
+ help="Message-ID this replies to (threads on the recipient's end)")
+ p_send.add_argument("--references",
+ help="References header (space-separated Message-IDs)")
p_send.set_defaults(func=cmd_send)
args = p.parse_args()
diff --git a/.ai/scripts/tests/test_cmail_action.py b/.ai/scripts/tests/test_cmail_action.py
index 3f77ca3..6788464 100644
--- a/.ai/scripts/tests/test_cmail_action.py
+++ b/.ai/scripts/tests/test_cmail_action.py
@@ -526,6 +526,52 @@ class TestBuildMessage:
assert parsed["Subject"] == "日本語 ñ ü"
assert "café" in parsed.get_content()
+ def test_cc_and_bcc_headers_set_from_lists(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="Re: thread",
+ body="body",
+ cc=["cc1@example.com", "cc2@example.com"],
+ bcc=["bcc@example.com"],
+ )
+ assert msg["Cc"] == "cc1@example.com, cc2@example.com"
+ assert msg["Bcc"] == "bcc@example.com"
+
+ def test_threading_headers_set(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="Re: thread",
+ body="body",
+ in_reply_to="<abc@host>",
+ references="<root@host> <abc@host>",
+ )
+ assert msg["In-Reply-To"] == "<abc@host>"
+ assert msg["References"] == "<root@host> <abc@host>"
+
+ def test_no_cc_bcc_or_threading_headers_when_omitted(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="plain",
+ body="body",
+ )
+ assert msg["Cc"] is None
+ assert msg["Bcc"] is None
+ assert msg["In-Reply-To"] is None
+ assert msg["References"] is None
+
+ def test_cc_accepts_a_bare_string(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="s",
+ body="b",
+ cc="solo@example.com",
+ )
+ assert msg["Cc"] == "solo@example.com"
+
# ---------------------------------------------------------------------------
# load_attachment — file I/O via tmp_path
@@ -580,12 +626,17 @@ class TestCmdSend:
@staticmethod
def _args(to="r@example.com", subject="s", body="b", body_file=None,
- attach=None, stdin=False):
+ attach=None, stdin=False, cc=None, bcc=None,
+ in_reply_to=None, references=None):
return SimpleNamespace(
to=to, subject=subject,
body=None if stdin else body,
body_file=body_file,
attach=attach or [],
+ cc=cc or [],
+ bcc=bcc or [],
+ in_reply_to=in_reply_to,
+ references=references,
)
def test_normal_inline_body_calls_send_message(self, cmail_action):
diff --git a/claude-templates/.ai/scripts/cmail-action.py b/claude-templates/.ai/scripts/cmail-action.py
index 10eb215..0acd82d 100755
--- a/claude-templates/.ai/scripts/cmail-action.py
+++ b/claude-templates/.ai/scripts/cmail-action.py
@@ -120,17 +120,31 @@ def extract_body(msg):
return msg.get_content()
-def build_message(from_addr, to_addr, subject, body, attachments=None):
+def build_message(from_addr, to_addr, subject, body, attachments=None,
+ cc=None, bcc=None, in_reply_to=None, references=None):
"""Construct an EmailMessage from the given fields and attachments.
attachments is a list of (filename, maintype, subtype, content_bytes)
- tuples — typically the return value of load_attachment per file. Pure
- function: no I/O, no SMTP.
+ tuples — typically the return value of load_attachment per file.
+
+ cc and bcc accept either a list of addresses or a single string; the
+ Cc/Bcc headers are set when present (smtplib.send_message reads them for
+ delivery and strips Bcc before sending). in_reply_to and references set
+ the In-Reply-To and References headers so a reply threads on the
+ recipient's end. Pure function: no I/O, no SMTP.
"""
msg = EmailMessage()
msg["From"] = from_addr
msg["To"] = to_addr
+ if cc:
+ msg["Cc"] = ", ".join(cc) if isinstance(cc, (list, tuple)) else cc
+ if bcc:
+ msg["Bcc"] = ", ".join(bcc) if isinstance(bcc, (list, tuple)) else bcc
msg["Subject"] = subject
+ if in_reply_to:
+ msg["In-Reply-To"] = in_reply_to
+ if references:
+ msg["References"] = references
msg.set_content(body)
for filename, maintype, subtype, content in (attachments or []):
msg.add_attachment(content, maintype=maintype, subtype=subtype,
@@ -310,7 +324,9 @@ def cmd_send(args):
body = Path(args.body_file).read_text()
else:
body = sys.stdin.read()
- msg = build_message(USER, args.to, args.subject, body, attachments)
+ msg = build_message(USER, args.to, args.subject, body, attachments,
+ cc=args.cc, bcc=args.bcc,
+ in_reply_to=args.in_reply_to, references=args.references)
smtp = smtp_connect()
try:
smtp.send_message(msg)
@@ -377,6 +393,14 @@ def main():
"contents become the body")
p_send.add_argument("--attach", action="append", default=[],
help="path to attach (repeatable)")
+ p_send.add_argument("--cc", action="append", default=[],
+ help="Cc address (repeatable)")
+ p_send.add_argument("--bcc", action="append", default=[],
+ help="Bcc address (repeatable)")
+ p_send.add_argument("--in-reply-to",
+ help="Message-ID this replies to (threads on the recipient's end)")
+ p_send.add_argument("--references",
+ help="References header (space-separated Message-IDs)")
p_send.set_defaults(func=cmd_send)
args = p.parse_args()
diff --git a/claude-templates/.ai/scripts/tests/test_cmail_action.py b/claude-templates/.ai/scripts/tests/test_cmail_action.py
index 3f77ca3..6788464 100644
--- a/claude-templates/.ai/scripts/tests/test_cmail_action.py
+++ b/claude-templates/.ai/scripts/tests/test_cmail_action.py
@@ -526,6 +526,52 @@ class TestBuildMessage:
assert parsed["Subject"] == "日本語 ñ ü"
assert "café" in parsed.get_content()
+ def test_cc_and_bcc_headers_set_from_lists(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="Re: thread",
+ body="body",
+ cc=["cc1@example.com", "cc2@example.com"],
+ bcc=["bcc@example.com"],
+ )
+ assert msg["Cc"] == "cc1@example.com, cc2@example.com"
+ assert msg["Bcc"] == "bcc@example.com"
+
+ def test_threading_headers_set(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="Re: thread",
+ body="body",
+ in_reply_to="<abc@host>",
+ references="<root@host> <abc@host>",
+ )
+ assert msg["In-Reply-To"] == "<abc@host>"
+ assert msg["References"] == "<root@host> <abc@host>"
+
+ def test_no_cc_bcc_or_threading_headers_when_omitted(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="plain",
+ body="body",
+ )
+ assert msg["Cc"] is None
+ assert msg["Bcc"] is None
+ assert msg["In-Reply-To"] is None
+ assert msg["References"] is None
+
+ def test_cc_accepts_a_bare_string(self, cmail_action):
+ msg = cmail_action.build_message(
+ from_addr="c@cjennings.net",
+ to_addr="to@example.com",
+ subject="s",
+ body="b",
+ cc="solo@example.com",
+ )
+ assert msg["Cc"] == "solo@example.com"
+
# ---------------------------------------------------------------------------
# load_attachment — file I/O via tmp_path
@@ -580,12 +626,17 @@ class TestCmdSend:
@staticmethod
def _args(to="r@example.com", subject="s", body="b", body_file=None,
- attach=None, stdin=False):
+ attach=None, stdin=False, cc=None, bcc=None,
+ in_reply_to=None, references=None):
return SimpleNamespace(
to=to, subject=subject,
body=None if stdin else body,
body_file=body_file,
attach=attach or [],
+ cc=cc or [],
+ bcc=bcc or [],
+ in_reply_to=in_reply_to,
+ references=references,
)
def test_normal_inline_body_calls_send_message(self, cmail_action):