diff --git a/README.md b/README.md index faa34a7..0d97a21 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Emails are sent only from drafts. Create or edit a draft with `save_draft`/`edit ### 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`). - `reply_all=true` includes original recipients; otherwise it replies to the sender/Reply-To. diff --git a/src/services/email_service.py b/src/services/email_service.py index 7bf9cd2..f7d2ae0 100644 --- a/src/services/email_service.py +++ b/src/services/email_service.py @@ -1,4 +1,5 @@ import email +import html from email.header import decode_header from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -464,9 +465,11 @@ class EmailService: cc = context["cc"] in_reply_to = context["in_reply_to"] references = context["references"] + original = context["original"] else: in_reply_to = None references = None + original = None if not to: return OperationResult(success=False, message="'to' is required for drafts") @@ -475,6 +478,9 @@ class EmailService: if body is None: 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( to=to, subject=subject, @@ -554,9 +560,11 @@ class EmailService: resolved_cc = context["cc"] in_reply_to = context["in_reply_to"] references = context["references"] + original = context["original"] else: in_reply_to = None references = None + original = None try: client = self._get_imap_client() @@ -565,6 +573,9 @@ class EmailService: client.delete_messages([uid]) client.expunge() + if original: + resolved_body, resolved_html = self._build_reply_bodies(original, resolved_body) + msg = self._build_draft_message( to=resolved_to, subject=resolved_subject, @@ -975,6 +986,7 @@ class EmailService: references.append(in_reply_to) return { + "original": original, "to": to, "cc": reply_cc or None, "subject": subject, @@ -982,6 +994,44 @@ class EmailService: "references": references or 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", "
") + 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", "
") + html_body = ( + f"
{html.escape(body_text).replace('\\n', '
')}
" + f"

{html_intro}

" + f"
" + f"
{quoted_html}
" + f"



" + ) + + 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( self, sender_email: Optional[str], sender_name: Optional[str] ) -> tuple[Optional[str], str]: