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:
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 +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
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]] = []
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user