Gmail label sync + no-dup backfill, live label/UI updates, account-delete cleanup, Sync-SPAM toggle, sidebar logo

Gmail sync (SPEC: Gmail sync):
- Provider fetches X-GM-LABELS when X-GM-EXT-1 is advertised; parses custom labels
  (system labels dropped, mUTF-7 decoded) onto RemoteMessage.
- Engine syncs only system folders + All Mail for Gmail, skips every label folder,
  and dedupes the All Mail pass by Message-ID — kills the INBOX/All-Mail duplication.
- Custom labels ride the message.new event; the labels plugin subscribes and maps
  them to chips as "<name> [Gmail]" (per-message + thread aggregate + known set),
  so labels fill in gradually with the sync window. Non-ASCII labels decode right.

Labels self-healing (message-driven reconcile):
- Per-message keys are the source of truth; t: aggregates and _labels are derived.
  Reconcile drops labels of deleted messages, regroups survivors by their current
  thread (fixing JWZ thread merges), rewrites changed t: keys, and rebuilds _labels
  from live messages — keeping user labels, dropping orphaned Gmail ones.
- New SDK reads: sdk.threads.existing / sdk.messages.threads_of. Sidebar refreshes
  live via a plugin.labels.changed WS event on new labels.

Account deletion: prune threads left empty after the cascade (they were user-scoped,
so only messages cascaded) + de-list the account from surviving cross-account threads.

Sync SPAM: opt-in per-account sync_spam (migration 0015, default off) — the engine
skips the Junk folder in backfill and poll unless enabled; wizard checkbox added.

Robustness / UX:
- WebSocket auto-reconnects with backoff and resyncs on (re)connect, and account
  add/delete invalidates threads/labels — no more hard refresh for live updates.
- Backfill banner + sidebar ring also require done < total, so a completed account
  can't leave a stuck "X of X".
- Scrub lone UTF-16 surrogates from parsed headers/bodies so bad messages no longer
  crash the backfill on Postgres text/JSONB.
- Ultimo signet logo in the Mail/Calendar/Contacts sidebar header (and at the top of
  the collapsed rail), via the shared plugin-ui.css + MailLayout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-05 18:04:49 +02:00
