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:
+175
-49
@@ -8,9 +8,13 @@ import re
|
|||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.header import decode_header
|
from email.header import Header, decode_header
|
||||||
from email.message import EmailMessage
|
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.parser import BytesParser
|
||||||
|
from email.utils import parseaddr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -88,6 +92,135 @@ def safe_filename(name: str) -> str:
|
|||||||
return name
|
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()
|
@click.group()
|
||||||
def mail():
|
def mail():
|
||||||
"""Mail client (IMAP/SMTP). No send command — use draft instead."""
|
"""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()
|
cfg = load_config()
|
||||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||||
|
|
||||||
msg = EmailMessage()
|
headers = {"To": to, "Subject": subject}
|
||||||
msg["From"] = cfg["email"]
|
|
||||||
msg["To"] = to
|
|
||||||
if cc:
|
if cc:
|
||||||
msg["Cc"] = cc
|
headers["Cc"] = cc
|
||||||
msg["Subject"] = subject
|
|
||||||
|
|
||||||
if html:
|
raw = _build_message(cfg, headers, body, html, attach)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
imap = get_imap(cfg)
|
imap = get_imap(cfg)
|
||||||
imap.select("INBOX")
|
imap.select("INBOX")
|
||||||
imap.append("Drafts", None, None, msg.as_bytes())
|
imap.append("Drafts", None, None, raw)
|
||||||
imap.close()
|
imap.close()
|
||||||
imap.logout()
|
imap.logout()
|
||||||
|
|
||||||
@@ -309,9 +422,10 @@ def draft(to, cc, subject, body, attach, html):
|
|||||||
@mail.command()
|
@mail.command()
|
||||||
@click.argument("uid", type=int)
|
@click.argument("uid", type=int)
|
||||||
@click.option("--body", "-b", required=True, help="Reply body")
|
@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")
|
@click.option("--html", is_flag=True, help="Body is HTML")
|
||||||
@handle_errors
|
@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)."""
|
"""Reply to a message by IMAP UID (draft mode — saves to Drafts)."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||||
@@ -327,20 +441,27 @@ def reply(uid, body, html):
|
|||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
original = BytesParser(policy=policy.default).parsebytes(raw_orig)
|
original = BytesParser(policy=policy.default).parsebytes(raw_orig)
|
||||||
|
|
||||||
msg = EmailMessage()
|
subject = original["Subject"] or ""
|
||||||
msg["From"] = cfg["email"]
|
if not str(subject).startswith("Re:"):
|
||||||
msg["To"] = original["Reply-To"] or original["From"]
|
subject = f"Re: {subject}"
|
||||||
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"]
|
|
||||||
|
|
||||||
|
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:
|
if html:
|
||||||
msg.set_content("Reply draft", subtype="plain")
|
# For HTML replies we still include the plain quoted body as plain text
|
||||||
msg.add_alternative(body, subtype="html")
|
# 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:
|
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.close()
|
||||||
imap.logout()
|
imap.logout()
|
||||||
|
|
||||||
@@ -351,9 +472,10 @@ def reply(uid, body, html):
|
|||||||
@click.argument("uid", type=int)
|
@click.argument("uid", type=int)
|
||||||
@click.option("--to", required=True, help="Forward to")
|
@click.option("--to", required=True, help="Forward to")
|
||||||
@click.option("--body", "-b", default="", help="Additional body text")
|
@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")
|
@click.option("--html", is_flag=True, help="Body is HTML")
|
||||||
@handle_errors
|
@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)."""
|
"""Forward a message by IMAP UID (draft mode — saves to Drafts)."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
body = body.replace("\\n", "\n").replace("\\t", "\t")
|
||||||
@@ -369,24 +491,28 @@ def forward(uid, to, body, html):
|
|||||||
raise click.Abort()
|
raise click.Abort()
|
||||||
original = BytesParser(policy=policy.default).parsebytes(raw_orig)
|
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 = ""
|
orig_body = ""
|
||||||
b = original.get_body(preferencelist=("plain",))
|
b = original.get_body(preferencelist=("plain",))
|
||||||
if b:
|
if b:
|
||||||
orig_body = b.get_content()
|
orig_body = b.get_content()
|
||||||
|
|
||||||
if html:
|
subject = f"Fwd: {original['Subject']}"
|
||||||
msg.set_content("Forwarded message", subtype="plain")
|
content = (
|
||||||
content = f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}"
|
f"{body}\n\n"
|
||||||
msg.add_alternative(content.replace("\n", "<br>\n"), subtype="html")
|
f"--- Forwarded message ---\n"
|
||||||
else:
|
f"From: {original['From']}\n"
|
||||||
msg.set_content(f"{body}\n\n--- Forwarded message ---\nFrom: {original['From']}\nDate: {original['Date']}\nSubject: {original['Subject']}\n\n{orig_body}")
|
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.close()
|
||||||
imap.logout()
|
imap.logout()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user