diff options
| -rwxr-xr-x | .ai/scripts/cmail-action.py | 32 | ||||
| -rw-r--r-- | .ai/scripts/tests/test_cmail_action.py | 53 | ||||
| -rwxr-xr-x | claude-templates/.ai/scripts/cmail-action.py | 32 | ||||
| -rw-r--r-- | claude-templates/.ai/scripts/tests/test_cmail_action.py | 53 |
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): |