co-authored by Claude Opus 4.8
parent e5a87b37b1
commit 4c200ab94a
38 changed files with 863 additions and 106 deletions
+2
View File
@@ -49,6 +49,8 @@ def _help_block(raw: object) -> HelpBlock | None:
app_name_hint=str(raw.get("app_name_hint", "")), app_name_hint=str(raw.get("app_name_hint", "")),
steps=tuple(str(s) for s in steps) if isinstance(steps, list) else (), steps=tuple(str(s) for s in steps) if isinstance(steps, list) else (),
docs_url=str(raw.get("docs_url", "")), docs_url=str(raw.get("docs_url", "")),
setup_link=str(raw.get("setup_link", "")),
button_after_step=int(raw.get("button_after_step", 0)),
) )
+3
View File
@@ -25,12 +25,15 @@ providers:
help: help:
help_id: gmail help_id: gmail
deep_link: https://myaccount.google.com/apppasswords deep_link: https://myaccount.google.com/apppasswords
setup_link: https://myaccount.google.com/signinoptions/two-step-verification
app_name_hint: "ultimo.email" app_name_hint: "ultimo.email"
button_after_step: 2
docs_url: https://support.google.com/mail/answer/185833 docs_url: https://support.google.com/mail/answer/185833
steps: steps:
- wizard.help.gmail.step1 - wizard.help.gmail.step1
- wizard.help.gmail.step2 - wizard.help.gmail.step2
- wizard.help.gmail.step3 - wizard.help.gmail.step3
- wizard.help.gmail.step4
microsoft: microsoft:
domains: [outlook.com, hotmail.com, live.com, msn.com] domains: [outlook.com, hotmail.com, live.com, msn.com]
+3
View File
@@ -28,6 +28,8 @@ class HelpBlockOut(BaseModel):
app_name_hint: str = "" app_name_hint: str = ""
steps: list[str] = [] steps: list[str] = []
docs_url: str = "" docs_url: str = ""
setup_link: str = ""
button_after_step: int = 0
class CandidateOut(BaseModel): class CandidateOut(BaseModel):
@@ -73,6 +75,7 @@ class CreateAccountRequest(BaseModel):
username: str | None = None username: str | None = None
password: str = Field(min_length=1) password: str = Field(min_length=1)
sync_window: SyncWindow = "all" sync_window: SyncWindow = "all"
sync_spam: bool = False
class UpdateAccountRequest(BaseModel): class UpdateAccountRequest(BaseModel):
+20 -2
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import uuid import uuid
from sqlalchemy import delete, select from sqlalchemy import delete, func, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.schemas import CreateAccountRequest from app.accounts.schemas import CreateAccountRequest
from app.accounts.types import ServerConfig from app.accounts.types import ServerConfig
from app.indexer import blobstore from app.indexer import blobstore
from app.models import Account, Attachment, Message from app.models import Account, Attachment, Message, Thread
from app.security.crypto import encrypt_secret from app.security.crypto import encrypt_secret
@@ -40,6 +40,7 @@ async def create_account(
discovery_src="manual", discovery_src="manual",
state="backfilling", state="backfilling",
sync_window=req.sync_window, sync_window=req.sync_window,
sync_spam=req.sync_spam,
) )
session.add(account) session.add(account)
try: try:
@@ -81,6 +82,23 @@ async def delete_account(
await session.execute(delete(Account).where(Account.id == account_id)) await session.execute(delete(Account).where(Account.id == account_id))
await session.flush() await session.flush()
# Threads are user-scoped and can span accounts (Thread.account_ids), so the
# account cascade only removes its messages — not the threads. Clean up: drop
# threads left with no messages (else they linger as empty rows the unified list
# and label views still show but can't open), and de-list the account from
# threads that survive on another account's messages.
await session.execute(
delete(Thread).where(
Thread.user_id == user_id,
~select(Message.id).where(Message.thread_id == Thread.id).exists(),
)
)
await session.execute(
update(Thread)
.where(Thread.user_id == user_id, Thread.account_ids.contains([account_id]))
.values(account_ids=func.array_remove(Thread.account_ids, account_id))
)
# Deregister from sync:live and drop any lease so the owning sync worker # Deregister from sync:live and drop any lease so the owning sync worker
# stops the account's runner on its next renewal (refactor §2 fencing). # stops the account's runner on its next renewal (refactor §2 fencing).
from app.events import get_event_bus from app.events import get_event_bus
+2
View File
@@ -22,6 +22,8 @@ class HelpBlock:
app_name_hint: str # suggested app-password label app_name_hint: str # suggested app-password label
steps: tuple[str, ...] # i18n message keys steps: tuple[str, ...] # i18n message keys
docs_url: str = "" docs_url: str = ""
setup_link: str = "" # optional 2FA/security page linked from the first step
button_after_step: int = 0 # 1-based step to place the app-password button after; 0 = top
@dataclass(frozen=True) @dataclass(frozen=True)
+2
View File
@@ -72,6 +72,8 @@ def _candidate_out(c: CandidateConfig) -> CandidateOut:
app_name_hint=c.help.app_name_hint, app_name_hint=c.help.app_name_hint,
steps=list(c.help.steps), steps=list(c.help.steps),
docs_url=c.help.docs_url, docs_url=c.help.docs_url,
setup_link=c.help.setup_link,
button_after_step=c.help.button_after_step,
) )
if c.help if c.help
else None else None
+25 -2
View File
@@ -36,6 +36,7 @@ class PersistResult:
message: Message message: Message
search_doc: SearchDoc search_doc: SearchDoc
has_remote_images: bool has_remote_images: bool
gmail_labels: tuple[str, ...] = ()
def _epoch(value: datetime) -> int: def _epoch(value: datetime) -> int:
@@ -140,8 +141,13 @@ async def persist_message(
mailbox_id: uuid.UUID, mailbox_id: uuid.UUID,
mailbox_special_use: str | None, mailbox_special_use: str | None,
remote: RemoteMessage, remote: RemoteMessage,
dedupe_account_scope: bool = False,
) -> PersistResult | None: ) -> PersistResult | None:
"""Persist one message. Returns None if it already exists (idempotent).""" """Persist one message. Returns None if it already exists (idempotent).
``dedupe_account_scope`` skips a message already stored under a *different*
mailbox of the same account (same Message-ID) — used for Gmail's All Mail pass so
a message already synced from INBOX/Sent isn't duplicated (SPEC: Gmail sync)."""
existing = await session.scalar( existing = await session.scalar(
select(Message.id).where(Message.mailbox_id == mailbox_id, Message.uid == remote.uid) select(Message.id).where(Message.mailbox_id == mailbox_id, Message.uid == remote.uid)
) )
@@ -154,6 +160,18 @@ async def persist_message(
message_id_hdr = await _resolve_message_id( message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
) )
if dedupe_account_scope and message_id_hdr is not None:
dup = await session.scalar(
select(Message.id)
.where(
Message.account_id == account_id,
Message.message_id_hdr == message_id_hdr,
Message.mailbox_id != mailbox_id,
)
.limit(1)
)
if dup is not None:
return None
message = Message( message = Message(
id=uuid.uuid4(), id=uuid.uuid4(),
user_id=user_id, user_id=user_id,
@@ -223,4 +241,9 @@ async def persist_message(
await session.flush() await session.flush()
doc = _build_search_doc(message, parsed, mailbox_special_use) doc = _build_search_doc(message, parsed, mailbox_special_use)
return PersistResult(message=message, search_doc=doc, has_remote_images=has_remote_images) return PersistResult(
message=message,
search_doc=doc,
has_remote_images=has_remote_images,
gmail_labels=remote.gmail_labels,
)
+22 -8
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import email import email
import os import os
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from email.headerregistry import AddressHeader from email.headerregistry import AddressHeader
@@ -20,6 +21,19 @@ from app.threading_.normalize import normalize_message_id, normalize_reference_l
_SNIPPET_LEN = 160 _SNIPPET_LEN = 160
# Undecodable header/body bytes come out of the stdlib parser as lone UTF-16
# surrogates (U+D800–U+DFFF). Those are invalid UTF-8, so Postgres rejects them in
# text/JSONB columns — scrub them to U+FFFD before anything is stored.
_SURROGATE_RE = re.compile("[\ud800-\udfff]")
def _scrub(value: str) -> str:
return _SURROGATE_RE.sub("�", value) if value else value
def _scrub_addrs(addrs: list[dict[str, str]]) -> list[dict[str, str]]:
return [{k: _scrub(v) for k, v in addr.items()} for addr in addrs]
@dataclass(frozen=True) @dataclass(frozen=True)
class ParsedAttachment: class ParsedAttachment:
@@ -112,7 +126,7 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
inline = cid is not None and disposition != "attachment" inline = cid is not None and disposition != "attachment"
out.append( out.append(
ParsedAttachment( ParsedAttachment(
filename=filename or "attachment", filename=_scrub(filename or "attachment"),
mime_type=part.get_content_type(), mime_type=part.get_content_type(),
content_id=cid.strip("<>") if (inline and cid is not None) else None, content_id=cid.strip("<>") if (inline and cid is not None) else None,
payload=payload, payload=payload,
@@ -166,13 +180,13 @@ def _parse_email(msg: EmailMessage) -> ParsedEmail:
message_id=message_id, message_id=message_id,
in_reply_to=in_reply_to, in_reply_to=in_reply_to,
references=references, references=references,
subject=subject, subject=_scrub(subject),
from_addr=_addresses(msg, "From"), from_addr=_scrub_addrs(_addresses(msg, "From")),
to_addr=_addresses(msg, "To"), to_addr=_scrub_addrs(_addresses(msg, "To")),
cc_addr=_addresses(msg, "Cc"), cc_addr=_scrub_addrs(_addresses(msg, "Cc")),
bcc_addr=_addresses(msg, "Bcc"), bcc_addr=_scrub_addrs(_addresses(msg, "Bcc")),
date=date, date=date,
text_body=text_body or "", text_body=_scrub(text_body or ""),
html_body=html_body, html_body=_scrub(html_body) if html_body is not None else None,
attachments=_extract_attachments(msg), attachments=_extract_attachments(msg),
) )
+2
View File
@@ -86,6 +86,8 @@ class Account(Base):
# How much history to import (SPEC §12 M2): all | 3months | new. Drives the # How much history to import (SPEC §12 M2): all | 3months | new. Drives the
# initial backfill; backfill_total/backfill_done feed the progress UX. # initial backfill; backfill_total/backfill_done feed the progress UX.
sync_window: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'all'")) sync_window: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'all'"))
# Whether the Spam/Junk folder is synced (opt-in; SPEC §6.3 folder selection).
sync_spam: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
backfill_total: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) backfill_total: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
backfill_done: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) backfill_done: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) last_sync_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+47 -1
View File
@@ -22,7 +22,7 @@ from sqlalchemy import select
from app.db import session_scope from app.db import session_scope
from app.events import get_event_bus from app.events import get_event_bus
from app.indexer import blobstore from app.indexer import blobstore
from app.models import Message from app.models import Message, Thread
class PluginMessages: class PluginMessages:
@@ -44,6 +44,29 @@ class PluginMessages:
return None return None
return message_from_bytes(raw).get(name) return message_from_bytes(raw).get(name)
async def threads_of(
self, user_id: uuid.UUID, message_ids: Iterable[str]
) -> dict[str, str | None]:
"""Map still-existing message ids to their current thread id (None if
unthreaded). Deleted messages are omitted, so a plugin can prune stale
per-message references and re-home labels onto the live thread after a
JWZ thread merge."""
ids: list[uuid.UUID] = []
for mid in message_ids:
try:
ids.append(uuid.UUID(str(mid)))
except (ValueError, AttributeError, TypeError):
continue
if not ids:
return {}
async with session_scope() as session:
rows = await session.execute(
select(Message.id, Message.thread_id).where(
Message.user_id == user_id, Message.id.in_(ids)
)
)
return {str(mid): (str(tid) if tid else None) for mid, tid in rows}
async def envelope(self, user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any] | None: async def envelope(self, user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any] | None:
async with session_scope() as session: async with session_scope() as session:
msg = await session.scalar( msg = await session.scalar(
@@ -66,6 +89,29 @@ class PluginMessages:
} }
class PluginThreads:
"""Read-only, user-scoped thread queries (e.g. self-healing stale references)."""
async def existing(self, user_id: uuid.UUID, thread_ids: Iterable[str]) -> set[str]:
"""Return the subset of ``thread_ids`` that still exist for the user.
Lets a plugin prune references to threads deleted out from under it (e.g. an
account removal drops its threads, orphaning label aggregates keyed by id)."""
ids: list[uuid.UUID] = []
for tid in thread_ids:
try:
ids.append(uuid.UUID(str(tid)))
except (ValueError, AttributeError, TypeError):
continue
if not ids:
return set()
async with session_scope() as session:
rows = await session.scalars(
select(Thread.id).where(Thread.user_id == user_id, Thread.id.in_(ids))
)
return {str(r) for r in rows}
class PluginActions: class PluginActions:
"""User-scoped actions a plugin can take on a message.""" """User-scoped actions a plugin can take on a message."""
+6 -1
View File
@@ -28,7 +28,7 @@ from app.identity.deps import current_user, require_csrf
from app.indexer import blobstore from app.indexer import blobstore
from app.models import User from app.models import User
from app.outbox import service as outbox_service from app.outbox import service as outbox_service
from app.plugins.actions import PluginActions, PluginMessages from app.plugins.actions import PluginActions, PluginMessages, PluginThreads
from app.plugins.db import plugin_session from app.plugins.db import plugin_session
from app.plugins.http import PluginHttp from app.plugins.http import PluginHttp
from app.plugins.kv import PluginKvStore from app.plugins.kv import PluginKvStore
@@ -151,6 +151,11 @@ class PluginSdk:
"""User-scoped message actions (``set_flags``) — e.g. for filter rules.""" """User-scoped message actions (``set_flags``) — e.g. for filter rules."""
return PluginActions() return PluginActions()
@property
def threads(self) -> PluginThreads:
"""Read-only thread queries (e.g. pruning stale references)."""
return PluginThreads()
# --- outbound HTTP ------------------------------------------------------ # --- outbound HTTP ------------------------------------------------------
async def http_get(self, url: str) -> str | None: async def http_get(self, url: str) -> str | None:
"""GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize).""" """GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize)."""
+1
View File
@@ -46,6 +46,7 @@ class RemoteMessage:
raw: bytes # full RFC822 raw: bytes # full RFC822
flags: frozenset[str] flags: frozenset[str]
internal_date: datetime internal_date: datetime
gmail_labels: tuple[str, ...] = () # custom Gmail labels (Gmail accounts only)
@dataclass(frozen=True) @dataclass(frozen=True)
+81 -2
View File
@@ -12,6 +12,7 @@ from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from imapclient.imap_utf7 import decode as imap_utf7_decode from imapclient.imap_utf7 import decode as imap_utf7_decode
from imapclient.imap_utf7 import encode as imap_utf7_encode
# --- SPECIAL-USE mapping (SPEC §4 special_use values) ----------------------- # --- SPECIAL-USE mapping (SPEC §4 special_use values) -----------------------
@@ -27,6 +28,7 @@ _SPECIAL_USE_FLAGS = {
_LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$') _LIST_RE = re.compile(rb'^\(([^)]*)\)\s+(?:"([^"]*)"|NIL)\s+(.+)$')
_UID_RE = re.compile(rb"UID\s+(\d+)") _UID_RE = re.compile(rb"UID\s+(\d+)")
_FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)") _FLAGS_RE = re.compile(rb"FLAGS\s+\(([^)]*)\)")
_GM_LABELS_RE = re.compile(rb"X-GM-LABELS\s+\(([^)]*)\)")
_INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"') _INTERNALDATE_RE = re.compile(rb'INTERNALDATE\s+"([^"]+)"')
_LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$") _LITERAL_RE = re.compile(rb"\{(\d+)\}\s*$")
_FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE) _FETCH_HEADER_RE = re.compile(rb"^\d+\s+FETCH\s+\(", re.IGNORECASE)
@@ -44,6 +46,7 @@ class ParsedFetch:
flags: frozenset[str] flags: frozenset[str]
internal_date: datetime | None internal_date: datetime | None
raw: bytes | None # None when the fetch did not request the body raw: bytes | None # None when the fetch did not request the body
gmail_labels: tuple[str, ...] = () # custom Gmail labels (X-GM-LABELS), system ones dropped
def _decode_mailbox_name(raw: bytes) -> str: def _decode_mailbox_name(raw: bytes) -> str:
@@ -54,12 +57,29 @@ def _decode_mailbox_name(raw: bytes) -> str:
return str(imap_utf7_decode(bytes(name))) return str(imap_utf7_decode(bytes(name)))
def encode_mailbox_name(name: str) -> str:
"""Modified UTF-7 wire form of a mailbox name (inverse of ``_decode_mailbox_name``).
Mailbox names are stored/displayed as Unicode but IMAP commands
(SELECT/STATUS/APPEND) require them mUTF-7 encoded. ASCII names are unchanged;
non-ASCII names — e.g. Gmail's localized ``[Gmail]/Wysłane`` — get encoded so the
server can find them."""
return str(imap_utf7_encode(name).decode("ascii"))
def parse_list_line(line: bytes) -> ParsedMailbox | None: def parse_list_line(line: bytes) -> ParsedMailbox | None:
"""Parse one LIST response line into a :class:`ParsedMailbox` (or None).""" """Parse one LIST response line into a :class:`ParsedMailbox` (or None).
Non-selectable mailboxes are skipped (return None): ``\\Noselect`` /
``\\NonExistent`` names are pure hierarchy containers that hold no messages and
cannot be SELECTed — e.g. Gmail's ``[Gmail]`` root or a parent label like
``Serial numbers`` that only groups child labels."""
m = _LIST_RE.match(line.strip()) m = _LIST_RE.match(line.strip())
if not m: if not m:
return None return None
flags = m.group(1).lower().split() flags = m.group(1).lower().split()
if b"\\noselect" in flags or b"\\nonexistent" in flags:
return None
name = _decode_mailbox_name(m.group(3)) name = _decode_mailbox_name(m.group(3))
special: str | None = None special: str | None = None
for flag in flags: for flag in flags:
@@ -100,6 +120,57 @@ def parse_fetch_header(line: bytes) -> tuple[int | None, frozenset[str], datetim
return uid, flags, internal return uid, flags, internal
def _tokenize_gm_labels(blob: bytes) -> list[bytes]:
"""Split an ``X-GM-LABELS (...)`` body into raw tokens, keeping quoted strings
(which may contain spaces) whole. Escaped chars inside quotes are unescaped."""
tokens: list[bytes] = []
i, n = 0, len(blob)
while i < n:
ch = blob[i : i + 1]
if ch in (b" ", b"\t"):
i += 1
continue
if ch == b'"':
j = i + 1
buf = bytearray()
while j < n and blob[j : j + 1] != b'"':
if blob[j : j + 1] == b"\\" and j + 1 < n:
buf += blob[j + 1 : j + 2]
j += 2
continue
buf += blob[j : j + 1]
j += 1
tokens.append(bytes(buf))
i = j + 1
else:
j = i
while j < n and blob[j : j + 1] not in (b" ", b"\t"):
j += 1
tokens.append(blob[i:j])
i = j
return tokens
def parse_gm_labels(header: bytes) -> tuple[str, ...]:
"""Custom Gmail labels from a FETCH header's ``X-GM-LABELS`` (SPEC: Gmail sync).
System labels (``\\Inbox``, ``\\Sent``, ``\\Important``, ``\\Starred``, …) are
dropped — only user labels become chips. Names are modified-UTF-7 decoded, like
mailbox names, so e.g. ``Zg&AAI...`` → the real Unicode label."""
m = _GM_LABELS_RE.search(header)
if not m:
return ()
out: list[str] = []
for tok in _tokenize_gm_labels(m.group(1)):
if not tok or tok.startswith(b"\\"): # system label
continue
try:
out.append(str(imap_utf7_decode(tok)))
except Exception: # noqa: BLE001 - fall back to a best-effort ASCII decode
out.append(tok.decode("utf-8", "ignore"))
return tuple(out)
def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]: def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]:
"""Walk a full FETCH response, returning one :class:`ParsedFetch` per message. """Walk a full FETCH response, returning one :class:`ParsedFetch` per message.
@@ -131,7 +202,15 @@ def parse_fetch_lines(lines: list[bytes | bytearray]) -> list[ParsedFetch]:
else: else:
i += 1 i += 1
if uid is not None: if uid is not None:
results.append(ParsedFetch(uid=uid, flags=flags, internal_date=internal, raw=raw)) results.append(
ParsedFetch(
uid=uid,
flags=flags,
internal_date=internal,
raw=raw,
gmail_labels=parse_gm_labels(header),
)
)
return results return results
+31 -6
View File
@@ -31,6 +31,7 @@ from app.providers.base import (
RemoteMessage, RemoteMessage,
) )
from app.providers.imap_smtp.parsing import ( from app.providers.imap_smtp.parsing import (
encode_mailbox_name,
parse_fetch_lines, parse_fetch_lines,
parse_list_line, parse_list_line,
parse_search_uids, parse_search_uids,
@@ -39,6 +40,9 @@ from app.providers.imap_smtp.parsing import (
) )
_FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])" _FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])"
# Gmail (X-GM-EXT-1): also pull each message's labels so custom labels can map to
# chips without visiting every label folder (SPEC: Gmail sync / no-duplication).
_FETCH_ITEMS_GM = "(UID FLAGS INTERNALDATE RFC822.SIZE X-GM-LABELS BODY.PEEK[])"
_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min _IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min
_CONNECT_TIMEOUT = 20 _CONNECT_TIMEOUT = 20
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs _PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
@@ -81,6 +85,13 @@ class ImapSmtpProvider(MailProvider):
self._uidvalidity_seen: dict[str, int] = {} self._uidvalidity_seen: dict[str, int] = {}
self._conn_slots: list[asyncio.Semaphore] = [] self._conn_slots: list[asyncio.Semaphore] = []
self._qresync_enabled = False self._qresync_enabled = False
self._gm_ext = False
@property
def gmail_labels_supported(self) -> bool:
"""True when the server advertises ``X-GM-EXT-1`` (Gmail), so FETCH can pull
``X-GM-LABELS`` and the engine can use the no-duplication Gmail sync path."""
return self._gm_ext
@property @property
def qresync_enabled(self) -> bool: def qresync_enabled(self) -> bool:
@@ -148,6 +159,8 @@ class ImapSmtpProvider(MailProvider):
self._release_conn_slots() # never hold a slot without an open socket self._release_conn_slots() # never hold a slot without an open socket
raise raise
self._imap = imap self._imap = imap
caps = {c.upper() for c in (imap.protocol.capabilities or set())}
self._gm_ext = "X-GM-EXT-1" in caps
await self._maybe_enable_qresync(imap) await self._maybe_enable_qresync(imap)
return imap return imap
@@ -165,9 +178,16 @@ class ImapSmtpProvider(MailProvider):
except Exception: # noqa: BLE001 - degrade gracefully to the reconcile path except Exception: # noqa: BLE001 - degrade gracefully to the reconcile path
self._qresync_enabled = False self._qresync_enabled = False
@staticmethod
def _wire(mailbox: str) -> str:
"""Server wire form of a mailbox name: mUTF-7 encoded and quoted. Handles
non-ASCII names (Gmail's localized ``[Gmail]/Wysłane``) and names with
spaces (``[Gmail]/All Mail``); ASCII names are unchanged bar the quotes."""
return str(aioimaplib.quoted(encode_mailbox_name(mailbox)))
async def _select(self, mailbox: str) -> dict[str, int]: async def _select(self, mailbox: str) -> dict[str, int]:
imap = await self._connect() imap = await self._connect()
resp = await imap.select(mailbox) resp = await imap.select(self._wire(mailbox))
if resp.result != "OK": if resp.result != "OK":
raise ProviderConnectError(f"cannot select {mailbox!r}") raise ProviderConnectError(f"cannot select {mailbox!r}")
self._selected = mailbox self._selected = mailbox
@@ -212,7 +232,7 @@ class ImapSmtpProvider(MailProvider):
async def _mailbox_uidvalidity(self, path: str) -> int: async def _mailbox_uidvalidity(self, path: str) -> int:
imap = await self._connect() imap = await self._connect()
try: try:
resp = await imap.status(path, "(UIDVALIDITY)") resp = await imap.status(self._wire(path), "(UIDVALIDITY)")
except Exception: except Exception:
return 0 return 0
if resp.result != "OK": if resp.result != "OK":
@@ -280,7 +300,8 @@ class ImapSmtpProvider(MailProvider):
if not uids: if not uids:
return [] return []
uid_set = ",".join(str(u) for u in uids) uid_set = ",".join(str(u) for u in uids)
resp = await imap.uid("fetch", uid_set, _FETCH_ITEMS) items = _FETCH_ITEMS_GM if self._gm_ext else _FETCH_ITEMS
resp = await imap.uid("fetch", uid_set, items)
if resp.result != "OK": if resp.result != "OK":
raise ProviderConnectError("FETCH failed") raise ProviderConnectError("FETCH failed")
out: list[RemoteMessage] = [] out: list[RemoteMessage] = []
@@ -289,7 +310,11 @@ class ImapSmtpProvider(MailProvider):
continue continue
out.append( out.append(
RemoteMessage( RemoteMessage(
uid=pf.uid, raw=pf.raw, flags=pf.flags, internal_date=pf.internal_date uid=pf.uid,
raw=pf.raw,
flags=pf.flags,
internal_date=pf.internal_date,
gmail_labels=pf.gmail_labels,
) )
) )
return out return out
@@ -355,7 +380,7 @@ class ImapSmtpProvider(MailProvider):
proto = imap.protocol proto = imap.protocol
param = f"(QRESYNC ({uidvalidity} {modseq}))" param = f"(QRESYNC ({uidvalidity} {modseq}))"
cmd = aioimaplib.Command( cmd = aioimaplib.Command(
"SELECT", proto.new_tag(), aioimaplib.quoted(mailbox), param, loop=proto.loop "SELECT", proto.new_tag(), self._wire(mailbox), param, loop=proto.loop
) )
return await proto.execute(cmd) return await proto.execute(cmd)
@@ -419,7 +444,7 @@ class ImapSmtpProvider(MailProvider):
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int: async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
imap = await self._connect() imap = await self._connect()
flag_str = f"({' '.join(sorted(flags))})" if flags else None flag_str = f"({' '.join(sorted(flags))})" if flags else None
resp = await imap.append(raw, mailbox=mailbox, flags=flag_str) resp = await imap.append(raw, mailbox=self._wire(mailbox), flags=flag_str)
if resp.result != "OK": if resp.result != "OK":
raise ProviderConnectError(f"APPEND to {mailbox} failed") raise ProviderConnectError(f"APPEND to {mailbox} failed")
for line in resp.lines: for line in resp.lines:
+50 -11
View File
@@ -27,7 +27,15 @@ from app.sync.provider_factory import build_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3). # Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3).
# archive (Gmail All Mail) sorts last so its dedupe pass runs after the system folders.
_PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2} _PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2}
# Gmail sync (SPEC: Gmail sync): the only folders backfilled — All Mail (archive) is
# the complete store, the rest are the system folders needed for their views. Every
# custom-label folder is skipped; labels come from X-GM-LABELS instead.
_GMAIL_SYNC_FOLDERS = {"inbox", "sent", "drafts", "trash", "junk", "archive"}
# Progress denominator for Gmail: All Mail already contains INBOX/Sent/Drafts/labels,
# so only these three are counted (Trash/Spam sit outside All Mail).
_GMAIL_DENOM_FOLDERS = {"archive", "trash", "junk"}
# Sync-window import strategy (SPEC §12 M2). "all" imports the most recent # Sync-window import strategy (SPEC §12 M2). "all" imports the most recent
# _RECENT_DAYS first (so the account is usable fast), then the older remainder. # _RECENT_DAYS first (so the account is usable fast), then the older remainder.
@@ -110,15 +118,17 @@ async def _index_and_notify(results: list[PersistResult], account: Account) -> N
bus = get_event_bus() bus = get_event_bus()
for r in results: for r in results:
await backend.index(r.search_doc) await backend.index(r.search_doc)
await bus.publish( payload: dict[str, object] = {
"message.new",
{
"user_id": str(account.user_id), "user_id": str(account.user_id),
"account_id": str(account.id), "account_id": str(account.id),
"message_id": str(r.message.id), "message_id": str(r.message.id),
"thread_id": str(r.message.thread_id), "thread_id": str(r.message.thread_id),
}, }
) # Gmail custom labels ride along so the labels plugin can map them to chips
# (SPEC: Gmail sync). Only present on Gmail accounts with labelled messages.
if r.gmail_labels:
payload["gmail_labels"] = list(r.gmail_labels)
await bus.publish("message.new", payload)
async def _emit_batch( async def _emit_batch(
@@ -178,12 +188,15 @@ async def backfill_mailbox(
*, *,
since: date | None = None, since: date | None = None,
before: date | None = None, before: date | None = None,
dedupe_account_scope: bool = False,
) -> int: ) -> int:
"""Backfill one mailbox newest-first, indexing inline per batch (FR-30). """Backfill one mailbox newest-first, indexing inline per batch (FR-30).
``since``/``before`` bound the import by date (sync window, SPEC §12 M2). ``since``/``before`` bound the import by date (sync window, SPEC §12 M2).
When a ``progress`` tracker is given, batches count toward the account-level When a ``progress`` tracker is given, batches count toward the account-level
backfill total; otherwise each batch emits a standalone ``sync.progress``. backfill total; otherwise each batch emits a standalone ``sync.progress``.
``dedupe_account_scope`` drops messages already synced under another mailbox of
the account (Gmail All Mail pass — SPEC: Gmail sync / no-duplication).
""" """
from app.db import session_scope from app.db import session_scope
@@ -202,6 +215,7 @@ async def backfill_mailbox(
mailbox_id=mailbox_id, mailbox_id=mailbox_id,
mailbox_special_use=special_use, mailbox_special_use=special_use,
remote=remote, remote=remote,
dedupe_account_scope=dedupe_account_scope,
) )
if r is not None: if r is not None:
results.append(r) results.append(r)
@@ -257,6 +271,16 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
account_obj = account account_obj = account
window = account.sync_window window = account.sync_window
mailbox_list = list(mailboxes) mailbox_list = list(mailboxes)
# Gmail: sync only the system folders + All Mail and read each message's
# labels (X-GM-LABELS) instead of visiting every label folder. This avoids
# the INBOX/All-Mail/label duplication and lets labels map to chips lazily,
# tied to the sync window (SPEC: Gmail sync).
gmail = provider.gmail_labels_supported
if gmail:
mailbox_list = [m for m in mailbox_list if m.special_use in _GMAIL_SYNC_FOLDERS]
# Spam is opt-in: skip the Junk folder unless the account enabled it (§6.3).
if not account.sync_spam:
mailbox_list = [m for m in mailbox_list if m.special_use != "junk"]
try: try:
if window == "new": if window == "new":
@@ -270,9 +294,13 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
date.today() - timedelta(days=_WINDOW_DAYS[window]) if window in _WINDOW_DAYS else None date.today() - timedelta(days=_WINDOW_DAYS[window]) if window in _WINDOW_DAYS else None
) )
# Progress denominator: count what we're about to import up front so the # Progress denominator: count what we're about to import up front so the
# bar has a stable total (SPEC §12 M2 backfill-progress UX). # bar has a stable total (SPEC §12 M2 backfill-progress UX). For Gmail only
# All Mail + Trash + Spam are counted — INBOX/Sent/Drafts are subsets of All
# Mail, so counting them too would inflate the total past the deduped `done`.
total = 0 total = 0
for mailbox in mailbox_list: for mailbox in mailbox_list:
if gmail and mailbox.special_use not in _GMAIL_DENOM_FOLDERS:
continue
total += await provider.message_count(mailbox.path, since=since) total += await provider.message_count(mailbox.path, since=since)
await _set_backfill_counts(account_id, total=total, done=0) await _set_backfill_counts(account_id, total=total, done=0)
progress = _BackfillProgress(account_obj, total) progress = _BackfillProgress(account_obj, total)
@@ -280,16 +308,22 @@ async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
recent_cutoff = date.today() - timedelta(days=_RECENT_DAYS) recent_cutoff = date.today() - timedelta(days=_RECENT_DAYS)
count = 0 count = 0
for mailbox in mailbox_list: for mailbox in mailbox_list:
# Dedupe the Gmail All Mail pass against messages already stored under a
# system folder (INBOX/Sent/…), which the priority order backfills first.
dedupe = gmail and mailbox.special_use == "archive"
if since is not None: # 3months: single windowed pass if since is not None: # 3months: single windowed pass
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=since provider, account_obj, mailbox, progress, since=since,
dedupe_account_scope=dedupe,
) )
else: # all: recent window first (usable fast), then the remainder else: # all: recent window first (usable fast), then the remainder
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, since=recent_cutoff provider, account_obj, mailbox, progress, since=recent_cutoff,
dedupe_account_scope=dedupe,
) )
count += await backfill_mailbox( count += await backfill_mailbox(
provider, account_obj, mailbox, progress, before=recent_cutoff provider, account_obj, mailbox, progress, before=recent_cutoff,
dedupe_account_scope=dedupe,
) )
await _set_state(account_id, "live") await _set_state(account_id, "live")
except Exception as exc: # noqa: BLE001 - surface provider errors as account state except Exception as exc: # noqa: BLE001 - surface provider errors as account state
@@ -490,7 +524,8 @@ async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> No
``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic ``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic
secondary loop leaves INBOX to its dedicated IDLE loop. Custom (non special-use) secondary loop leaves INBOX to its dedicated IDLE loop. Custom (non special-use)
folders are not polled. Drafts are polled so drafts made in other clients appear folders are not polled. Drafts are polled so drafts made in other clients appear
(persisted un-threaded). (persisted un-threaded). ``archive`` (Gmail All Mail) is never live-polled — it is
huge and cold, and backfill already covers it.
""" """
from app.db import session_scope from app.db import session_scope
@@ -498,10 +533,14 @@ async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> No
account = await _load_account(session, account_id) account = await _load_account(session, account_id)
account_obj = account account_obj = account
stmt = select(Mailbox.id).where( stmt = select(Mailbox.id).where(
Mailbox.account_id == account_id, Mailbox.special_use.isnot(None) Mailbox.account_id == account_id,
Mailbox.special_use.isnot(None),
Mailbox.special_use != "archive",
) )
if not include_inbox: if not include_inbox:
stmt = stmt.where(Mailbox.special_use != "inbox") stmt = stmt.where(Mailbox.special_use != "inbox")
if not account.sync_spam: # Spam is opt-in (§6.3)
stmt = stmt.where(Mailbox.special_use != "junk")
mailbox_ids = list(await session.scalars(stmt)) mailbox_ids = list(await session.scalars(stmt))
if not mailbox_ids: if not mailbox_ids:
return return
@@ -0,0 +1,28 @@
"""add accounts.sync_spam
Per-account toggle for whether the Spam/Junk folder is synced. Off by default —
most users don't want spam mirrored into the app; they can opt in at setup.
Revision ID: 0015
Revises: 0014
Create Date: 2026-08-05
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0015"
down_revision: str | None = "0014"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN sync_spam boolean NOT NULL DEFAULT false")
def downgrade() -> None:
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS sync_spam")
+44 -1
View File
@@ -19,7 +19,7 @@ from app.config import get_settings
from app.identity.passwords import hash_password from app.identity.passwords import hash_password
from app.identity.sessions import create_session from app.identity.sessions import create_session
from app.main import create_app from app.main import create_app
from app.models import Account, User from app.models import Account, Thread, User
CSRF = "test-csrf-token" CSRF = "test-csrf-token"
GOOD_CFG = { GOOD_CFG = {
@@ -281,3 +281,46 @@ async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no
gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id))) gone = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(account_id)))
assert gone is None assert gone is None
async def test_delete_account_prunes_orphaned_threads(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Deleting an account removes threads left with no messages (else the unified
list / label views show empty rows that can't be opened)."""
import datetime as dt
async def fake_test(**_kw: object) -> None:
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
created = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "orphan@example.com", "password": "secret"},
headers=_hdr(),
)
account_id = uuid.UUID(created.json()["id"])
# An empty thread tagged to this account (message-less, as after a cascade delete).
thread_id = uuid.uuid4()
ctx.session.add(
Thread(
id=thread_id,
user_id=ctx.user_id,
subject_norm="orphan",
last_message_at=dt.datetime.now(dt.UTC),
account_ids=[account_id],
)
)
await ctx.session.commit()
ctx.session.expunge_all() # force the assertion query to hit the DB
resp = await ctx.client.request("DELETE", f"/api/accounts/{account_id}", headers=_hdr())
assert resp.status_code == 200
assert await ctx.session.scalar(select(Thread).where(Thread.id == thread_id)) is None
+11
View File
@@ -49,6 +49,17 @@ def test_parse_basic_headers_and_body() -> None:
assert parsed.snippet == "plain body" assert parsed.snippet == "plain body"
def test_undecodable_header_bytes_scrubbed_to_valid_utf8() -> None:
# A raw 8-bit byte the parser can't decode surfaces as a lone surrogate; it must
# be scrubbed so the value is valid UTF-8 for Postgres text/JSONB storage.
raw = _build(subject="Fakt\udce9ra", from_addr="B\udce9ta <b@example.com>")
parsed = parse_message(raw)
parsed.subject.encode("utf-8") # would raise on a lone surrogate
parsed.from_addr[0]["name"].encode("utf-8")
assert "\udce9" not in parsed.subject
assert "�" in parsed.subject
def test_references_include_in_reply_to_in_order() -> None: def test_references_include_in_reply_to_in_order() -> None:
parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>")) parsed = parse_message(_build(references="<a@x.com> <b@x.com>", in_reply_to="<b@x.com>"))
# References first, then In-Reply-To, deduped # References first, then In-Reply-To, deduped
+40
View File
@@ -37,6 +37,17 @@ def test_parse_list_plain_folder_has_no_special_use() -> None:
assert parsed.special_use is None assert parsed.special_use is None
def test_parse_list_skips_noselect_containers() -> None:
# \Noselect / \NonExistent hierarchy nodes can't be SELECTed (Gmail's [Gmail]
# root, a parent label like "Serial numbers " that only groups children).
assert parse_list_line(rb'(\HasChildren \Noselect) "/" "[Gmail]"') is None
assert parse_list_line(rb'(\HasChildren \Noselect) "/" "Serial numbers "') is None
assert parse_list_line(rb'(\NonExistent \HasChildren) "/" "Parent"') is None
# A selectable child of that container is still parsed.
child = parse_list_line(rb'(\HasNoChildren) "/" "Serial numbers / licencje"')
assert child is not None and child.path == "Serial numbers / licencje"
def test_parse_list_utf7_name() -> None: def test_parse_list_utf7_name() -> None:
# Modified UTF-7: "&AOk-" decodes to "é" -> folder "Réponses" # Modified UTF-7: "&AOk-" decodes to "é" -> folder "Réponses"
parsed = parse_list_line(rb'(\HasNoChildren) "/" "R&AOk-ponses"') parsed = parse_list_line(rb'(\HasNoChildren) "/" "R&AOk-ponses"')
@@ -44,6 +55,35 @@ def test_parse_list_utf7_name() -> None:
assert parsed.path == "Réponses" assert parsed.path == "Réponses"
def test_parse_gm_labels() -> None:
from app.providers.imap_smtp.parsing import parse_gm_labels
# System labels (backslash-prefixed) are dropped; custom labels kept + decoded.
header = rb'1 FETCH (UID 5 X-GM-LABELS (\Inbox \Important "Konkurs" "Projekty/2026") FLAGS ())'
assert parse_gm_labels(header) == ("Konkurs", "Projekty/2026")
# Unquoted single-word custom label.
assert parse_gm_labels(rb"1 FETCH (UID 5 X-GM-LABELS (\Sent PUP))") == ("PUP",)
# Modified UTF-7 decoding of a non-ASCII label ("Zg&AUI-oszenia" -> "Zgłoszenia").
assert parse_gm_labels(rb'1 FETCH (X-GM-LABELS ("Zg&AUI-oszenia"))') == ("Zgłoszenia",)
# No X-GM-LABELS at all.
assert parse_gm_labels(rb"1 FETCH (UID 5 FLAGS (\Seen))") == ()
# Only system labels -> empty.
assert parse_gm_labels(rb"1 FETCH (X-GM-LABELS (\Inbox \Starred))") == ()
def test_parse_fetch_lines_carries_gm_labels() -> None:
lines = [
rb'1 FETCH (UID 9 X-GM-LABELS (\Inbox "Work") FLAGS (\Seen) '
rb'INTERNALDATE "27-May-2026 19:08:09 +0200" RFC822.SIZE 3 BODY[] {3}',
b"abc",
b")",
]
out = parse_fetch_lines(lines)
assert len(out) == 1
assert out[0].gmail_labels == ("Work",)
assert out[0].raw == b"abc"
def test_parse_internaldate() -> None: def test_parse_internaldate() -> None:
dt = parse_internaldate("27-May-2026 19:08:09 +0200") dt = parse_internaldate("27-May-2026 19:08:09 +0200")
assert dt == datetime.fromisoformat("2026-05-27T19:08:09+02:00") assert dt == datetime.fromisoformat("2026-05-27T19:08:09+02:00")
@@ -31,6 +31,8 @@ def _raw(mid: str) -> bytes:
class _FakeProvider: class _FakeProvider:
gmail_labels_supported = False
def __init__(self) -> None: def __init__(self) -> None:
self.backfill_calls: list[tuple[str, date | None, date | None]] = [] self.backfill_calls: list[tuple[str, date | None, date | None]] = []
self.count_calls: list[tuple[str, date | None]] = [] self.count_calls: list[tuple[str, date | None]] = []
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="44.943924mm"
height="42.975948mm"
viewBox="0 0 44.943923 42.975948"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
offset="0.20198774"
id="stop13" /><stop
style="stop-color:#f74176;stop-opacity:1;"
offset="1"
id="stop12" /></linearGradient><linearGradient
xlink:href="#linearGradient11"
id="linearGradient12"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="165.16425mm"
height="43.229576mm"
viewBox="0 0 165.16424 43.229576"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
offset="0.20198774"
id="stop13" /><stop
style="stop-color:#f74176;stop-opacity:1;"
offset="1"
id="stop12" /></linearGradient><linearGradient
xlink:href="#linearGradient11"
id="linearGradient12"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /><path
style="font-weight:bold;font-size:44.7887px;line-height:1.5;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';letter-spacing:0.223944px;fill:#f74e6a;stroke-width:56.4265;stroke-linecap:round"
d="m 73.758258,226.15765 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -28.35124 h 5.912108 v 28.1273 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403099,0.13436 h 4.971545 v 5.86732 z m 14.869774,0 q -1.701971,0 -3.090421,-0.85098 -1.388449,-0.85099 -2.239435,-2.23944 -0.850985,-1.38845 -0.850985,-3.09042 v -27.76899 h 5.86732 v 7.97239 h 10.122246 v 5.86732 H 88.314511 v 13.70534 q 0,0.22394 0.134366,0.4031 0.179155,0.13436 0.403098,0.13436 h 9.584782 v 5.86732 z m 13.705278,0 v 0 -25.97744 h 5.86732 v 25.97744 z m 0,-28.57519 v 0 -5.91211 h 5.86732 v 5.91211 z m 10.25654,28.57519 v 0 -25.97744 h 32.20307 q 1.74676,0 3.13521,0.85098 1.38845,0.85099 2.19465,2.23944 0.85098,1.38845 0.85098,3.09042 v 19.7966 h -5.82253 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.17916,-0.17915 -0.4031,-0.17915 h -9.27126 q -0.22395,0 -0.4031,0.17915 -0.13437,0.13437 -0.13437,0.35831 v 19.57266 h -5.91211 v -19.57266 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 h -9.31605 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 19.57266 z m 50.07371,0 q -1.70197,0 -3.09042,-0.85098 -1.38845,-0.85099 -2.23944,-2.23944 -0.85098,-1.38845 -0.85098,-3.09042 v -13.61576 q 0,-1.70197 0.85098,-3.09042 0.85099,-1.38845 2.23944,-2.23944 1.38845,-0.85098 3.09042,-0.85098 h 14.01886 q 1.70197,0 3.09042,0.85098 1.43324,0.85099 2.23944,2.23944 0.85098,1.38845 0.85098,3.09042 v 13.61576 q 0,1.70197 -0.85098,3.09042 -0.8062,1.38845 -2.19465,2.23944 -1.38845,0.85098 -3.13521,0.85098 z m 0.22394,-5.86732 h 13.57098 q 0.22394,0 0.35831,-0.13436 0.17915,-0.17916 0.17915,-0.4031 v -13.16788 q 0,-0.22394 -0.17915,-0.35831 -0.13437,-0.17915 -0.35831,-0.17915 H 162.8875 q -0.22394,0 -0.4031,0.17915 -0.13436,0.13437 -0.13436,0.35831 v 13.16788 q 0,0.22394 0.13436,0.4031 0.17916,0.13436 0.4031,0.13436 z"
id="text13"
aria-label="ltimo" /></g></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="44.943924mm"
height="42.975948mm"
viewBox="0 0 44.943923 42.975948"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
offset="0.20198774"
id="stop13" /><stop
style="stop-color:#f74176;stop-opacity:1;"
offset="1"
id="stop12" /></linearGradient><linearGradient
xlink:href="#linearGradient11"
id="linearGradient12"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+1 -2
View File
@@ -10,7 +10,6 @@ import { Login } from "./routes/Login";
import { MailLayout } from "./routes/MailLayout"; import { MailLayout } from "./routes/MailLayout";
import { Register } from "./routes/Register"; import { Register } from "./routes/Register";
import { Settings } from "./routes/settings/Settings"; import { Settings } from "./routes/settings/Settings";
import { AccountWizard } from "./routes/wizard/AccountWizard";
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
const { data, isLoading, isError } = useMe(); const { data, isLoading, isError } = useMe();
@@ -64,7 +63,7 @@ export function App() {
path="/settings/accounts/new" path="/settings/accounts/new"
element={ element={
<RequireAuth> <RequireAuth>
<AccountWizard /> <Settings />
</RequireAuth> </RequireAuth>
} }
/> />
+3 -1
View File
@@ -13,7 +13,9 @@ export function BackfillBanner() {
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const importing = (accounts.data ?? []).filter( const importing = (accounts.data ?? []).filter(
(a) => a.state === "backfilling" && a.backfill_total > 0, // Also require done < total so a completed account whose state event was missed
// (e.g. a dropped WS) never leaves a stuck "X of X" banner.
(a) => a.state === "backfilling" && a.backfill_total > 0 && a.backfill_done < a.backfill_total,
); );
const active = importing.length > 0; const active = importing.length > 0;
+6 -2
View File
@@ -239,6 +239,7 @@ export const en = {
"wizard.creating": "Adding…", "wizard.creating": "Adding…",
"wizard.created": "Account added — syncing your mail.", "wizard.created": "Account added — syncing your mail.",
"wizard.startReading": "Start reading now", "wizard.startReading": "Start reading now",
"wizard.backToAccounts": "Back to accounts",
"wizard.manualConfig": "Enter settings manually", "wizard.manualConfig": "Enter settings manually",
"wizard.displayName": "Display name", "wizard.displayName": "Display name",
"wizard.syncWindow": "How much mail to sync", "wizard.syncWindow": "How much mail to sync",
@@ -249,6 +250,8 @@ export const en = {
"wizard.syncWindow.new": "New mail only", "wizard.syncWindow.new": "New mail only",
"wizard.syncWindow.new.hint": "wizard.syncWindow.new.hint":
"Nothing from before now — download older mail anytime from account settings.", "Nothing from before now — download older mail anytime from account settings.",
"wizard.syncSpam": "Sync spam?",
"wizard.syncSpam.hint": "Also import the Spam/Junk folder. Off by default.",
// Provider credential-helper panel (app passwords, SPEC §6.2.2). // Provider credential-helper panel (app passwords, SPEC §6.2.2).
"wizard.appPassword.title": "Create an app password", "wizard.appPassword.title": "Create an app password",
@@ -272,8 +275,9 @@ export const en = {
"wizard.appPassword.oauthInstead": "Sign in with Google instead", "wizard.appPassword.oauthInstead": "Sign in with Google instead",
// Gmail steps. // Gmail steps.
"wizard.help.gmail.step1": "Turn on 2-Step Verification for your Google account if you haven't.", "wizard.help.gmail.step1": "Turn on 2-Step Verification for your Google account if you haven't.",
"wizard.help.gmail.step2": "On the App passwords page, name the password (e.g. \"ultimo.email\") and create it.", "wizard.help.gmail.step2": "Click the button to generate an app password.",
"wizard.help.gmail.step3": "Copy the 16-character password and paste it below.", "wizard.help.gmail.step3": "On the App passwords page, name the password (e.g. \"ultimo.email\") and create it.",
"wizard.help.gmail.step4": "Copy the 16-character password and paste it below.",
// Outlook / Microsoft personal steps. // Outlook / Microsoft personal steps.
"wizard.help.outlook.step1": "Turn on two-step verification in your Microsoft account security settings.", "wizard.help.outlook.step1": "Turn on two-step verification in your Microsoft account security settings.",
"wizard.help.outlook.step2": "Under Advanced security options, create a new app password.", "wizard.help.outlook.step2": "Under Advanced security options, create a new app password.",
+1
View File
@@ -133,6 +133,7 @@ export const api = {
smtp: ServerConfig; smtp: ServerConfig;
password: string; password: string;
sync_window?: SyncWindow; sync_window?: SyncWindow;
sync_spam?: boolean;
}) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }), }) => apiFetch<AccountDetail>("/accounts", { method: "POST", body: JSON.stringify(input) }),
accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`), accountDetail: (id: string) => apiFetch<AccountDetail>(`/accounts/${id}`),
updateAccount: ( updateAccount: (
+2
View File
@@ -116,6 +116,8 @@ export interface HelpBlock {
app_name_hint: string; app_name_hint: string;
steps: string[]; // i18n message keys steps: string[]; // i18n message keys
docs_url: string; docs_url: string;
setup_link: string; // optional 2FA page linked from the first step
button_after_step: number; // 1-based step to place the app-password button after; 0 = top
} }
export interface CandidateConfig { export interface CandidateConfig {
+57 -8
View File
@@ -37,15 +37,29 @@ export function subscribeWs(listener: WsListener): () => void {
export function connectWs(queryClient: QueryClient): () => void { export function connectWs(queryClient: QueryClient): () => void {
const proto = window.location.protocol === "https:" ? "wss" : "ws"; const proto = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); const url = `${proto}://${window.location.host}/api/ws`;
const ping = window.setInterval(() => { let socket: WebSocket | null = null;
if (socket.readyState === WebSocket.OPEN) { let ping: number | undefined;
socket.send(JSON.stringify({ type: "ping" })); let reconnectTimer: number | undefined;
let attempts = 0;
let stopped = false;
// Refetch the live-updated lists — used on every (re)connect so a dropped socket
// (server restart, sleep, network blip) self-heals and catches up on missed events.
const resync = () => {
for (const key of [
["threads"],
["accounts"],
["folder-counts"],
["quota"],
["drafts-remote"],
]) {
queryClient.invalidateQueries({ queryKey: key });
} }
}, 30000); };
socket.onmessage = (event) => { const handleMessage = (event: MessageEvent) => {
let parsed: unknown; let parsed: unknown;
try { try {
parsed = JSON.parse(event.data); parsed = JSON.parse(event.data);
@@ -87,7 +101,15 @@ export function connectWs(queryClient: QueryClient): () => void {
queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
} }
if (name === "account.state_changed") { if (name === "account.state_changed") {
// Adding/deleting an account changes threads and (Gmail) labels wholesale —
// refetch the list, chips, and sidebar so nothing stale lingers.
queryClient.invalidateQueries({ queryKey: ["accounts"] }); queryClient.invalidateQueries({ queryKey: ["accounts"] });
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
window.dispatchEvent(new Event("webmail:labels-changed"));
} }
if (name === "message.sent") { if (name === "message.sent") {
// The sent message's draft is deleted server-side; refresh the Drafts list. // The sent message's draft is deleted server-side; refresh the Drafts list.
@@ -96,8 +118,35 @@ export function connectWs(queryClient: QueryClient): () => void {
} }
}; };
const connect = () => {
socket = new WebSocket(url);
socket.onopen = () => {
attempts = 0;
ping = window.setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
resync(); // catch up on anything published while we were disconnected
};
socket.onmessage = handleMessage;
socket.onclose = () => {
if (ping !== undefined) window.clearInterval(ping);
if (stopped) return;
// Exponential backoff, capped at 30s, so a downed server is retried politely.
const delay = Math.min(30000, 1000 * 2 ** attempts);
attempts += 1;
reconnectTimer = window.setTimeout(connect, delay);
};
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect
};
connect();
return () => { return () => {
window.clearInterval(ping); stopped = true;
socket.close(); if (ping !== undefined) window.clearInterval(ping);
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socket?.close();
}; };
} }
+19 -1
View File
@@ -148,7 +148,25 @@
@apply flex items-center justify-between pt-3 px-3 pb-1; @apply flex items-center justify-between pt-3 px-3 pb-1;
} }
.wm-side-switch { .wm-side-switch {
@apply relative; @apply relative flex items-center gap-2;
}
/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers). */
.wm-side-switch::before {
content: "";
flex: 0 0 auto;
width: 28px;
height: 28px;
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
}
/* And at the top of the collapsed rail, above the expand/theme buttons. */
.cal-rail::before,
.ct-rail::before {
content: "";
flex: 0 0 auto;
width: 30px;
height: 30px;
margin: 10px auto 6px;
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
} }
.wm-side-switch > button { .wm-side-switch > button {
@apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover; @apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover;
+14 -2
View File
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom";
import signetLogo from "../../icons/ultimo-logo-signet.svg";
import { AppSwitcher } from "../components/AppSwitcher"; import { AppSwitcher } from "../components/AppSwitcher";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { DraftsList } from "../components/list/DraftsList"; import { DraftsList } from "../components/list/DraftsList";
@@ -512,7 +513,14 @@ export function MailLayout() {
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3" collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`} }`}
> >
{!collapsed && <AppSwitcher />} {collapsed ? (
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
) : (
<div className="flex items-center gap-2">
<img src={signetLogo} alt="Ultimo" className="h-7 w-auto shrink-0" />
<AppSwitcher />
</div>
)}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}> <div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
<button <button
onClick={() => setCollapsed((v) => !v)} onClick={() => setCollapsed((v) => !v)}
@@ -573,7 +581,11 @@ export function MailLayout() {
> >
<AccountDot <AccountDot
color={a.color} color={a.color}
syncing={a.state === "backfilling" && a.backfill_total > 0} syncing={
a.state === "backfilling" &&
a.backfill_total > 0 &&
a.backfill_done < a.backfill_total
}
total={a.backfill_total} total={a.backfill_total}
done={a.backfill_done} done={a.backfill_done}
/> />
+18 -6
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor"; import { RichTextEditor } from "../../components/compose/RichTextEditor";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
@@ -11,6 +11,7 @@ import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat"; import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot"; import { PluginSlot } from "../../plugins/PluginSlot";
import { AccountWizard } from "../wizard/AccountWizard";
const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const; const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const;
type Section = (typeof SECTIONS)[number]; type Section = (typeof SECTIONS)[number];
@@ -21,10 +22,20 @@ export function Settings() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// Where the back button returns to — set by whoever opened Settings (Mail or a // Where the back button returns to — set by whoever opened Settings (Mail or a
// plugin like Calendar via ?from=). Only accept internal paths; default to Mail. // plugin like Calendar via ?from=). Only accept internal paths; default to Mail.
const navigate = useNavigate();
const rawFrom = searchParams.get("from") ?? ""; const rawFrom = searchParams.get("from") ?? "";
const from = rawFrom.startsWith("/") ? rawFrom : "/mail"; const from = rawFrom.startsWith("/") ? rawFrom : "/mail";
const fromQuery = `?from=${encodeURIComponent(from)}`; const fromQuery = `?from=${encodeURIComponent(from)}`;
const section = (SECTIONS.includes(params.section as Section) ? params.section : "profile") as Section; // The add-account wizard renders inside this layout (route /settings/accounts/new),
// with the Accounts nav item highlighted.
const addingAccount = useLocation().pathname.endsWith("/accounts/new");
const section = (
addingAccount
? "accounts"
: SECTIONS.includes(params.section as Section)
? params.section
: "profile"
) as Section;
return ( return (
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
@@ -49,15 +60,16 @@ export function Settings() {
))} ))}
</nav> </nav>
<main className="flex-1 overflow-y-auto p-6"> <main className="flex-1 overflow-y-auto p-6">
{section === "profile" && <ProfileSection />} {addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
{section === "security" && <SecuritySection />} {!addingAccount && section === "profile" && <ProfileSection />}
{section === "accounts" && <AccountsSection />} {!addingAccount && section === "security" && <SecuritySection />}
{!addingAccount && section === "accounts" && <AccountsSection />}
{section === "calendars" && <CalendarsSection />} {section === "calendars" && <CalendarsSection />}
{section === "appearance" && <AppearanceSection />} {section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />} {section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts. {/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */} Matches the accounts section width (max-w-5xl) with a gap above it. */}
{section === "accounts" && ( {!addingAccount && section === "accounts" && (
<div className="mt-8 max-w-5xl"> <div className="mt-8 max-w-5xl">
<PluginSlot name="settings.page" /> <PluginSlot name="settings.page" />
</div> </div>
+37 -2
View File
@@ -13,7 +13,14 @@ const EMPTY: ServerConfig = { host: "", port: 993, tls: "ssl", auth: "password"
// 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done. // 1 = email, 15 = provider app-password panel (step "1.5"), 2 = details, 3 = done.
type Step = 1 | 15 | 2 | 3; type Step = 1 | 15 | 2 | 3;
export function AccountWizard() { interface WizardProps {
// When embedded in the Settings layout, drop the full-screen framing and return
// to the accounts list on completion instead of navigating away.
embedded?: boolean;
onDone?: () => void;
}
export function AccountWizard({ embedded = false, onDone }: WizardProps = {}) {
const t = useT(); const t = useT();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -30,6 +37,7 @@ export function AccountWizard() {
const [googleOauthEnabled, setGoogleOauthEnabled] = useState(false); const [googleOauthEnabled, setGoogleOauthEnabled] = useState(false);
const [usedPanel, setUsedPanel] = useState(false); const [usedPanel, setUsedPanel] = useState(false);
const [syncWindow, setSyncWindow] = useState<SyncWindow>("all"); const [syncWindow, setSyncWindow] = useState<SyncWindow>("all");
const [syncSpam, setSyncSpam] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tested, setTested] = useState(false); const [tested, setTested] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -89,6 +97,7 @@ export function AccountWizard() {
smtp, smtp,
password, password,
sync_window: syncWindow, sync_window: syncWindow,
sync_spam: syncSpam,
}); });
await queryClient.invalidateQueries({ queryKey: ["accounts"] }); await queryClient.invalidateQueries({ queryKey: ["accounts"] });
setStep(3); setStep(3);
@@ -173,12 +182,24 @@ export function AccountWizard() {
</label> </label>
))} ))}
</div> </div>
<label className="mt-2 flex cursor-pointer items-start gap-2 border-t border-app-border pt-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={syncSpam}
onChange={(e) => setSyncSpam(e.target.checked)}
/>
<span>
<span className="font-medium">{t("wizard.syncSpam")}</span>
<span className="block text-xs text-app-faint">{t("wizard.syncSpam.hint")}</span>
</span>
</label>
</fieldset> </fieldset>
</> </>
); );
return ( return (
<div className="mx-auto mt-16 max-w-lg px-4"> <div className={embedded ? "max-w-lg" : "mx-auto mt-16 max-w-lg px-4"}>
<h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1> <h1 className="mb-6 text-2xl font-semibold">{t("wizard.title")}</h1>
{step === 1 && ( {step === 1 && (
@@ -198,12 +219,19 @@ export function AccountWizard() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoFocus autoFocus
/> />
<div className="flex gap-2">
<button <button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50" className="rounded bg-app-accent px-4 py-2 text-app-accent-fg disabled:opacity-50"
disabled={busy || !email.includes("@")} disabled={busy || !email.includes("@")}
> >
{busy ? t("wizard.discovering") : t("wizard.discover")} {busy ? t("wizard.discovering") : t("wizard.discover")}
</button> </button>
{embedded && onDone && (
<button type="button" className="px-3 py-2 text-sm text-app-muted" onClick={onDone}>
{t("common.cancel")}
</button>
)}
</div>
</form> </form>
)} )}
@@ -311,12 +339,19 @@ export function AccountWizard() {
{step === 3 && ( {step === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-emerald-500">{t("wizard.created")}</p> <p className="text-emerald-500">{t("wizard.created")}</p>
<div className="flex gap-2">
<button <button
className="rounded bg-app-accent px-4 py-2 text-app-accent-fg" className="rounded bg-app-accent px-4 py-2 text-app-accent-fg"
onClick={() => navigate("/mail")} onClick={() => navigate("/mail")}
> >
{t("wizard.startReading")} {t("wizard.startReading")}
</button> </button>
{embedded && onDone && (
<button className="px-3 py-2 text-sm text-app-muted" onClick={onDone}>
{t("wizard.backToAccounts")}
</button>
)}
</div>
</div> </div>
)} )}
@@ -94,22 +94,31 @@ export function ProviderCredentialPanel({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]); }, [value]);
const openDeepLink = () => { // Open a provider page in a popup window (not a tab). Must fire from a direct
// Must fire from this click (a direct user gesture) so popup blockers allow it. // user gesture (this click) so popup blockers allow it.
// Sized features open a popup window rather than a new tab. const openPopup = (url: string, name: string) => {
const w = 720; const w = 720;
const h = 680; const h = 680;
// Position the popup at the right (3/3) of the screen, pulled 200px back from // Position at the right (3/3) of the screen, pulled 200px back from the edge.
// the edge so it doesn't touch it; clamped to stay on-screen.
const left = Math.max(0, window.screen.width - w - 200); const left = Math.max(0, window.screen.width - w - 200);
const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2); const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2);
window.open( window.open(
help.deep_link, url,
`${help.help_id}-app-passwords`, `${help.help_id}-${name}`,
`popup,noopener,noreferrer,width=${w},height=${h},left=${left},top=${top}`, `popup,noopener,noreferrer,width=${w},height=${h},left=${left},top=${top}`,
); );
}; };
const appPasswordButton = (
<button
type="button"
onClick={() => openPopup(help.deep_link, "app-passwords")}
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg"
>
{t("wizard.appPassword.open", { provider: providerLabel })}
</button>
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -117,24 +126,27 @@ export function ProviderCredentialPanel({
<p className="mt-1 text-sm text-app-muted">{t("wizard.appPassword.intro")}</p> <p className="mt-1 text-sm text-app-muted">{t("wizard.appPassword.intro")}</p>
</div> </div>
{/* Button above the list only when no step placement is configured. */}
{help.button_after_step === 0 && appPasswordButton}
<ol className="list-decimal space-y-2 pl-5 text-sm text-app-muted">
{help.steps.map((key, i) => (
<li key={key}>
{i === 0 && help.setup_link ? (
<button <button
type="button" type="button"
onClick={openDeepLink} onClick={() => openPopup(help.setup_link, "2fa")}
className="rounded-lg bg-app-accent px-4 py-2 text-sm text-app-accent-fg" className="text-left text-app-accent hover:underline"
> >
{t("wizard.appPassword.open", { provider: providerLabel })} {t(key as MessageKey)}
</button> </button>
) : (
<ol className="list-decimal space-y-1 pl-5 text-sm text-app-muted"> t(key as MessageKey)
{help.steps.map((key) => ( )}
<li key={key}>{t(key as MessageKey)}</li> {help.button_after_step === i + 1 && <div className="mt-2">{appPasswordButton}</div>}
</li>
))} ))}
</ol> </ol>
{help.app_name_hint && (
<p className="text-xs text-app-faint">
{t("wizard.appPassword.appNameHint", { hint: help.app_name_hint })}
</p>
)}
<div> <div>
<input <input
@@ -9,11 +9,13 @@ own Actions menu + label chips (which read the plugin's API directly); only the
from __future__ import annotations from __future__ import annotations
from app.plugins.sdk import Plugin, Slot, UiManifest from app.plugins.sdk import Plugin, Slot, UiManifest
from webmail_plugin_labels.hook import on_message_new
from webmail_plugin_labels.router import router from webmail_plugin_labels.router import router
plugin = Plugin( plugin = Plugin(
name="labels", name="labels",
version="0.1.0", version="0.1.0",
hooks={"message.new": on_message_new},
router=router, router=router,
ui_manifest=UiManifest( ui_manifest=UiManifest(
slots=[ slots=[
@@ -0,0 +1,55 @@
"""message.new hook: map a message's Gmail labels to label chips (SPEC: Gmail sync).
Runs in the sync worker wherever ``message.new`` is published. When the core carries
``gmail_labels`` on the event (Gmail accounts only), each custom Gmail label is stored
as a ``<name> [Gmail]`` chip using the same KV layout as the rest of the feature: a
per-message key, a ``t:<thread_id>`` thread aggregate, and the ``_labels`` known set.
Names are merged (union), never overwritten, so app-native labels are preserved.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.sdk import sdk
logger = logging.getLogger("plg.labels")
_ALL_KEY = "_labels"
_GMAIL_SUFFIX = " [Gmail]"
async def _merge(kv: Any, key: str, names: set[str]) -> None:
"""Union ``names`` into the list stored at ``key`` (no-op if already present)."""
current = set(await kv.get(key, []))
if names <= current:
return
await kv.set(key, sorted(current | names))
async def on_message_new(payload: dict[str, Any]) -> None:
labels = payload.get("gmail_labels")
user_raw, message_id = payload.get("user_id"), payload.get("message_id")
thread_id = payload.get("thread_id")
if not labels or not isinstance(user_raw, str) or not isinstance(message_id, str):
return
names = {f"{str(name).strip()}{_GMAIL_SUFFIX}" for name in labels if str(name).strip()}
if not names:
return
s = sdk("labels")
kv = s.kv(uuid.UUID(user_raw))
await _merge(kv, message_id, names) # per-message labels
if isinstance(thread_id, str):
await _merge(kv, f"t:{thread_id}", names) # thread aggregate (list view chips)
# Known-label set (sidebar). When it actually grows, push plugin.labels.changed
# over the WS so the sidebar list refreshes live during a sync (SPEC §9.2/§11.3).
known = set(await kv.get(_ALL_KEY, []))
fresh = names - known
if fresh:
await kv.set(_ALL_KEY, sorted(known | names))
await s.publish("changed", {"user_id": user_raw})
logger.info("labels: %d new Gmail label(s) from message %s", len(fresh), message_id)
@@ -80,26 +80,76 @@ async def set_thread_labels(
@router.post("/threads/labels") @router.post("/threads/labels")
async def batch_thread_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]: async def batch_thread_labels(body: IdsIn, user_id: CurrentUserId) -> dict[str, list[str]]:
"""Aggregated labels for a set of threads (list-view chips). Reconciles first so
the chips reflect live messages — stale ones from deleted mail are dropped."""
live = await _reconcile(user_id)
return {tid: live[tid] for tid in body.ids if live.get(tid)}
_GMAIL_SUFFIX = " [Gmail]"
async def _reconcile(user_id: object) -> dict[str, list[str]]:
"""Self-heal label state from the per-message keys (the source of truth) and
return the live ``thread_id -> labels`` map.
Per-message keys (``<message_id>``) are authoritative; the ``t:<thread_id>``
aggregates and the ``_labels`` set are derived. Messages get deleted (account
removal) and re-threaded (JWZ merges) out from under us, which orphans thread
aggregates — so rebuild them here: drop labels of deleted messages, regroup the
survivors by their *current* thread, rewrite changed/stale ``t:`` keys, and
rebuild ``_labels`` from labels on live messages. User-created labels (no
``… [Gmail]`` suffix) are preserved even when unused; orphaned Gmail labels drop."""
kv = _SDK.kv(user_id) kv = _SDK.kv(user_id)
out = {tid: await kv.get(f"t:{tid}", []) for tid in body.ids} items = await kv.items()
return {tid: labels for tid, labels in out.items() if labels} msg_keys = {
k: v
for k, v in items.items()
if k != _ALL_KEY and not k.startswith("t:") and isinstance(v, list)
}
homes = await _SDK.messages.threads_of(user_id, list(msg_keys))
thread_labels: dict[str, set[str]] = {}
live_labels: set[str] = set()
for message_id, labels in msg_keys.items():
if message_id not in homes: # message deleted -> drop its orphaned key
await kv.delete(message_id)
continue
live_labels.update(labels)
thread_id = homes[message_id]
if thread_id is not None:
thread_labels.setdefault(thread_id, set()).update(labels)
# Rewrite only the thread aggregates that changed; delete ones with no messages.
old_thread_keys = {k[2:] for k in items if k.startswith("t:")}
result: dict[str, list[str]] = {}
for thread_id, names in thread_labels.items():
value = sorted(names)
result[thread_id] = value
if items.get(f"t:{thread_id}") != value:
await kv.set(f"t:{thread_id}", value)
for thread_id in old_thread_keys - set(thread_labels):
await kv.delete(f"t:{thread_id}")
known = set(items.get(_ALL_KEY, []) or [])
new_known = {n for n in known if not n.endswith(_GMAIL_SUFFIX)} | live_labels
if new_known != known:
await kv.set(_ALL_KEY, sorted(new_known))
return result
@router.get("/labels", response_model=list[str]) @router.get("/labels", response_model=list[str])
async def all_labels(user_id: CurrentUserId) -> list[str]: async def all_labels(user_id: CurrentUserId) -> list[str]:
await _reconcile(user_id) # self-heal from live messages, then return the known set
return await _SDK.kv(user_id).get(_ALL_KEY, []) return await _SDK.kv(user_id).get(_ALL_KEY, [])
@router.get("/labels/{name}/threads", response_model=list[str]) @router.get("/labels/{name}/threads", response_model=list[str])
async def label_threads(name: str, user_id: CurrentUserId) -> list[str]: async def label_threads(name: str, user_id: CurrentUserId) -> list[str]:
"""Thread ids whose aggregate labels include ``name`` — powers the label view """Thread ids whose aggregate labels include ``name`` — powers the label view in
in the mail list. Scans the per-thread label keys (``t:<thread_id>``).""" the mail list. Reconciles first, so it reflects live messages' current threads."""
items = await _SDK.kv(user_id).items() live = await _reconcile(user_id)
return [ return [tid for tid, labels in live.items() if name in labels]
key[2:]
for key, labels in items.items()
if key.startswith("t:") and isinstance(labels, list) and name in labels
]
@router.post("/labels", dependencies=[Depends(require_csrf)]) @router.post("/labels", dependencies=[Depends(require_csrf)])
@@ -128,8 +128,18 @@ export default function mount(el, ctx) {
refresh(); refresh();
window.addEventListener("webmail:labels-changed", refresh); window.addEventListener("webmail:labels-changed", refresh);
// Live update while syncing: the backend pushes plugin.labels.changed over the WS
// whenever a new Gmail label is mapped. Re-dispatch the local event so this list
// (and Mail's chip queries) refresh together.
const unsubscribe =
ctx.events && ctx.events.subscribe
? ctx.events.subscribe("plugin.labels.changed", () =>
window.dispatchEvent(new Event("webmail:labels-changed")),
)
: null;
return () => { return () => {
window.removeEventListener("webmail:labels-changed", refresh); window.removeEventListener("webmail:labels-changed", refresh);
if (unsubscribe) unsubscribe();
el.innerHTML = ""; el.innerHTML = "";
}; };
} }