Files
lukasandClaude Opus 5.5 0514053252 Messages size at once; quoted inline images travel inside the reply
The message frame is set up as soon as its document is parsed instead of at
its load event, which waits for every image (remote ones included): it no
longer sits at its first height until the slowest image arrives.

A reply quoted an original's inline (cid:) images as this server's own URLs,
and they were sent like that — broken for the recipient, and mangled copies
("http:///api/…") made browsers look up a host named "api". Outgoing mail
(and the IMAP Drafts copy) now carries them as inline parts with new
Content-IDs; one that can no longer be had is left out. Shown messages load
such URLs from this server (sanitizer v5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00

126 lines
4.4 KiB
Python

"""Mirror local drafts to the account's IMAP Drafts folder (SPEC §6.5 extension).
Drafts are stored server-side as ``\\Draft`` messages so they can be continued on
any standard mail client. IMAP messages are immutable, so each edit APPENDs a new
copy and expunges the previous one (tracked by ``drafts.imap_uid``). Runs in the
worker (credential decryption is worker-only, §10.2).
"""
from __future__ import annotations
import contextlib
import logging
import uuid
from typing import Any
from sqlalchemy import select
from app.db import session_scope
from app.models import Account, Draft, Mailbox, Message
from app.outbox import inline_images
from app.outbox.attachments import load_attachments
from app.outbox.mime_build import build_mime
from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__)
_DRAFT_FLAGS = frozenset({"\\Draft", "\\Seen"})
async def _ensure_drafts_folder(provider: Any, account: Account, user_id: uuid.UUID) -> str:
"""Return the account's Drafts folder path, creating one if it is missing."""
path = "Drafts"
await provider.create_mailbox(path)
remote = await provider.list_mailboxes()
uidvalidity = next((r.uidvalidity for r in remote if r.path == path), 0)
async with session_scope() as session:
existing = await session.scalar(
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
)
if existing is not None:
return existing.path
session.add(
Mailbox(
account_id=account.id,
user_id=user_id,
path=path,
special_use="drafts",
uidvalidity=uidvalidity,
)
)
return path
async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None:
"""APPEND the draft to the Drafts folder and expunge the previous copy."""
did = uuid.UUID(draft_id)
async with session_scope() as session:
draft = await session.get(Draft, did)
if draft is None:
return
account = await session.get(Account, draft.account_id)
if account is None:
return
user_id = draft.user_id
old_uid = draft.imap_uid
reply_to = (
await session.get(Message, draft.reply_to_message_id)
if draft.reply_to_message_id
else None
)
attachments = await load_attachments(session, user_id, draft)
html, inline = await inline_images.embed(
session,
user_id,
str(draft.payload.get("html", "") or ""),
account.email.rsplit("@", 1)[-1],
)
_, sent_raw, _, _ = build_mime(
account, draft, reply_to, attachments, html=html, inline=inline
)
drafts_mb = await session.scalar(
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
)
drafts_path = drafts_mb.path if drafts_mb else None
account_obj = account
provider = build_provider(account_obj)
new_uid = 0
try:
if drafts_path is None:
drafts_path = await _ensure_drafts_folder(provider, account_obj, user_id)
new_uid = await provider.append(drafts_path, sent_raw, _DRAFT_FLAGS)
if old_uid:
with contextlib.suppress(Exception):
await provider.delete_messages(drafts_path, [old_uid])
finally:
await provider.close()
if new_uid:
async with session_scope() as session:
draft = await session.get(Draft, did)
if draft is not None:
draft.imap_uid = new_uid
async def remove_imap_draft_job(ctx: dict[str, Any], account_id: str, uid: int) -> None:
"""Expunge a draft copy from the account's Drafts folder (on discard)."""
aid = uuid.UUID(account_id)
async with session_scope() as session:
account = await session.get(Account, aid)
if account is None:
return
drafts_mb = await session.scalar(
select(Mailbox).where(Mailbox.account_id == aid, Mailbox.special_use == "drafts")
)
drafts_path = drafts_mb.path if drafts_mb else None
account_obj = account
if drafts_path is None:
return
provider = build_provider(account_obj)
try:
with contextlib.suppress(Exception):
await provider.delete_messages(drafts_path, [int(uid)])
finally:
await provider.close()