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.
This commit is contained in:
Zhengshou Lai
2026-06-23 10:24:23 +08:00
parent 9d802aa54b
commit ffaf7e58f2
+175 -49
View File
@@ -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 <addr>' 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", "<br>\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", "<br>\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", "<br>\n"), True, attach)
else:
raw = _build_message(cfg, headers, content, False, attach)
imap.append("Drafts", None, None, raw)
imap.close()
imap.logout()