Quote original message in reply drafts
All checks were successful
Build And Test / publish (push) Successful in 49s

This commit is contained in:
2026-01-01 17:01:22 -08:00
parent 64af784998
commit 1543bc4174
2 changed files with 51 additions and 1 deletions

View File

@@ -158,7 +158,7 @@ Emails are sent only from drafts. Create or edit a draft with `save_draft`/`edit
### Replying to an email ### Replying to an email
Use `in_reply_to_email_id` on `save_draft` or `edit_draft` to create a reply without a separate tool. Then send it with `send_draft`. Use `in_reply_to_email_id` on `save_draft` or `edit_draft` to create a reply without a separate tool. The draft includes reply headers and a quoted original message so webmail clients can preserve threading on send. Then send it with `send_draft`.
- Provide `in_reply_to_email_id` (and optionally `in_reply_to_mailbox`, default `INBOX`). - Provide `in_reply_to_email_id` (and optionally `in_reply_to_mailbox`, default `INBOX`).
- `reply_all=true` includes original recipients; otherwise it replies to the sender/Reply-To. - `reply_all=true` includes original recipients; otherwise it replies to the sender/Reply-To.

View File

@@ -1,4 +1,5 @@
import email import email
import html
from email.header import decode_header from email.header import decode_header
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
@@ -464,9 +465,11 @@ class EmailService:
cc = context["cc"] cc = context["cc"]
in_reply_to = context["in_reply_to"] in_reply_to = context["in_reply_to"]
references = context["references"] references = context["references"]
original = context["original"]
else: else:
in_reply_to = None in_reply_to = None
references = None references = None
original = None
if not to: if not to:
return OperationResult(success=False, message="'to' is required for drafts") return OperationResult(success=False, message="'to' is required for drafts")
@@ -475,6 +478,9 @@ class EmailService:
if body is None: if body is None:
return OperationResult(success=False, message="'body' is required for drafts") return OperationResult(success=False, message="'body' is required for drafts")
if original:
body, html_body = self._build_reply_bodies(original, body)
msg = self._build_draft_message( msg = self._build_draft_message(
to=to, to=to,
subject=subject, subject=subject,
@@ -554,9 +560,11 @@ class EmailService:
resolved_cc = context["cc"] resolved_cc = context["cc"]
in_reply_to = context["in_reply_to"] in_reply_to = context["in_reply_to"]
references = context["references"] references = context["references"]
original = context["original"]
else: else:
in_reply_to = None in_reply_to = None
references = None references = None
original = None
try: try:
client = self._get_imap_client() client = self._get_imap_client()
@@ -565,6 +573,9 @@ class EmailService:
client.delete_messages([uid]) client.delete_messages([uid])
client.expunge() client.expunge()
if original:
resolved_body, resolved_html = self._build_reply_bodies(original, resolved_body)
msg = self._build_draft_message( msg = self._build_draft_message(
to=resolved_to, to=resolved_to,
subject=resolved_subject, subject=resolved_subject,
@@ -975,6 +986,7 @@ class EmailService:
references.append(in_reply_to) references.append(in_reply_to)
return { return {
"original": original,
"to": to, "to": to,
"cc": reply_cc or None, "cc": reply_cc or None,
"subject": subject, "subject": subject,
@@ -982,6 +994,44 @@ class EmailService:
"references": references or None, "references": references or None,
}, None }, None
def _build_reply_bodies(self, original: Email, body_text: str) -> tuple[str, Optional[str]]:
intro = self._format_reply_intro(original)
quoted_text = original.body_text or ""
text = body_text or ""
if intro:
text = f"{text}\n\n{intro}\n\n{quoted_text}".rstrip()
html_body = None
quoted_html = original.body_html
if not quoted_html and quoted_text:
quoted_html = html.escape(quoted_text).replace("\n", "<br/>")
if quoted_html:
cite = original.headers.get("Message-ID") or original.in_reply_to or ""
cite_attr = f' cite="{html.escape(cite)}"' if cite else ""
html_intro = html.escape(intro).replace("\n", "<br/>")
html_body = (
f"<div>{html.escape(body_text).replace('\\n', '<br/>')}</div>"
f"<br/><br/>{html_intro}<br/><br/>"
f"<blockquote type=\"cite\"{cite_attr}>"
f"<div dir=\"ltr\">{quoted_html}</div>"
f"</blockquote><br/><br/><br/>"
)
return text, html_body
def _format_reply_intro(self, original: Email) -> str:
date_str = ""
if original.date:
try:
date_str = original.date.strftime("%A, %B %d, %Y %H:%M %Z").strip()
except Exception:
date_str = str(original.date)
from_name = original.from_address.name or original.from_address.email
from_email = original.from_address.email
if date_str:
return f"On {date_str}, {from_name} <{from_email}> wrote:"
return f"On {from_name} <{from_email}> wrote:"
def _resolve_sender( def _resolve_sender(
self, sender_email: Optional[str], sender_name: Optional[str] self, sender_email: Optional[str], sender_name: Optional[str]
) -> tuple[Optional[str], str]: ) -> tuple[Optional[str], str]: