From ffaf7e58f2ae06d1af5037e9e231e3908234a7df Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Tue, 23 Jun 2026 10:24:23 +0800 Subject: [PATCH] fix(mail): improve attachment filename and reply body handling - Use RFC 2047 encoded-word for non-ASCII attachment filenames instead of RFC 2231 filename* segments, which Coremail Web fails to display. - Build multipart messages with MIMEMultipart when attachments are present to keep address headers and attachment filenames Coremail-compatible. - Add --attach support to reply and forward commands. - Quote the original message body in reply drafts for proper threading. --- bin/commands/mail.py | 224 +++++++++++++++++++++++++++++++++---------- 1 file changed, 175 insertions(+), 49 deletions(-) diff --git a/bin/commands/mail.py b/bin/commands/mail.py index 4c99e78..f9c8870 100644 --- a/bin/commands/mail.py +++ b/bin/commands/mail.py @@ -8,9 +8,13 @@ import re import smtplib from datetime import datetime from email import policy -from email.header import decode_header +from email.header import Header, decode_header from email.message import EmailMessage +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from email.parser import BytesParser +from email.utils import parseaddr from pathlib import Path import click @@ -88,6 +92,135 @@ def safe_filename(name: str) -> str: return name +def _encode_filename(filename: str) -> str: + """Return a Content-Disposition filename value compatible with Coremail. + + Coremail Web client does not reliably display RFC 2231 filename* segments. + We use RFC 2047 encoded-word for the plain filename parameter, which older + Chinese university webmail clients generally handle better. + """ + ascii_name = filename.encode("ascii", "ignore").decode() + if ascii_name == filename: + return f'filename="{filename}"' + encoded = Header(filename, charset="utf-8").encode() + return f'filename="{encoded}"' + + +def _quote_body(text: str) -> str: + """Prefix each line of a body with '> ' for reply quoting.""" + return "\n".join(f"> {line}" for line in text.splitlines()) + + +def _build_reply_body(original, body: str) -> str: + """Build a reply body that quotes the original message.""" + orig_body = "" + b = original.get_body(preferencelist=("plain",)) + if b: + orig_body = b.get_content() or "" + quoted = _quote_body(orig_body) if orig_body else "" + return ( + f"{body}\n\n" + f"--- Original message ---\n" + f"From: {original['From']}\n" + f"Date: {original['Date']}\n" + f"Subject: {original['Subject']}\n\n" + f"{quoted}" + ) + + +def _references(original) -> str: + """Build References header for a reply.""" + refs = [] + if original["References"]: + refs.append(str(original["References"])) + if original["Message-ID"]: + refs.append(str(original["Message-ID"])) + return " ".join(refs) + + +def _build_address_header(raw_address: str) -> str: + """Encode a 'Display Name ' header for Coremail compatibility. + + Python's compat32 policy folds long encoded words incorrectly when the + address contains both non-ASCII display name and angle brackets. We encode + the display name manually and preserve the raw address outside the encoded + word. + """ + if not raw_address: + return "" + display_name, addr_spec = parseaddr(raw_address) + if not display_name or display_name == addr_spec: + return addr_spec + encoded = base64.b64encode(display_name.encode("utf-8")).decode("ascii") + return f"=?utf-8?b?{encoded}?= <{addr_spec}>" + + +def _build_message( + cfg: dict, + headers: dict, + body: str, + html: bool, + attach_paths, +) -> bytes: + """Build a MIME message and return its serialized bytes. + + Uses MIMEMultipart whenever attachments are present so that non-ASCII + display names in address headers and non-ASCII attachment filenames are + both serialized in a Coremail-compatible way. + """ + attach_paths = [p for p in attach_paths if p] + + if attach_paths: + msg = MIMEMultipart("mixed") + msg["From"] = cfg["email"] + for key, value in headers.items(): + if key in ("To", "Cc") and value: + msg[key] = _build_address_header(value) + elif key == "Subject" and value: + msg[key] = Header(value, charset="utf-8").encode() + elif value: + msg[key] = value + + if html: + text_part = MIMEText("Draft includes HTML version.", "plain", "utf-8") + html_part = MIMEText(body, "html", "utf-8") + msg.attach(text_part) + msg.attach(html_part) + else: + text_part = MIMEText(body, "plain", "utf-8") + msg.attach(text_part) + + for path in attach_paths: + p = Path(path) + if not p.exists(): + click.echo(f"Attachment not found: {path}", err=True) + continue + with open(p, "rb") as f: + data = f.read() + part = MIMEApplication(data) + part["Content-Disposition"] = f"attachment; {_encode_filename(p.name)}" + msg.attach(part) + + return msg.as_bytes() + + # No attachments: use EmailMessage with default policy. + msg = EmailMessage() + msg["From"] = cfg["email"] + for key, value in headers.items(): + if key == "Subject" and value: + msg[key] = value + elif value: + msg[key] = value + + if html: + msg.set_content("Draft includes HTML version.", subtype="plain") + msg.add_alternative(body, subtype="html") + else: + msg.set_content(body) + + return msg.as_bytes() + + @click.group() def mail(): """Mail client (IMAP/SMTP). No send command — use draft instead.""" @@ -269,35 +402,15 @@ def draft(to, cc, subject, body, attach, html): cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") - msg = EmailMessage() - msg["From"] = cfg["email"] - msg["To"] = to + headers = {"To": to, "Subject": subject} if cc: - msg["Cc"] = cc - msg["Subject"] = subject + headers["Cc"] = cc - if html: - msg.set_content("Draft email (HTML)", subtype="plain") - msg.add_alternative(body, subtype="html") - else: - msg.set_content(body) - - for path in attach: - p = Path(path) - if not p.exists(): - click.echo(f"Attachment not found: {path}", err=True) - continue - with open(p, "rb") as f: - msg.add_attachment( - f.read(), - maintype="application", - subtype="octet-stream", - filename=p.name, - ) + raw = _build_message(cfg, headers, body, html, attach) imap = get_imap(cfg) imap.select("INBOX") - imap.append("Drafts", None, None, msg.as_bytes()) + imap.append("Drafts", None, None, raw) imap.close() imap.logout() @@ -309,9 +422,10 @@ def draft(to, cc, subject, body, attach, html): @mail.command() @click.argument("uid", type=int) @click.option("--body", "-b", required=True, help="Reply body") +@click.option("--attach", multiple=True, help="Attachment path(s)") @click.option("--html", is_flag=True, help="Body is HTML") @handle_errors -def reply(uid, body, html): +def reply(uid, body, attach, html): """Reply to a message by IMAP UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") @@ -327,20 +441,27 @@ def reply(uid, body, html): raise click.Abort() original = BytesParser(policy=policy.default).parsebytes(raw_orig) - msg = EmailMessage() - msg["From"] = cfg["email"] - msg["To"] = original["Reply-To"] or original["From"] - msg["Subject"] = f"Re: {original['Subject']}" if not str(original['Subject'] or '').startswith("Re:") else str(original['Subject']) - msg["In-Reply-To"] = original["Message-ID"] - msg["References"] = original["References"] or original["Message-ID"] + subject = original["Subject"] or "" + if not str(subject).startswith("Re:"): + subject = f"Re: {subject}" + headers = { + "To": original["Reply-To"] or original["From"], + "Subject": subject, + "In-Reply-To": original["Message-ID"], + "References": _references(original), + } + + content = _build_reply_body(original, body) if html: - msg.set_content("Reply draft", subtype="plain") - msg.add_alternative(body, subtype="html") + # For HTML replies we still include the plain quoted body as plain text + # alternative and an HTML rendering as primary alternative. + content = _build_reply_body(original, body) + raw = _build_message(cfg, headers, content.replace("\n", "
\n"), True, attach) else: - msg.set_content(body) + raw = _build_message(cfg, headers, content, False, attach) - imap.append("Drafts", None, None, msg.as_bytes()) + imap.append("Drafts", None, None, raw) imap.close() imap.logout() @@ -351,9 +472,10 @@ def reply(uid, body, html): @click.argument("uid", type=int) @click.option("--to", required=True, help="Forward to") @click.option("--body", "-b", default="", help="Additional body text") +@click.option("--attach", multiple=True, help="Attachment path(s)") @click.option("--html", is_flag=True, help="Body is HTML") @handle_errors -def forward(uid, to, body, html): +def forward(uid, to, body, attach, html): """Forward a message by IMAP UID (draft mode — saves to Drafts).""" cfg = load_config() body = body.replace("\\n", "\n").replace("\\t", "\t") @@ -369,24 +491,28 @@ def forward(uid, to, body, html): raise click.Abort() original = BytesParser(policy=policy.default).parsebytes(raw_orig) - msg = EmailMessage() - msg["From"] = cfg["email"] - msg["To"] = to - msg["Subject"] = f"Fwd: {original['Subject']}" - orig_body = "" b = original.get_body(preferencelist=("plain",)) if b: orig_body = b.get_content() - if html: - msg.set_content("Forwarded message", subtype="plain") - content = f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}" - msg.add_alternative(content.replace("\n", "
\n"), subtype="html") - else: - msg.set_content(f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}") + subject = f"Fwd: {original['Subject']}" + content = ( + f"{body}\n\n" + f"--- Forwarded message ---\n" + f"From: {original['From']}\n" + f"Date: {original['Date']}\n" + f"Subject: {original['Subject']}\n\n" + f"{orig_body}" + ) - imap.append("Drafts", None, None, msg.as_bytes()) + headers = {"To": to, "Subject": subject} + if html: + raw = _build_message(cfg, headers, content.replace("\n", "
\n"), True, attach) + else: + raw = _build_message(cfg, headers, content, False, attach) + + imap.append("Drafts", None, None, raw) imap.close() imap.logout()