mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-05 07:27:27 +08:00
fix(imap): fix mojibake in nested emails, empty headers, and date handling (#909)
* fix(imap): fix mojibake in nested emails, empty headers, and date handling - Add line-by-line mojibake fix fallback for complex emails with mixed content - Apply empty header cleanup globally to fix nested message/rfc822 parts - Add locale-independent date formatting (format_imap_date, format_rfc2822_date) - Fill missing Date header from created_at field - Fix getSubPart for non-multipart messages - Accept CREATE requests from clients (e.g. Gmail creating Drafts) - Strip whitespace from IMAP password - Use MIMEText instead of MIMEMultipart for sent mail generation - Keep body in original CTE encoding for correct BODYSTRUCTURE - Update CHANGELOG (zh/en) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: consolidate IMAP changelog entries into single line Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
64d11799b3
commit
03965f3612
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
- fix: |自动回复| 修复 `source_prefix` 为空字符串时自动回复不触发的问题(#459),空值现在正确匹配所有发件人
|
||||||
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
- fix: |OAuth2| 修复 Android via 浏览器等移动端 OAuth2 登录时 sessionStorage 丢失导致回调失败的问题,新增 localStorage 兜底(#900)
|
||||||
|
- fix: |IMAP| 修复嵌套回复邮件乱码、Gmail 空 Content-Type 头解析失败、缺失 Date 头及 locale 依赖日期格式等问题
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
|
- fix: |Auto Reply| Fix auto-reply not triggering when `source_prefix` is empty string (#459), empty value now correctly matches all senders
|
||||||
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
|
- fix: |OAuth2| Fix OAuth2 login callback failure on Android via browser and other mobile browsers due to sessionStorage loss during redirect, add localStorage fallback (#900)
|
||||||
|
- fix: |IMAP| Fix nested reply email mojibake, Gmail empty Content-Type header parsing failure, missing Date header, and locale-dependent date formatting issues
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class BackendClient:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, password: str):
|
def __init__(self, password: str):
|
||||||
self.password = password
|
self.password = password.strip()
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
base_url=settings.proxy_url,
|
base_url=settings.proxy_url,
|
||||||
headers={
|
headers={
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from zope.interface import implementer
|
|||||||
from config import settings
|
from config import settings
|
||||||
from imap_http_client import BackendClient
|
from imap_http_client import BackendClient
|
||||||
from imap_message import SimpleMessage
|
from imap_message import SimpleMessage
|
||||||
from parse_email import generate_email_model, parse_email
|
from parse_email import generate_email_model, parse_email, clean_raw_headers, fix_mojibake
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_logger.setLevel(logging.INFO)
|
_logger.setLevel(logging.INFO)
|
||||||
@@ -246,6 +246,8 @@ class SimpleMailbox:
|
|||||||
try:
|
try:
|
||||||
if self.name == "INBOX":
|
if self.name == "INBOX":
|
||||||
raw = item.get("raw", "")
|
raw = item.get("raw", "")
|
||||||
|
raw = fix_mojibake(raw)
|
||||||
|
raw = clean_raw_headers(raw)
|
||||||
email_model = parse_email(raw)
|
email_model = parse_email(raw)
|
||||||
elif self.name == "SENT":
|
elif self.name == "SENT":
|
||||||
email_model, raw = generate_email_model(item)
|
email_model, raw = generate_email_model(item)
|
||||||
@@ -256,7 +258,8 @@ class SimpleMailbox:
|
|||||||
self._flags[uid_val] = {r"\Seen"}
|
self._flags[uid_val] = {r"\Seen"}
|
||||||
flags = self._flags[uid_val]
|
flags = self._flags[uid_val]
|
||||||
msg = SimpleMessage(
|
msg = SimpleMessage(
|
||||||
uid_val, email_model, flags=flags, raw=raw
|
uid_val, email_model, flags=flags, raw=raw,
|
||||||
|
created_at=item.get("created_at"),
|
||||||
)
|
)
|
||||||
self._cache.put(uid_val, msg)
|
self._cache.put(uid_val, msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,28 +1,72 @@
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from twisted.mail import imap4
|
from twisted.mail import imap4
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from models import EmailModel
|
from models import EmailModel
|
||||||
|
|
||||||
|
# Locale-independent English names for IMAP date formatting
|
||||||
|
_MONTHS = ('', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
|
||||||
|
_DAYS = ('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun')
|
||||||
|
|
||||||
|
_CREATED_AT_FMTS = (
|
||||||
|
"%Y-%m-%d %H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||||
|
"%Y-%m-%d %H:%M:%S.%f",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_created_at(created_at: str) -> datetime | None:
|
||||||
|
"""Parse created_at string into datetime, returns None on failure."""
|
||||||
|
for fmt in _CREATED_AT_FMTS:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(created_at, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_imap_date(dt: datetime) -> str:
|
||||||
|
"""Format datetime as IMAP INTERNALDATE: '21-Mar-2026 13:04:59 +0000'."""
|
||||||
|
return (f"{dt.day:02d}-{_MONTHS[dt.month]}-{dt.year} "
|
||||||
|
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
|
||||||
|
|
||||||
|
|
||||||
|
def format_rfc2822_date(dt: datetime) -> str:
|
||||||
|
"""Format datetime as RFC 2822: 'Thu, 13 Mar 2026 11:15:57 +0000'."""
|
||||||
|
return (f"{_DAYS[dt.weekday()]}, {dt.day:02d} {_MONTHS[dt.month]} {dt.year} "
|
||||||
|
f"{dt.hour:02d}:{dt.minute:02d}:{dt.second:02d} +0000")
|
||||||
|
|
||||||
|
|
||||||
@implementer(imap4.IMessage, imap4.IMessageFile)
|
@implementer(imap4.IMessage, imap4.IMessageFile)
|
||||||
class SimpleMessage:
|
class SimpleMessage:
|
||||||
|
|
||||||
def __init__(self, uid: int, email_model: EmailModel,
|
def __init__(self, uid: int, email_model: EmailModel,
|
||||||
flags: set[str] = None, raw: str = None):
|
flags: set[str] = None, raw: str = None, created_at: str = None):
|
||||||
self.uid = uid
|
self.uid = uid
|
||||||
self.email = email_model
|
self.email = email_model
|
||||||
self.subparts = self.email.subparts
|
self.subparts = self.email.subparts
|
||||||
self._flags = flags if flags is not None else set()
|
self._flags = flags if flags is not None else set()
|
||||||
self._raw = raw
|
self._raw = raw
|
||||||
|
self._created_at = created_at
|
||||||
|
self._fill_date_header()
|
||||||
|
|
||||||
|
def _fill_date_header(self):
|
||||||
|
"""Fill empty/missing Date header from created_at."""
|
||||||
|
date_val = self.email.headers.get("Date", "").strip()
|
||||||
|
if date_val or not self._created_at:
|
||||||
|
return
|
||||||
|
dt = parse_created_at(self._created_at)
|
||||||
|
if dt:
|
||||||
|
self.email.headers["Date"] = format_rfc2822_date(dt)
|
||||||
|
|
||||||
def getUID(self):
|
def getUID(self):
|
||||||
return self.uid
|
return self.uid
|
||||||
|
|
||||||
def getHeaders(self, negate, *names):
|
def getHeaders(self, negate, *names):
|
||||||
# Twisted passes header names as bytes (e.g. b"SUBJECT");
|
|
||||||
# normalize to lowercase str for comparison.
|
|
||||||
names_lower = set()
|
names_lower = set()
|
||||||
for n in names:
|
for n in names:
|
||||||
if isinstance(n, bytes):
|
if isinstance(n, bytes):
|
||||||
@@ -47,6 +91,10 @@ class SimpleMessage:
|
|||||||
return len(self.subparts) > 0
|
return len(self.subparts) > 0
|
||||||
|
|
||||||
def getSubPart(self, part):
|
def getSubPart(self, part):
|
||||||
|
if not self.subparts:
|
||||||
|
if part == 0:
|
||||||
|
return SimpleMessage(self.uid, self.email, flags=self._flags)
|
||||||
|
raise IndexError(part)
|
||||||
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
|
return SimpleMessage(self.uid, self.subparts[part], flags=self._flags)
|
||||||
|
|
||||||
def getBodyFile(self):
|
def getBodyFile(self):
|
||||||
@@ -61,6 +109,10 @@ class SimpleMessage:
|
|||||||
return list(self._flags)
|
return list(self._flags)
|
||||||
|
|
||||||
def getInternalDate(self):
|
def getInternalDate(self):
|
||||||
|
if self._created_at:
|
||||||
|
dt = parse_created_at(self._created_at)
|
||||||
|
if dt:
|
||||||
|
return format_imap_date(dt)
|
||||||
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
|
return self.email.headers.get("Date", "Mon, 1 Jan 1900 00:00:00 +0000")
|
||||||
|
|
||||||
# IMessageFile
|
# IMessageFile
|
||||||
|
|||||||
@@ -81,13 +81,18 @@ class Account(imap4.MemoryAccount):
|
|||||||
"""Custom account that initializes mailbox UID index on select."""
|
"""Custom account that initializes mailbox UID index on select."""
|
||||||
|
|
||||||
def _emptyMailbox(self, name, id):
|
def _emptyMailbox(self, name, id):
|
||||||
"""Ignore CREATE for unknown mailboxes instead of crashing."""
|
"""Return a dummy mailbox for CREATE requests (e.g. Gmail creating Drafts)."""
|
||||||
return None
|
_logger.debug("Accepting CREATE request for %s", name)
|
||||||
|
return SimpleMailbox(name, self._client)
|
||||||
|
|
||||||
def create(self, pathspec):
|
def create(self, pathspec):
|
||||||
"""Silently ignore mailbox creation requests from clients."""
|
"""Accept CREATE silently without actually creating mailboxes."""
|
||||||
_logger.debug("Ignoring CREATE request for %s", pathspec)
|
_logger.debug("Ignoring CREATE for %s", pathspec)
|
||||||
return False
|
return True
|
||||||
|
|
||||||
|
def listMailboxes(self, ref, wildcard):
|
||||||
|
"""Only list INBOX and SENT, ignore client-created mailboxes."""
|
||||||
|
return [("INBOX", self.mailboxes["INBOX"]), ("SENT", self.mailboxes["SENT"])]
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def select(self, name, readwrite=1):
|
def select(self, name, readwrite=1):
|
||||||
@@ -110,6 +115,7 @@ class SimpleRealm:
|
|||||||
sent = SimpleMailbox("SENT", client)
|
sent = SimpleMailbox("SENT", client)
|
||||||
|
|
||||||
account = Account(username)
|
account = Account(username)
|
||||||
|
account._client = client
|
||||||
account.mailboxes = {"INBOX": inbox, "SENT": sent}
|
account.mailboxes = {"INBOX": inbox, "SENT": sent}
|
||||||
account.subscriptions = ["INBOX", "SENT"]
|
account.subscriptions = ["INBOX", "SENT"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import datetime
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import email
|
import email
|
||||||
|
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
from email.mime.multipart import MIMEMultipart
|
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
|
||||||
from models import EmailModel
|
from models import EmailModel
|
||||||
|
from imap_message import parse_created_at, format_rfc2822_date
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
_logger.setLevel(logging.INFO)
|
_logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Matches an empty header value (header name with no value)
|
||||||
|
_EMPTY_HEADER_RE = re.compile(r'^([A-Za-z][A-Za-z0-9-]*):\s*\r?\n', re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def get_email_model(msg: Message):
|
def get_email_model(msg: Message):
|
||||||
subparts = [
|
subparts = [
|
||||||
@@ -22,23 +26,62 @@ def get_email_model(msg: Message):
|
|||||||
if msg.is_multipart():
|
if msg.is_multipart():
|
||||||
body = ""
|
body = ""
|
||||||
else:
|
else:
|
||||||
raw_body = msg.get_payload(decode=True) or b""
|
# Keep body in its original CTE encoding (base64/QP/7bit/8bit)
|
||||||
charset = msg.get_content_charset() or "utf-8"
|
# so it matches the Content-Transfer-Encoding header.
|
||||||
try:
|
# The IMAP client will decode CTE itself based on BODYSTRUCTURE.
|
||||||
body = raw_body.decode(charset, errors="replace")
|
body = msg.get_payload(decode=False) or ""
|
||||||
except LookupError:
|
|
||||||
body = raw_body.decode("utf-8", errors="replace")
|
|
||||||
return EmailModel(
|
return EmailModel(
|
||||||
headers={k: v for k, v in msg.items()},
|
headers={k: v for k, v in msg.items()},
|
||||||
body=body,
|
body=body,
|
||||||
content_type=msg.get_content_type(),
|
content_type=msg.get_content_type(),
|
||||||
size=len(body) + sum(subpart.size for subpart in subparts),
|
size=len(body.encode("utf-8") if isinstance(body, str) else body) + sum(subpart.size for subpart in subparts),
|
||||||
subparts=subparts,
|
subparts=subparts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_raw_headers(raw: str) -> str:
|
||||||
|
"""Remove empty header lines that break Python email parser.
|
||||||
|
|
||||||
|
Some emails (e.g. from Gmail via Cloudflare) have duplicate headers
|
||||||
|
like 'Content-Type: \\n' (empty) followed by the real Content-Type.
|
||||||
|
The empty one confuses email.message_from_string().
|
||||||
|
|
||||||
|
Applies globally so nested message/rfc822 parts are also cleaned.
|
||||||
|
"""
|
||||||
|
return _EMPTY_HEADER_RE.sub('', raw)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_mojibake(raw: str) -> str:
|
||||||
|
"""Fix UTF-8 mojibake where upstream stored UTF-8 bytes as cp1252/latin-1.
|
||||||
|
|
||||||
|
Tries whole-string fix first (fast path). If that fails (e.g. complex
|
||||||
|
emails with mixed binary/text content), falls back to line-by-line fix.
|
||||||
|
"""
|
||||||
|
# Fast path: fix entire string at once
|
||||||
|
for enc in ("cp1252", "latin-1"):
|
||||||
|
try:
|
||||||
|
return raw.encode(enc).decode("utf-8")
|
||||||
|
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Slow path: fix line by line (tolerates mixed content)
|
||||||
|
lines = raw.split('\n')
|
||||||
|
fixed = []
|
||||||
|
for line in lines:
|
||||||
|
fixed_line = line
|
||||||
|
for enc in ("cp1252", "latin-1"):
|
||||||
|
try:
|
||||||
|
fixed_line = line.encode(enc).decode("utf-8")
|
||||||
|
break
|
||||||
|
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||||
|
continue
|
||||||
|
fixed.append(fixed_line)
|
||||||
|
return '\n'.join(fixed)
|
||||||
|
|
||||||
|
|
||||||
def parse_email(raw: str) -> EmailModel:
|
def parse_email(raw: str) -> EmailModel:
|
||||||
try:
|
try:
|
||||||
|
raw = clean_raw_headers(raw)
|
||||||
msg = email.message_from_string(raw)
|
msg = email.message_from_string(raw)
|
||||||
return get_email_model(msg)
|
return get_email_model(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -52,6 +95,7 @@ def parse_email(raw: str) -> EmailModel:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
||||||
"""Build an EmailModel from a sendbox item.
|
"""Build an EmailModel from a sendbox item.
|
||||||
|
|
||||||
@@ -59,25 +103,24 @@ def generate_email_model(item: dict) -> tuple[EmailModel, str]:
|
|||||||
synthesised MIME to SimpleMessage for correct BODY[] responses.
|
synthesised MIME to SimpleMessage for correct BODY[] responses.
|
||||||
"""
|
"""
|
||||||
email_json = json.loads(item["raw"])
|
email_json = json.loads(item["raw"])
|
||||||
message = MIMEMultipart()
|
|
||||||
if email_json.get("version") == "v2":
|
if email_json.get("version") == "v2":
|
||||||
message['From'] = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
|
from_addr = f'{email_json["from_name"]} <{item["address"]}>' if email_json.get("from_name") else item["address"]
|
||||||
message['To'] = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
|
to_addr = f'{email_json["to_name"]} <{email_json["to_mail"]}>' if email_json.get("to_name") else email_json["to_mail"]
|
||||||
message.attach(MIMEText(
|
content = email_json["content"]
|
||||||
email_json["content"],
|
subtype = "html" if email_json.get("is_html") else "plain"
|
||||||
"html" if email_json.get("is_html") else "plain"
|
|
||||||
))
|
|
||||||
else:
|
else:
|
||||||
message['From'] = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
|
from_addr = f'{email_json["from"]["name"]} <{email_json["from"]["email"]}>'
|
||||||
message['To'] = ", ".join(
|
to_addr = ", ".join(
|
||||||
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
|
[f"{to['name']} <{to['email']}>" for to in email_json["personalizations"][0]["to"]])
|
||||||
message.attach(MIMEText(
|
content = email_json["content"][0]["value"]
|
||||||
email_json["content"][0]["value"],
|
subtype = "html" if "html" in email_json["content"][0]["type"] else "plain"
|
||||||
"html" if "html" in email_json["content"][0]["type"] else "plain"
|
|
||||||
))
|
message = MIMEText(content, subtype, "utf-8")
|
||||||
|
message['From'] = from_addr
|
||||||
|
message['To'] = to_addr
|
||||||
message['Subject'] = email_json["subject"]
|
message['Subject'] = email_json["subject"]
|
||||||
message["Date"] = datetime.datetime.strptime(
|
dt = parse_created_at(item["created_at"])
|
||||||
item["created_at"], "%Y-%m-%d %H:%M:%S"
|
if dt:
|
||||||
).strftime("%a, %d %b %Y %H:%M:%S +0000")
|
message["Date"] = format_rfc2822_date(dt)
|
||||||
raw_mime = message.as_string()
|
raw_mime = message.as_string()
|
||||||
return parse_email(raw_mime), raw_mime
|
return parse_email(raw_mime), raw_mime
|
||||||
|
|||||||
Reference in New Issue
Block a user