Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI
Backend - Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX. - Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr. - Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge); inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID. - Per-user storage quota (5 GiB default) and manual "sync all folders" action. - Account editing (display/color/server/password) and per-account HTML signature. - Account view scoped to that account's inbox; new migrations 0006–0009. Frontend - Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh. - Aligned + widened mail-list margins; reading-pane bars fixed height. - Rich-text composer: color palette, highlight, image; attachments; expand/pop-out. - Signatures: auto-insert for new mail, replace on From change, reply insert. - Restore/Delete actions per folder; drafts view merges local + remote, date-sorted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""Resolve a draft's compose-attachment ids into raw (filename, mime, data) tuples."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.indexer import blobstore
|
||||
from app.models import ComposeAttachment, Draft
|
||||
|
||||
|
||||
async def load_attachments(
|
||||
session: AsyncSession, user_id: uuid.UUID, draft: Draft
|
||||
) -> list[tuple[str, str, bytes]]:
|
||||
"""Return each of the draft's compose attachments as ``(filename, mime, data)``."""
|
||||
raw_ids = draft.payload.get("attachment_ids") or []
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
return []
|
||||
ids: list[uuid.UUID] = []
|
||||
for value in raw_ids:
|
||||
try:
|
||||
ids.append(uuid.UUID(str(value)))
|
||||
except ValueError:
|
||||
continue
|
||||
if not ids:
|
||||
return []
|
||||
rows = await session.scalars(
|
||||
select(ComposeAttachment).where(
|
||||
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
|
||||
)
|
||||
)
|
||||
by_id = {r.id: r for r in rows}
|
||||
out: list[tuple[str, str, bytes]] = []
|
||||
for i in ids:
|
||||
att = by_id.get(i)
|
||||
if att is not None:
|
||||
out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256)))
|
||||
return out
|
||||
@@ -0,0 +1,120 @@
|
||||
"""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.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)
|
||||
_, sent_raw, _, _ = build_mime(account, draft, reply_to, attachments)
|
||||
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()
|
||||
+75
-39
@@ -8,6 +8,7 @@ to Sent (skipped for Gmail, which self-appends), harvest recipients into
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
@@ -19,17 +20,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import session_scope
|
||||
from app.events import get_event_bus
|
||||
from app.indexer import blobstore
|
||||
from app.models import (
|
||||
Account,
|
||||
ComposeAttachment,
|
||||
Draft,
|
||||
KnownRecipient,
|
||||
Mailbox,
|
||||
Message,
|
||||
Outbox,
|
||||
)
|
||||
from app.indexer.indexer import persist_message
|
||||
from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox
|
||||
from app.outbox.attachments import load_attachments
|
||||
from app.outbox.mime_build import build_mime
|
||||
from app.providers.base import RemoteMessage
|
||||
from app.search.factory import get_search_backend
|
||||
from app.sync.provider_factory import build_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -56,33 +52,56 @@ async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails:
|
||||
await session.execute(stmt)
|
||||
|
||||
|
||||
async def _load_attachments(
|
||||
session: AsyncSession, user_id: uuid.UUID, draft: Draft
|
||||
) -> list[tuple[str, str, bytes]]:
|
||||
"""Resolve a draft's compose-attachment ids into (filename, mime, data) tuples."""
|
||||
raw_ids = draft.payload.get("attachment_ids") or []
|
||||
if not isinstance(raw_ids, list) or not raw_ids:
|
||||
return []
|
||||
ids: list[uuid.UUID] = []
|
||||
for value in raw_ids:
|
||||
try:
|
||||
ids.append(uuid.UUID(str(value)))
|
||||
except ValueError:
|
||||
continue
|
||||
if not ids:
|
||||
return []
|
||||
rows = await session.scalars(
|
||||
select(ComposeAttachment).where(
|
||||
ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id
|
||||
async def _persist_sent_copy(
|
||||
user_id: uuid.UUID, account: Account, raw: bytes, uid: int
|
||||
) -> None:
|
||||
"""Store the just-sent message in the DB (Sent mailbox) and notify the UI.
|
||||
|
||||
No-op without a UIDPLUS append UID (uid <= 0); in that case a future Sent sync
|
||||
would pick it up. persist_message dedupes on (mailbox_id, uid).
|
||||
"""
|
||||
if uid <= 0:
|
||||
return
|
||||
async with session_scope() as session:
|
||||
sent_mb = await session.scalar(
|
||||
select(Mailbox).where(
|
||||
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
|
||||
)
|
||||
)
|
||||
if sent_mb is None:
|
||||
return
|
||||
remote = RemoteMessage(
|
||||
uid=uid, raw=raw, flags=frozenset({"\\Seen"}), internal_date=datetime.now(UTC)
|
||||
)
|
||||
result = await persist_message(
|
||||
session,
|
||||
user_id=user_id,
|
||||
account_id=account.id,
|
||||
mailbox_id=sent_mb.id,
|
||||
mailbox_special_use="sent",
|
||||
remote=remote,
|
||||
)
|
||||
if result is None:
|
||||
return
|
||||
message_id = result.message.id
|
||||
thread_id = result.message.thread_id
|
||||
search_doc = result.search_doc
|
||||
|
||||
await get_search_backend().index(search_doc)
|
||||
bus = get_event_bus()
|
||||
await bus.publish(
|
||||
"message.new",
|
||||
{
|
||||
"user_id": str(user_id),
|
||||
"account_id": str(account.id),
|
||||
"message_id": str(message_id),
|
||||
"thread_id": str(thread_id) if thread_id else None,
|
||||
},
|
||||
)
|
||||
by_id = {r.id: r for r in rows}
|
||||
out: list[tuple[str, str, bytes]] = []
|
||||
for i in ids:
|
||||
att = by_id.get(i)
|
||||
if att is not None:
|
||||
out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256)))
|
||||
return out
|
||||
if thread_id is not None:
|
||||
await bus.publish(
|
||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
|
||||
)
|
||||
|
||||
|
||||
async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
||||
@@ -109,21 +128,34 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
||||
outbox.error = "account missing"
|
||||
return
|
||||
user_id = outbox.user_id
|
||||
attachments = await _load_attachments(session, user_id, draft)
|
||||
raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments)
|
||||
attachments = await load_attachments(session, user_id, draft)
|
||||
transmit_raw, sent_raw, envelope_from, envelope_to = build_mime(
|
||||
account, draft, reply_to, attachments
|
||||
)
|
||||
sent_path = await session.scalar(
|
||||
select(Mailbox.path).where(
|
||||
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
|
||||
)
|
||||
)
|
||||
drafts_path = await session.scalar(
|
||||
select(Mailbox.path).where(
|
||||
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
|
||||
)
|
||||
)
|
||||
draft_imap_uid = draft.imap_uid
|
||||
gmail = _is_gmail(account)
|
||||
account_obj = account
|
||||
|
||||
provider = build_provider(account_obj)
|
||||
appended_uid = 0
|
||||
try:
|
||||
await provider.send(raw, envelope_from, envelope_to)
|
||||
await provider.send(transmit_raw, envelope_from, envelope_to)
|
||||
if sent_path and not gmail:
|
||||
await provider.append(sent_path, raw, frozenset({"\\Seen"}))
|
||||
appended_uid = await provider.append(sent_path, sent_raw, frozenset({"\\Seen"}))
|
||||
# Remove the mirrored Drafts-folder copy now that it has been sent.
|
||||
if drafts_path and draft_imap_uid:
|
||||
with contextlib.suppress(Exception):
|
||||
await provider.delete_messages(drafts_path, [draft_imap_uid])
|
||||
except Exception as exc: # noqa: BLE001 - record and notify, keep the draft
|
||||
async with session_scope() as session:
|
||||
ob = await session.get(Outbox, oid)
|
||||
@@ -139,6 +171,10 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
# Persist the sent copy (with Bcc) ourselves so it shows in Sent and threads
|
||||
# immediately; the periodic Sent poll would also catch it (dedup by UID).
|
||||
await _persist_sent_copy(user_id, account_obj, sent_raw, appended_uid)
|
||||
|
||||
async with session_scope() as session:
|
||||
ob = await session.get(Outbox, oid)
|
||||
if ob is not None:
|
||||
|
||||
@@ -49,8 +49,13 @@ def build_mime(
|
||||
draft: Draft,
|
||||
reply_to: Message | None,
|
||||
attachments: list[tuple[str, str, bytes]] | None = None,
|
||||
) -> tuple[bytes, str, list[str]]:
|
||||
"""Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``.
|
||||
) -> tuple[bytes, bytes, str, list[str]]:
|
||||
"""Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``.
|
||||
|
||||
``transmit_raw`` is sent to the SMTP recipients and carries NO ``Bcc`` header.
|
||||
``sent_raw`` is the copy filed in Sent; it additionally includes a ``Bcc``
|
||||
header so the sender keeps a record of blind recipients (standard client
|
||||
behavior). They are identical when there is no Bcc.
|
||||
|
||||
``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the
|
||||
compose Attach action; each is added as a file attachment part.
|
||||
@@ -99,4 +104,10 @@ def build_mime(
|
||||
)
|
||||
|
||||
envelope_to = [_email_only(a) for a in (to + cc + bcc)]
|
||||
return msg.as_bytes(), account.email, envelope_to
|
||||
transmit_raw = msg.as_bytes() # no Bcc header -> safe to transmit
|
||||
if bcc:
|
||||
msg["Bcc"] = ", ".join(bcc)
|
||||
sent_raw = msg.as_bytes()
|
||||
else:
|
||||
sent_raw = transmit_raw
|
||||
return transmit_raw, sent_raw, account.email, envelope_to
|
||||
|
||||
@@ -35,6 +35,20 @@ class DraftOut(BaseModel):
|
||||
updated_at: str
|
||||
|
||||
|
||||
class RemoteDraftOut(BaseModel):
|
||||
"""A draft found in the account's IMAP Drafts folder (created on another client)."""
|
||||
|
||||
message_id: uuid.UUID
|
||||
account_id: uuid.UUID
|
||||
imap_uid: int
|
||||
subject: str
|
||||
to: list[str]
|
||||
cc: list[str]
|
||||
bcc: list[str]
|
||||
snippet: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SendRequest(BaseModel):
|
||||
draft_id: uuid.UUID
|
||||
|
||||
|
||||
Reference in New Issue
Block a user