From bdfa071977cf556c208f637b295ac95f092c16fd Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:20:03 +0200 Subject: [PATCH 1/9] Wire explicit thread recipient resolver into tracked mail client --- src/postmaster/tracked_mail.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/postmaster/tracked_mail.py b/src/postmaster/tracked_mail.py index c78c5a9..dfbcbfe 100644 --- a/src/postmaster/tracked_mail.py +++ b/src/postmaster/tracked_mail.py @@ -14,6 +14,8 @@ from .link_tracking import link_store from .mail_bridge import MailBridgeError from .mail_extensions import EnhancedMailClient, _plain_to_html +from .thread_recipients import ThreadRecipientsMixin + def _sent_clean_html(body_html: str, delivery: dict[str, Any]) -> str: """Render recipient-visible placeholders without retaining recipient telemetry URLs.""" @@ -42,7 +44,7 @@ def _synchronize_transport_headers(outbound: EmailMessage, sent_copy: EmailMessa sent_copy[header] = str(outbound[header]) -class LinkTrackingMailClient(EnhancedMailClient): +class LinkTrackingMailClient(ThreadRecipientsMixin, EnhancedMailClient): """v9.4 delivery variant: tracked recipient MIME plus clean archived Sent MIME.""" def _send_message_with_clean_sent(self, outbound: EmailMessage, sent_copy: EmailMessage, recipients: list[str]) -> dict[str, Any]: From 926e0e8fc3aa264ac6caa79edc45f17c2c40e7e5 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:20:48 +0200 Subject: [PATCH 2/9] Add shared reply and follow-up recipient resolver --- src/postmaster/thread_recipients.py | 406 ++++++++++++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 src/postmaster/thread_recipients.py diff --git a/src/postmaster/thread_recipients.py b/src/postmaster/thread_recipients.py new file mode 100644 index 0000000..b6b69e4 --- /dev/null +++ b/src/postmaster/thread_recipients.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import re +from email import policy +from email.message import Message +from email.parser import BytesParser +from email.utils import getaddresses, parseaddr +from typing import Any, Iterable, Literal + +from .mail_bridge import MailBridgeError + + +ThreadMode = Literal["reply", "follow_up"] +_REPLY_PREFIX_RE = re.compile(r"^(?:\s*re\s*:\s*)+", re.IGNORECASE) + + +def _valid_address(value: str) -> str: + _, address = parseaddr(value or "") + address = address.strip() + if not address or "@" not in address: + return "" + return address + + +def _header_addresses(message: Message, header: str) -> list[str]: + values = message.get_all(header, []) or [] + result: list[str] = [] + for _, address in getaddresses([str(value) for value in values]): + address = address.strip() + if address and "@" in address: + result.append(address) + return result + + +def _iter_alias_values(value: Any) -> Iterable[str]: + if value is None: + return () + if isinstance(value, str): + return (item.strip() for item in re.split(r"[,;\n]", value) if item.strip()) + try: + return (str(item).strip() for item in value if str(item).strip()) + except TypeError: + return (str(value).strip(),) + + +def sender_identity_addresses(settings: Any) -> tuple[str, ...]: + """Return the primary sender plus any account identities/aliases known by Settings.""" + candidates: list[str] = [str(getattr(settings, "email_address", "") or "")] + + # Alias attributes are intentionally optional so this resolver remains compatible with + # current account rows while also honoring deployments/extensions that already attach them. + for attr in ("sender_aliases", "email_aliases", "aliases"): + candidates.extend(_iter_alias_values(getattr(settings, attr, None))) + + # IMAP/SMTP usernames are account-configured identities too when they are email addresses. + # Including them is conservative for self-reply prevention and is a no-op for host/user IDs. + candidates.extend( + [ + str(getattr(settings, "smtp_username", "") or ""), + str(getattr(settings, "imap_username", "") or ""), + ] + ) + + out: list[str] = [] + seen: set[str] = set() + for value in candidates: + address = _valid_address(value) + key = address.casefold() + if address and key not in seen: + seen.add(key) + out.append(address) + return tuple(out) + + +def normalize_reply_subject(subject: str) -> str: + """Collapse repeated leading Re: prefixes to one canonical Re:.""" + original = (subject or "").strip() + base = _REPLY_PREFIX_RE.sub("", original).strip() + return f"Re: {base}" if base else "Re:" + + +def extend_references(references: str, message_id: str) -> str: + """Preserve the existing chain and append the selected message exactly once.""" + existing = (references or "").strip() + selected = (message_id or "").strip() + if not selected: + return existing + tokens = existing.split() + if selected not in tokens: + tokens.append(selected) + return " ".join(tokens) + + +def _dedupe_external( + addresses: Iterable[str], + *, + excluded: set[str], + seen: set[str] | None = None, + strict: bool = False, +) -> list[str]: + out: list[str] = [] + seen_keys = seen if seen is not None else set() + for value in addresses: + address = _valid_address(str(value)) + if not address: + if strict: + raise MailBridgeError(f"Invalid recipient address: {value}") + continue + key = address.casefold() + if key in excluded or key in seen_keys: + continue + seen_keys.add(key) + out.append(address) + return out + + +def merge_thread_cc( + to: Iterable[str], + base_cc: Iterable[str], + extra_cc: Iterable[str] | None, + *, + sender_identities: Iterable[str], +) -> list[str]: + """Merge original/default Cc with caller Cc without self-addresses or duplicates.""" + excluded = {address.casefold() for address in sender_identities} + seen = {address.casefold() for address in to} + merged = _dedupe_external(base_cc, excluded=excluded, seen=seen) + if extra_cc: + merged.extend(_dedupe_external(extra_cc, excluded=excluded, seen=seen, strict=True)) + return merged + + +def resolve_thread_recipients( + message: Message, + *, + mode: ThreadMode, + sender_identities: Iterable[str], +) -> dict[str, Any]: + """Resolve safe To/Cc and thread headers for inbound reply or outbound follow-up.""" + if mode not in {"reply", "follow_up"}: + raise MailBridgeError(f"Unsupported thread recipient mode: {mode}") + + identities = tuple(sender_identities) + excluded = {address.casefold() for address in identities} + from_addresses = _header_addresses(message, "From") + outbound = any(address.casefold() in excluded for address in from_addresses) + + if mode == "reply" and outbound: + raise MailBridgeError( + "Selected message is outbound from this sender account; use follow_up_email instead." + ) + if mode == "follow_up" and not outbound: + raise MailBridgeError( + "Selected message is inbound to this sender account; use reply_email instead of follow_up_email." + ) + + seen: set[str] = set() + if mode == "reply": + reply_to = _header_addresses(message, "Reply-To") + preferred = reply_to if reply_to else from_addresses + to = _dedupe_external(preferred, excluded=excluded, seen=seen) + cc: list[str] = [] + if not to: + source = "Reply-To/From" if reply_to else "From" + raise MailBridgeError( + f"Original inbound email has no external {source} recipient after sender filtering." + ) + else: + # Never read or infer Bcc here. Only visible original To/Cc participate. + to = _dedupe_external(_header_addresses(message, "To"), excluded=excluded, seen=seen) + cc = _dedupe_external(_header_addresses(message, "Cc"), excluded=excluded, seen=seen) + if not to and not cc: + raise MailBridgeError( + "No external recipients remain after removing the sender account and aliases; " + "follow-up was not sent." + ) + if not to: + raise MailBridgeError( + "No external To recipient remains after removing the sender account and aliases; " + "follow-up was not sent." + ) + + message_id = str(message.get("Message-ID", "") or "").strip() + references = extend_references(str(message.get("References", "") or ""), message_id) + return { + "mode": mode, + "direction": "outbound" if outbound else "inbound", + "to": to, + "cc": cc, + "subject": normalize_reply_subject(str(message.get("Subject", "") or "")), + "message_id": message_id, + "references": references, + "sender_identities": list(identities), + } + + +class ThreadRecipientsMixin: + """Explicit inbound-reply / outbound-follow-up semantics on the existing mail pipeline.""" + + def _thread_source_message(self, mailbox: str, uid: str) -> Message: + # Headers are sufficient for recipient resolution/threading, avoiding a full body fetch. + with self._imap() as conn: + self._select(conn, mailbox, readonly=True) + raw = self._fetch_headers(conn, uid) + return BytesParser(policy=policy.default).parsebytes(raw) + + def resolve_thread_recipients(self, mailbox: str, uid: str, *, mode: ThreadMode) -> dict[str, Any]: + return resolve_thread_recipients( + self._thread_source_message(mailbox, uid), + mode=mode, + sender_identities=sender_identity_addresses(self.settings), + ) + + def _send_threaded( + self, + *, + mode: ThreadMode, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + track_opens: bool | None = None, + campaign_id: str | None = None, + ) -> dict[str, Any]: + resolved = self.resolve_thread_recipients(mailbox, uid, mode=mode) + identities = sender_identity_addresses(self.settings) + cc_clean = merge_thread_cc( + resolved["to"], resolved["cc"], cc, sender_identities=identities + ) + track = self._resolve_track_opens(track_opens) + + if not track: + msg, recipients, meta = self._build_message( + to=resolved["to"], + subject=resolved["subject"], + body=body, + cc=cc_clean, + bcc=bcc, + body_html=body_html, + attachments=attachments, + allow_unlisted=False, + in_reply_to=resolved["message_id"], + references=resolved["references"], + ) + result = self._send_message(msg, recipients) + result.update( + { + "html": True, + "amp": False, + "tracked": False, + "individualized": False, + "visible_recipient_headers_preserved": True, + "attachments": meta, + } + ) + else: + # Dynamic dispatch reaches LinkTrackingMailClient._send_individualized in v9.4, + # keeping tracked recipient MIME and sanitized Sent MIME on the same pipeline. + result = self._send_individualized( + to=resolved["to"], + cc=cc_clean, + bcc=bcc, + subject=resolved["subject"], + body=body, + body_html=body_html, + body_amp=None, + attachments=attachments, + track_opens=True, + campaign_id=campaign_id, + in_reply_to=resolved["message_id"], + references=resolved["references"], + ) + + result.update( + { + "thread_mode": mode, + "in_reply_to": resolved["message_id"], + "references": resolved["references"], + "resolved_to": list(resolved["to"]), + "resolved_cc": list(cc_clean), + } + ) + key = "reply_to" if mode == "reply" else "follow_up_to" + result[key] = { + "mailbox": mailbox, + "uid": uid, + "message_id": resolved["message_id"], + } + return result + + def reply_email( + self, + *, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + track_opens: bool | None = None, + campaign_id: str | None = None, + ) -> dict[str, Any]: + return self._send_threaded( + mode="reply", mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + track_opens=track_opens, campaign_id=campaign_id, + ) + + def follow_up_email( + self, + *, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + track_opens: bool | None = None, + campaign_id: str | None = None, + ) -> dict[str, Any]: + return self._send_threaded( + mode="follow_up", mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + track_opens=track_opens, campaign_id=campaign_id, + ) + + def _create_thread_draft( + self, + *, + mode: ThreadMode, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + resolved = self.resolve_thread_recipients(mailbox, uid, mode=mode) + identities = sender_identity_addresses(self.settings) + cc_clean = merge_thread_cc( + resolved["to"], resolved["cc"], cc, sender_identities=identities + ) + msg, recipients, meta = self._build_message( + to=resolved["to"], subject=resolved["subject"], body=body, cc=cc_clean, + bcc=bcc, body_html=body_html, attachments=attachments, + allow_unlisted=True, include_bcc_header=True, + in_reply_to=resolved["message_id"], references=resolved["references"], + ) + result = self._save_draft(msg) + result.update( + { + "html": True, + "attachments": meta, + "recipient_authorization": self.recipient_authorization_status(recipients)["results"], + "thread_mode": mode, + "in_reply_to": resolved["message_id"], + "references": resolved["references"], + "resolved_to": list(resolved["to"]), + "resolved_cc": list(cc_clean), + } + ) + key = "reply_to" if mode == "reply" else "follow_up_to" + result[key] = { + "mailbox": mailbox, + "uid": uid, + "message_id": resolved["message_id"], + } + return result + + def create_reply_draft( + self, + *, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + return self._create_thread_draft( + mode="reply", mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + ) + + def create_follow_up_draft( + self, + *, + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + ) -> dict[str, Any]: + return self._create_thread_draft( + mode="follow_up", mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + ) From 9d30637c005547faadf7cb01d99f965f94162621 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:21:40 +0200 Subject: [PATCH 3/9] Expose follow-up email and draft MCP tools --- src/postmaster/runtime.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/postmaster/runtime.py b/src/postmaster/runtime.py index 2862a2c..b9e6d29 100644 --- a/src/postmaster/runtime.py +++ b/src/postmaster/runtime.py @@ -2,6 +2,7 @@ import os from html import escape +from typing import Any import uvicorn from mcp.types import CallToolResult @@ -38,6 +39,9 @@ def build_status(): status["link_tracking"] = True status["sent_copy_tracking_sanitized"] = True status["provider_qualitative_classification"] = True + status["explicit_reply_follow_up_modes"] = True + status["follow_up_email"] = True + status["follow_up_draft"] = True return status mcp.remove_tool("build_status") @@ -45,6 +49,61 @@ def build_status(): _base.build_status = build_status +@mcp.tool() +def follow_up_email( + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + track_opens: bool | None = None, + campaign_id: str | None = None, + account_id: str | None = None, +): + """ + WRITE ACTION. Threaded follow-up to an outbound/Sent message from the selected account. + + The original visible To/Cc recipients are reused after removing the sender account and + configured account identities. Original Bcc is never rediscovered or exposed. Inbound + messages are rejected and should use reply_email instead. + + Tracking follows the same account-default/explicit override semantics and the same v9.4 + recipient/Sent-clean pipeline as send_email and reply_email. + """ + return _base._safe_call( + mail_client(account_id).follow_up_email, + mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + track_opens=track_opens, campaign_id=campaign_id, + ) + + +@mcp.tool() +def create_follow_up_draft( + mailbox: str, + uid: str, + body: str = "", + cc: list[str] | None = None, + bcc: list[str] | None = None, + body_html: str | None = None, + attachments: list[dict[str, Any]] | None = None, + account_id: str | None = None, +): + """ + WRITE ACTION. Save a threaded follow-up draft for an outbound/Sent message. + + The draft reuses the original visible To/Cc after sender/alias filtering, never recovers + original Bcc, and rejects inbound messages so reply/follow-up semantics stay explicit. + """ + return _base._safe_call( + mail_client(account_id).create_follow_up_draft, + mailbox=mailbox, uid=uid, body=body, cc=cc, bcc=bcc, + body_html=body_html, attachments=attachments, + ) + + @mcp.tool() def get_stored_file_resource(file_id: str, transport: str = "auto") -> CallToolResult: return stored_file_resource_result(_base.file_store(), file_id, transport) From 0ca475e5f6e8570714585f547630a8ce0e9ae038 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:23:09 +0200 Subject: [PATCH 4/9] Add v9.4.2 follow-up recipient regression coverage --- tests/test_v9_4_2_follow_up.py | 391 +++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 tests/test_v9_4_2_follow_up.py diff --git a/tests/test_v9_4_2_follow_up.py b/tests/test_v9_4_2_follow_up.py new file mode 100644 index 0000000..45c010e --- /dev/null +++ b/tests/test_v9_4_2_follow_up.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import base64 +import os +import tempfile +import unittest +from email.message import EmailMessage +from email.utils import getaddresses +from pathlib import Path +from unittest.mock import patch + +from postmaster.email_analytics import EmailAnalyticsStore +from postmaster.link_tracking import LinkTrackingStore +from postmaster.mail_bridge import MailBridgeError, Settings +from postmaster.thread_recipients import resolve_thread_recipients, sender_identity_addresses +from postmaster.tracked_mail import LinkTrackingMailClient, _synchronize_transport_headers + + +def source_message( + *, + sender: str, + to: list[str], + cc: list[str] | None = None, + bcc: list[str] | None = None, + reply_to: str | None = None, + subject: str = "Topic", + message_id: str = "", + references: str = "", +) -> EmailMessage: + msg = EmailMessage() + msg["From"] = sender + msg["To"] = ", ".join(to) + if cc: + msg["Cc"] = ", ".join(cc) + if bcc: + msg["Bcc"] = ", ".join(bcc) + if reply_to: + msg["Reply-To"] = reply_to + msg["Subject"] = subject + msg["Message-ID"] = message_id + if references: + msg["References"] = references + return msg + + +class CapturingThreadClient(LinkTrackingMailClient): + def __init__(self, settings: Settings, source: EmailMessage): + super().__init__(settings) + self.source = source + self.group_messages: list[EmailMessage] = [] + self.group_recipients: list[list[str]] = [] + self.outbound_messages: list[EmailMessage] = [] + self.sent_messages: list[EmailMessage] = [] + self.draft_messages: list[EmailMessage] = [] + self.validation_calls: list[list[str]] = [] + + def _thread_source_message(self, mailbox: str, uid: str): + return self.source + + def _validate_recipients(self, recipients): + cleaned = [str(value).strip() for value in recipients if str(value).strip()] + self.validation_calls.append(cleaned) + if not cleaned: + raise MailBridgeError("At least one recipient is required") + return cleaned + + def _send_message(self, msg, recipients): + self.group_messages.append(msg) + self.group_recipients.append(list(recipients)) + return { + "sent": True, + "from": self.settings.email_address, + "to": list(recipients), + "subject": str(msg.get("Subject", "")), + "message_id": "", + "sent_copy_saved": True, + "sent_copy_error": None, + } + + def _send_message_with_clean_sent(self, outbound, sent_copy, recipients): + _synchronize_transport_headers(outbound, sent_copy, self.settings.email_address) + self.outbound_messages.append(outbound) + self.sent_messages.append(sent_copy) + return { + "sent": True, + "from": self.settings.email_address, + "to": list(recipients), + "subject": str(outbound.get("Subject", "")), + "message_id": str(outbound.get("Message-ID", "")), + "sent_copy_saved": True, + "sent_copy_error": None, + "sent_copy_tracking_sanitized": True, + } + + def _save_draft(self, msg): + self.draft_messages.append(msg) + return { + "draft_saved": True, + "mailbox": "Drafts", + "from": self.settings.email_address, + "to": [a for _, a in getaddresses([msg.get("To", "")]) if a], + "cc": [a for _, a in getaddresses([msg.get("Cc", "")]) if a], + "bcc": [a for _, a in getaddresses([msg.get("Bcc", "")]) if a], + "subject": str(msg.get("Subject", "")), + "message_id": "", + } + + def recipient_authorization_status(self, recipients): + return { + "ok": True, + "results": [ + {"address": value, "authorized_for_automated_send": True} + for value in recipients + ], + } + + +class FollowUpRecipientTests(unittest.TestCase): + def setUp(self) -> None: + self.settings = Settings( + email_address="sender@example.test", + email_password="pw", + enable_send=True, + save_sent_copy=True, + allow_previous_sent_recipients=False, + account_id="acct", + smtp_username="alias@example.test", + smtp_password="pw", + ) + + def client(self, source: EmailMessage) -> CapturingThreadClient: + return CapturingThreadClient(self.settings, source) + + def test_inbound_reply_prefers_reply_to_and_falls_back_to_from(self) -> None: + with_reply_to = source_message( + sender="from@example.net", + to=["sender@example.test"], + reply_to="reply@example.net", + ) + client = self.client(with_reply_to) + result = client.reply_email(mailbox="INBOX", uid="1", body="Reply", track_opens=False) + self.assertEqual(result["resolved_to"], ["reply@example.net"]) + self.assertEqual(str(client.group_messages[0]["To"]), "reply@example.net") + + fallback = resolve_thread_recipients( + source_message(sender="from@example.net", to=["sender@example.test"]), + mode="reply", + sender_identities=sender_identity_addresses(self.settings), + ) + self.assertEqual(fallback["to"], ["from@example.net"]) + + def test_follow_up_uses_original_to_and_preserves_original_cc(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["one@example.net", "two@example.net"], + cc=["copy@example.net"], + )) + result = client.follow_up_email(mailbox="Sent", uid="2", body="Following up", track_opens=False) + self.assertEqual(result["resolved_to"], ["one@example.net", "two@example.net"]) + self.assertEqual(result["resolved_cc"], ["copy@example.net"]) + self.assertEqual(str(client.group_messages[0]["To"]), "one@example.net, two@example.net") + self.assertEqual(str(client.group_messages[0]["Cc"]), "copy@example.net") + + def test_sender_primary_and_alias_removed_and_addresses_deduped_case_insensitive(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=[ + "sender@example.test", + "A@example.net", + "a@EXAMPLE.NET", + "alias@example.test", + ], + cc=[ + "B@example.net", + "A@EXAMPLE.NET", + "ALIAS@example.test", + "b@EXAMPLE.NET", + ], + )) + result = client.follow_up_email(mailbox="Sent", uid="3", body="Follow-up", track_opens=False) + self.assertEqual(result["resolved_to"], ["A@example.net"]) + self.assertEqual(result["resolved_cc"], ["B@example.net"]) + + def test_original_bcc_is_never_rediscovered_or_exposed(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["one@example.net"], + cc=["copy@example.net"], + bcc=["secret@example.net"], + )) + client.follow_up_email(mailbox="Sent", uid="4", body="Follow-up", track_opens=False) + outgoing = client.group_messages[0] + self.assertIsNone(outgoing.get("Bcc")) + self.assertNotIn("secret@example.net", client.group_recipients[0]) + + def test_zero_external_recipients_errors_without_send(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["sender@example.test", "alias@example.test"], + cc=["ALIAS@example.test"], + )) + with self.assertRaisesRegex(MailBridgeError, "No external recipients"): + client.follow_up_email(mailbox="Sent", uid="5", body="Nope", track_opens=False) + self.assertEqual(client.group_messages, []) + self.assertEqual(client.outbound_messages, []) + + def test_reply_on_outbound_errors_use_follow_up_without_send(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["external@example.net"], + )) + with self.assertRaisesRegex(MailBridgeError, "use follow_up_email"): + client.reply_email(mailbox="Sent", uid="6", body="Nope", track_opens=False) + self.assertEqual(client.group_messages, []) + self.assertEqual(client.outbound_messages, []) + + def test_follow_up_on_inbound_is_rejected(self) -> None: + client = self.client(source_message( + sender="external@example.net", + to=["sender@example.test"], + )) + with self.assertRaisesRegex(MailBridgeError, "use reply_email"): + client.follow_up_email(mailbox="INBOX", uid="7", body="Nope", track_opens=False) + self.assertEqual(client.group_messages, []) + + def test_thread_headers_and_subject_are_normalized(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["external@example.net"], + subject=" RE: re: Launch plan", + message_id="", + references=" ", + )) + result = client.follow_up_email(mailbox="Sent", uid="8", body="Ping", track_opens=False) + msg = client.group_messages[0] + self.assertEqual(str(msg["Subject"]), "Re: Launch plan") + self.assertEqual(str(msg["In-Reply-To"]), "") + self.assertEqual( + str(msg["References"]), + " ", + ) + self.assertEqual(result["in_reply_to"], "") + + def test_authorization_receives_only_resolved_external_recipients(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["sender@example.test", "one@example.net", "alias@example.test"], + cc=["two@example.net", "sender@example.test"], + )) + client.follow_up_email(mailbox="Sent", uid="9", body="Authorized", track_opens=False) + flattened = [value.casefold() for call in client.validation_calls for value in call] + self.assertIn("one@example.net", flattened) + self.assertIn("two@example.net", flattened) + self.assertNotIn("sender@example.test", flattened) + self.assertNotIn("alias@example.test", flattened) + + def test_create_follow_up_draft_is_addressed_and_threaded_without_send(self) -> None: + client = self.client(source_message( + sender="sender@example.test", + to=["one@example.net"], + cc=["copy@example.net"], + bcc=["old-secret@example.net"], + subject="Re: Topic", + message_id="", + references="", + )) + result = client.create_follow_up_draft(mailbox="Sent", uid="10", body="Draft body") + self.assertTrue(result["draft_saved"]) + self.assertEqual(result["to"], ["one@example.net"]) + self.assertEqual(result["cc"], ["copy@example.net"]) + self.assertEqual(result["bcc"], []) + draft = client.draft_messages[0] + self.assertEqual(str(draft["In-Reply-To"]), "") + self.assertEqual(str(draft["References"]), " ") + self.assertEqual(client.group_messages, []) + self.assertEqual(client.outbound_messages, []) + + +class FollowUpTrackedPipelineTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.old_public = {key: os.environ.get(key) for key in ("PUBLIC_EMAIL_BASE_URL", "PUBLIC_MCP_HOST")} + os.environ["PUBLIC_EMAIL_BASE_URL"] = "https://postmaster.example.test" + os.environ["PUBLIC_MCP_HOST"] = "" + self.analytics = EmailAnalyticsStore( + db_path=str(root / "analytics.db"), + key_path=str(root / "analytics.key"), + ) + self.links = LinkTrackingStore(self.analytics) + self.settings = Settings( + email_address="sender@example.test", + email_password="pw", + enable_send=True, + save_sent_copy=True, + allow_previous_sent_recipients=False, + account_id="acct", + smtp_username="alias@example.test", + smtp_password="pw", + ) + self.client = CapturingThreadClient( + self.settings, + source_message( + sender="sender@example.test", + to=["one@example.net", "two@example.net", "alias@example.test"], + cc=["copy@example.net", "sender@example.test"], + bcc=["old-secret@example.net"], + subject="Topic", + message_id="", + references="", + ), + ) + + def tearDown(self) -> None: + for key, value in self.old_public.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + self.tmp.cleanup() + + @staticmethod + def part_text(msg: EmailMessage, content_type: str) -> str: + for part in msg.walk(): + if part.get_content_type() == content_type: + return str(part.get_content()) + return "" + + @staticmethod + def attachment_bytes(msg: EmailMessage) -> list[bytes]: + return [ + part.get_payload(decode=True) or b"" + for part in msg.walk() + if part.get_content_disposition() == "attachment" + ] + + def test_tracked_follow_up_preserves_visible_to_cc_and_sent_clean_attachment_bytes(self) -> None: + payload = b"\x00follow-up-attachment\xff" + attachments = [{ + "filename": "asset.bin", + "content_type": "application/octet-stream", + "content_base64": base64.b64encode(payload).decode("ascii"), + }] + html = 'Open link' + with patch("postmaster.tracked_mail.analytics_store", return_value=self.analytics), patch( + "postmaster.tracked_mail.link_store", return_value=self.links + ): + result = self.client.follow_up_email( + mailbox="Sent", + uid="11", + body="Plain fallback", + body_html=html, + attachments=attachments, + track_opens=True, + ) + + self.assertTrue(result["sent"]) + self.assertTrue(result["sent_copy_tracking_sanitized"]) + self.assertEqual(result["resolved_to"], ["one@example.net", "two@example.net"]) + self.assertEqual(result["resolved_cc"], ["copy@example.net"]) + self.assertEqual(len(self.client.outbound_messages), 3) + self.assertEqual(len(self.client.sent_messages), 3) + + for outbound, sent in zip(self.client.outbound_messages, self.client.sent_messages): + self.assertEqual(str(outbound["To"]), "one@example.net, two@example.net") + self.assertEqual(str(outbound["Cc"]), "copy@example.net") + self.assertEqual(str(sent["To"]), "one@example.net, two@example.net") + self.assertEqual(str(sent["Cc"]), "copy@example.net") + self.assertIsNone(outbound.get("Bcc")) + self.assertIsNone(sent.get("Bcc")) + self.assertEqual(str(outbound["In-Reply-To"]), "") + self.assertEqual(str(sent["In-Reply-To"]), "") + outbound_html = self.part_text(outbound, "text/html") + sent_html = self.part_text(sent, "text/html") + self.assertIn("/track/open/", outbound_html) + self.assertIn("/t/c/", outbound_html) + self.assertNotIn("/track/open/", sent_html) + self.assertNotIn("/t/c/", sent_html) + self.assertIn("https://destination.example/path?q=1", sent_html) + self.assertEqual(self.attachment_bytes(outbound), [payload]) + self.assertEqual(self.attachment_bytes(sent), [payload]) + + recipients = {row["recipient"] for row in result["deliveries"]} + self.assertEqual(recipients, {"one@example.net", "two@example.net", "copy@example.net"}) + self.assertNotIn("sender@example.test", recipients) + self.assertNotIn("alias@example.test", recipients) + self.assertNotIn("old-secret@example.net", recipients) + + +if __name__ == "__main__": + unittest.main() From 6de6fde6f02d09c054d70b34f4ee80fb924c2e59 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:25:46 +0200 Subject: [PATCH 5/9] Set version to 9.4.2 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ccfb75e..3c40359 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.4.1 +9.4.2 From ab89766dc0fab21d9e39b9db4de875467068571c Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:26:19 +0200 Subject: [PATCH 6/9] Document v9.4.2 reply and follow-up semantics --- CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fc6fb8..36f7715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable release should update `VERSION`, this changelog, and publish an immutable Git tag/release named `vX.Y.Z`. +## 9.4.2 - 2026-08-20 + +### Added +- Explicit outbound follow-up tools `follow_up_email` and `create_follow_up_draft`, mirroring the existing reply APIs while keeping inbound replies and outbound follow-ups as separate safety semantics. +- Shared thread-recipient resolution for reply/follow-up mode. Inbound replies prefer a valid `Reply-To` and otherwise use `From`; outbound follow-ups reuse the original visible `To` and preserve the original visible `Cc` by default. +- Direction guards: `reply_email` rejects messages clearly sent by the selected sender account and tells callers to use `follow_up_email`; `follow_up_email` rejects inbound messages and points callers to `reply_email`. +- Regression coverage for recipient direction, sender/identity filtering, case-insensitive deduplication, Bcc non-disclosure, zero-recipient failures, threading headers, subject normalization, recipient authorization, drafts, tracked visible headers, clean Sent copies and attachment-byte identity. + +### Changed +- Sender-owned identities are filtered from resolved `To`/`Cc` before authorization or delivery. The primary sender plus account-configured email identities are compared case-insensitively, and duplicate external recipients are removed while preserving a stable order. +- Thread subjects now normalize repeated leading `Re:` prefixes to one `Re:`. `In-Reply-To` targets the selected message's `Message-ID`, while `References` are preserved and extended without duplicating that selected ID. +- Follow-ups use the same existing outbound path as replies/sends. Tracked follow-ups therefore retain v9.4 individualized recipient MIME, visible `To`/`Cc`, clean archived Sent MIME, original URLs and identical attachment bytes without introducing a second tracking pipeline. + +### Fixed +- Calling `reply_email` on an outbound/Sent message can no longer select the sender's own `From` address and create a self-reply. +- Outbound follow-ups no longer authorize or validate the sender address in place of the original external recipients. +- Original Bcc recipients are never rediscovered, inferred or re-exposed by follow-up resolution. +- A follow-up with no external recipient left after sender/identity filtering fails before any SMTP delivery. + +### Compatibility / deployment +- Existing `reply_email` and `create_reply_draft` signatures remain compatible; their safe direction semantics are now explicit. +- No recipient-policy rule, tracking schema, environment variable, volume, port or public callback path changes are required. +- `postmaster-mcp.yml` remains unchanged. Deployments using `POSTMASTER_VERSION=latest` with update checks enabled can select v9.4.2 on the normal restart/redeploy after the stable release is published. +- No Cloudflare Access change is required for v9.4.2. + ## 9.4.1 - 2026-08-20 ### Added @@ -114,4 +139,4 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable rel - CI coverage for runtime import, bootstrap, model provisioning, MIME regressions and knowledge operations. ### Changed -- Public project naming and configuration became provider-agnostic. \ No newline at end of file +- Public project naming and configuration became provider-agnostic. From 62d2e081fe77a4bfee9f9bf3abf02855e8e5d2e2 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:27:28 +0200 Subject: [PATCH 7/9] Temporarily patch README in place for v9.4.2 --- .github/workflows/v9-4-2-readme-patch.yml | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/v9-4-2-readme-patch.yml diff --git a/.github/workflows/v9-4-2-readme-patch.yml b/.github/workflows/v9-4-2-readme-patch.yml new file mode 100644 index 0000000..4add726 --- /dev/null +++ b/.github/workflows/v9-4-2-readme-patch.yml @@ -0,0 +1,58 @@ +name: v9.4.2 README patch + +on: + push: + branches: + - v9.4.2-follow-up-recipients + paths: + - .github/workflows/v9-4-2-readme-patch.yml + +permissions: + contents: write + +jobs: + patch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: v9.4.2-follow-up-recipients + - name: Patch README in place + run: | + python - <<'PY' + from pathlib import Path + path = Path('README.md') + text = path.read_text(encoding='utf-8') + + replacements = { + '+-- drafts, replies and attachments': '+-- drafts, replies, follow-ups and attachments', + 'Postmaster MCP supports plain text, HTML, attachments, drafts, replies and forwarded messages.': 'Postmaster MCP supports plain text, HTML, attachments, drafts, replies, follow-ups and forwarded messages.', + 'Open tracking can be configured per account and overridden for individual sends/replies.': 'Open tracking can be configured per account and overridden for individual sends/replies/follow-ups.', + 'Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies preserve normal threading headers.': 'Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies and follow-ups preserve normal threading headers.', + } + for old, new in replacements.items(): + if old not in text: + raise SystemExit(f'Missing README anchor: {old!r}') + text = text.replace(old, new, 1) + + marker = '\n---\n\n# Recipient safety\n' + section = '''\n---\n\n# Reply vs follow-up (v9.4.2)\n\nThreaded mail actions deliberately separate inbound replies from outbound follow-ups:\n\n```text\nreply_email -> reply to an inbound message\ncreate_reply_draft -> draft a reply to an inbound message\nfollow_up_email -> follow up an outbound/Sent message\ncreate_follow_up_draft -> draft a follow-up to an outbound/Sent message\n```\n\nFor inbound messages, `reply_email` prefers a valid `Reply-To` and otherwise uses `From`. Calling it on a message clearly sent by the selected account is rejected with guidance to use `follow_up_email`, preventing self-replies.\n\nFor outbound/Sent messages, `follow_up_email` reuses the original visible `To` and preserves the original visible `Cc` by default. The sender account and its configured email identities are removed case-insensitively, duplicates are removed while preserving order, and at least one external `To` recipient must remain. Original Bcc recipients are never rediscovered, inferred or exposed. Calling follow-up on an inbound message is rejected.\n\nBoth modes preserve normal threading: one normalized `Re:` prefix, `In-Reply-To` pointing to the selected message's `Message-ID`, and `References` preserved/extended. Follow-up sending uses the same recipient-authorization, tracking, individualized-delivery and clean-Sent pipeline as existing sends/replies; it does not introduce a parallel tracking implementation.\n\n---\n\n# Recipient safety\n''' + if marker not in text: + raise SystemExit('Missing Recipient safety insertion anchor') + text = text.replace(marker, section, 1) + + release_section = '''\n\n# Explicit reply/follow-up semantics (v9.4.2)\n\nv9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered.\n\nTracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required.\n''' + if '# Explicit reply/follow-up semantics (v9.4.2)' not in text: + text = text.rstrip() + release_section + '\n' + + path.write_text(text, encoding='utf-8') + PY + git diff --check + git diff -- README.md + - name: Commit README update + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git add README.md + git commit -m 'Document v9.4.2 reply and follow-up semantics' + git push origin HEAD:v9.4.2-follow-up-recipients From eb2da63db0a390a82d4a7db14cc8eee699323389 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:29:03 +0200 Subject: [PATCH 8/9] Document v9.4.2 reply and follow-up semantics --- README.md | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d9e010e..b1f90bf 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Postmaster MCP +-- IMAP / SMTP mail operations +-- encrypted multi-account storage +-- recipient safety policy - +-- drafts, replies and attachments + +-- drafts, replies, follow-ups and attachments +-- open analytics / AMP support +-- persistent task registry +-- memories / skills / project context @@ -344,7 +344,7 @@ See `docs/context-model.md` for details. # Email and MIME handling -Postmaster MCP supports plain text, HTML, attachments, drafts, replies and forwarded messages. +Postmaster MCP supports plain text, HTML, attachments, drafts, replies, follow-ups and forwarded messages. A normal multipart message may contain: @@ -386,6 +386,25 @@ Credentials remain server-side and are not returned through MCP tools. --- +# Reply vs follow-up (v9.4.2) + +Threaded mail actions deliberately separate inbound replies from outbound follow-ups: + +```text +reply_email -> reply to an inbound message +create_reply_draft -> draft a reply to an inbound message +follow_up_email -> follow up an outbound/Sent message +create_follow_up_draft -> draft a follow-up to an outbound/Sent message +``` + +For inbound messages, `reply_email` prefers a valid `Reply-To` and otherwise uses `From`. Calling it on a message clearly sent by the selected account is rejected with guidance to use `follow_up_email`, preventing self-replies. + +For outbound/Sent messages, `follow_up_email` reuses the original visible `To` and preserves the original visible `Cc` by default. The sender account and its configured email identities are removed case-insensitively, duplicates are removed while preserving order, and at least one external `To` recipient must remain. Original Bcc recipients are never rediscovered, inferred or exposed. Calling follow-up on an inbound message is rejected. + +Both modes preserve normal threading: one normalized `Re:` prefix, `In-Reply-To` pointing to the selected message's `Message-ID`, and `References` preserved/extended. Follow-up sending uses the same recipient-authorization, tracking, individualized-delivery and clean-Sent pipeline as existing sends/replies; it does not introduce a parallel tracking implementation. + +--- + # Recipient safety Sending is protected by an authorization policy. @@ -436,7 +455,7 @@ The server persists the task state; the AI client performs the reasoning and exp # Open tracking and AMP -Open tracking can be configured per account and overridden for individual sends/replies. +Open tracking can be configured per account and overridden for individual sends/replies/follow-ups. ```text track_opens: null -> account default @@ -444,7 +463,7 @@ track_opens: true -> enable for this message track_opens: false -> disable for this message ``` -Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies preserve normal threading headers. +Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies and follow-ups preserve normal threading headers. Open events are telemetry, not proof that a human read a message. Mail scanners, proxies, prefetching and image blocking can affect observations. @@ -685,3 +704,9 @@ Cloudflare Access is external to the container. Keep the existing public bypasse Do not expose `/mcp`, dashboard/admin/private APIs, mail/task/memory/skill/file-management endpoints or tracking analytics. The pre-existing v9.3 signed `/files/*` handoff remains a separate deployment-policy concern and is not added automatically as part of v9.4. See `docs/LINK_TRACKING.md` for architecture, schema, Sent-clean behavior, analytics and the live Cloudflare preflight. + +# Explicit reply/follow-up semantics (v9.4.2) + +v9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered. + +Tracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required. From 229b58ee7f1bf860dc356f63cb46b526bac9c10e Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:29:15 +0200 Subject: [PATCH 9/9] Remove temporary README patch workflow --- .github/workflows/v9-4-2-readme-patch.yml | 58 ----------------------- 1 file changed, 58 deletions(-) delete mode 100644 .github/workflows/v9-4-2-readme-patch.yml diff --git a/.github/workflows/v9-4-2-readme-patch.yml b/.github/workflows/v9-4-2-readme-patch.yml deleted file mode 100644 index 4add726..0000000 --- a/.github/workflows/v9-4-2-readme-patch.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: v9.4.2 README patch - -on: - push: - branches: - - v9.4.2-follow-up-recipients - paths: - - .github/workflows/v9-4-2-readme-patch.yml - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: v9.4.2-follow-up-recipients - - name: Patch README in place - run: | - python - <<'PY' - from pathlib import Path - path = Path('README.md') - text = path.read_text(encoding='utf-8') - - replacements = { - '+-- drafts, replies and attachments': '+-- drafts, replies, follow-ups and attachments', - 'Postmaster MCP supports plain text, HTML, attachments, drafts, replies and forwarded messages.': 'Postmaster MCP supports plain text, HTML, attachments, drafts, replies, follow-ups and forwarded messages.', - 'Open tracking can be configured per account and overridden for individual sends/replies.': 'Open tracking can be configured per account and overridden for individual sends/replies/follow-ups.', - 'Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies preserve normal threading headers.': 'Tracked multi-recipient delivery uses a distinct token per recipient while preserving visible `To` / `Cc` headers. `Bcc` remains hidden. Replies and follow-ups preserve normal threading headers.', - } - for old, new in replacements.items(): - if old not in text: - raise SystemExit(f'Missing README anchor: {old!r}') - text = text.replace(old, new, 1) - - marker = '\n---\n\n# Recipient safety\n' - section = '''\n---\n\n# Reply vs follow-up (v9.4.2)\n\nThreaded mail actions deliberately separate inbound replies from outbound follow-ups:\n\n```text\nreply_email -> reply to an inbound message\ncreate_reply_draft -> draft a reply to an inbound message\nfollow_up_email -> follow up an outbound/Sent message\ncreate_follow_up_draft -> draft a follow-up to an outbound/Sent message\n```\n\nFor inbound messages, `reply_email` prefers a valid `Reply-To` and otherwise uses `From`. Calling it on a message clearly sent by the selected account is rejected with guidance to use `follow_up_email`, preventing self-replies.\n\nFor outbound/Sent messages, `follow_up_email` reuses the original visible `To` and preserves the original visible `Cc` by default. The sender account and its configured email identities are removed case-insensitively, duplicates are removed while preserving order, and at least one external `To` recipient must remain. Original Bcc recipients are never rediscovered, inferred or exposed. Calling follow-up on an inbound message is rejected.\n\nBoth modes preserve normal threading: one normalized `Re:` prefix, `In-Reply-To` pointing to the selected message's `Message-ID`, and `References` preserved/extended. Follow-up sending uses the same recipient-authorization, tracking, individualized-delivery and clean-Sent pipeline as existing sends/replies; it does not introduce a parallel tracking implementation.\n\n---\n\n# Recipient safety\n''' - if marker not in text: - raise SystemExit('Missing Recipient safety insertion anchor') - text = text.replace(marker, section, 1) - - release_section = '''\n\n# Explicit reply/follow-up semantics (v9.4.2)\n\nv9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered.\n\nTracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required.\n''' - if '# Explicit reply/follow-up semantics (v9.4.2)' not in text: - text = text.rstrip() + release_section + '\n' - - path.write_text(text, encoding='utf-8') - PY - git diff --check - git diff -- README.md - - name: Commit README update - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - git add README.md - git commit -m 'Document v9.4.2 reply and follow-up semantics' - git push origin HEAD:v9.4.2-follow-up-recipients