refactor: modularize IMAP server with dual login, STARTTLS, and test suite (#859)

refactor: modularize IMAP server with fixes and E2E tests

- Modularize IMAP server into imap_server, imap_mailbox, imap_message,
  imap_http_client, parse_email, config, models
- Support dual login: JWT token and address+password via backend
- Add STARTTLS support with configurable TLS cert/key
- Fix FETCH/STORE returning UID instead of sequence number (RFC 3501)
- Implement IMessageFile.open() for correct BODY[] raw MIME delivery
- Add UIDNEXT to SELECT response via _cbSelectWork override
- Use per-restart UIDVALIDITY to force client resync
- Pass raw MIME to SimpleMessage for accurate RFC822.SIZE
- Fix SENT mailbox returning empty source
- Handle CREATE command gracefully for Thunderbird compatibility
- Add IMAP E2E tests: auth, LIST, SELECT, STATUS, FETCH, SEARCH,
  STORE, UID FETCH, BODY[] integrity, size, seq numbers, SENT mailbox
- Add SMTP E2E tests using nodemailer: send plain/HTML, auth failure,
  sendbox verification
- Add sendTestMail helper using admin/send_mail

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dream Hunter
2026-03-06 11:08:10 +08:00
committed by GitHub
parent e38015a5b6
commit 2a52fd35d5
21 changed files with 1546 additions and 298 deletions

View File

@@ -19,7 +19,15 @@ def get_email_model(msg: Message):
get_email_model(subpart)
for subpart in msg.get_payload()
] if msg.is_multipart() else []
body = "" if msg.is_multipart() else msg._payload
if msg.is_multipart():
body = ""
else:
raw_body = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
try:
body = raw_body.decode(charset, errors="replace")
except LookupError:
body = raw_body.decode("utf-8", errors="replace")
return EmailModel(
headers={k: v for k, v in msg.items()},
body=body,
@@ -44,7 +52,12 @@ def parse_email(raw: str) -> EmailModel:
)
def generate_email_model(item: dict) -> EmailModel:
def generate_email_model(item: dict) -> tuple[EmailModel, str]:
"""Build an EmailModel from a sendbox item.
Returns (EmailModel, raw_mime_string) so callers can pass the
synthesised MIME to SimpleMessage for correct BODY[] responses.
"""
email_json = json.loads(item["raw"])
message = MIMEMultipart()
if email_json.get("version") == "v2":
@@ -66,4 +79,5 @@ def generate_email_model(item: dict) -> EmailModel:
message["Date"] = datetime.datetime.strptime(
item["created_at"], "%Y-%m-%d %H:%M:%S"
).strftime("%a, %d %b %Y %H:%M:%S +0000")
return parse_email(message.as_string())
raw_mime = message.as_string()
return parse_email(raw_mime), raw_mime