Commit Graph
10 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 c1c367456b the mailbox picker, so the multi-account backend is reachable
c60e3d6 made every route able to name its mailbox and left the UI unable to.
`?account=<id>` names one and `?account=all` spans them; nothing on screen
passed either, so the list showed whichever account sorted first enabled.

The selection is a QUERY PARAM, the same as the folder beside it and for the
same reason — it is addressable state, so a reload, a shared URL and a
cmd-click all land where they were meant to. The pills are real links built from
the live params, so the folder and any open email survive a mailbox switch.

`account` is in the react-query key. Without it, switching mailbox would serve
the previous one's page from cache under the new selection, which reads as the
filter not working rather than as a caching bug.

Absent means `all` in the UI and only in the UI. The routes keep treating absent
as "first enabled" — mail-scope.ts does that deliberately, because the mobile app
and the agent's email_db tool call them and know nothing about accounts. That
default is right for a caller that cannot choose and wrong for a person looking
at a screen: three accounts and you want all of it, not whichever sorts first.

Hidden with fewer than two accounts. A control that can say one thing is noise in
a toolbar that is already tight on a phone.

NOT visually verified: this machine has no email accounts configured, so the
picker cannot render here. tsgo clean, prettier clean, the 16 mail-scope tests
pass. It needs a look on a host with two accounts before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 23:34:08 +00:00
pastilhasandClaude Opus 5 bd040a5e21 carry the email-db skill in this repository
It lived in capabilities/skills/email-db, hand-written into the owner's store
rather than published by anything — the plugin shipped no skills directory at
all, so that file existed in exactly one place and nothing tied it to the plugin
it documents.

The platform now reads a plugin's skills from the plugin's own directory, so
this is where it belongs: it moves with the plugin, updates with it, and
disappears when the plugin is disabled instead of outliving it in somebody's
fork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 23:22:57 +00:00
pastilhasandClaude Opus 5 c60e3d6583 every route can name its mailbox, and the list can span them
Multiple accounts were already supported everywhere except where it counted. email_accounts is keyed
(user_id, email), each account syncs into its own emails.db, and IDLE watchers run per account — so three
accounts would all sync happily and only one could ever be read. The assumption lived in two lines:

    store.ts     openUserEmailDb()   accounts.find(a => a.enabled) ?? accounts[0]
    routes.ts    getSmtpTransport()  accounts.find(a => a.enabled) ?? accounts[0]

The first had thirteen callers — every read route. The second decided who sends.

Now `?account=<id>` names one mailbox and `?account=all` spans them. Absent stays the old behaviour, first
enabled, because the mobile app and the agent's email_db tool both call these routes and know nothing about
accounts; making it required would have broken both silently.

ONE DATABASE PER ACCOUNT, and the unified view is a MERGE rather than a join. Not a limitation — emails_fts
is a per-database FTS5 virtual table and MATCH cannot be aimed across an ATTACH boundary through a view, so
a unified search has to be a merge of local searches. The upside is that every SQL string in store.ts is
reused verbatim, searchEmails included, so the operator parser and the OR branching are not reimplemented
for the unified case.

The merge is a distributed top-K and it is exact, not approximate: each mailbox is asked for offset+limit
rows, because a row ranked third locally can belong fortieth globally and anything less leaves a hole in a
later page.

Threads are collapsed per mailbox before merging, deliberately. A conversation does not continue across two
accounts, so local collapse is both cheaper and more correct than any cross-database window function.

Four details worth keeping:

- Routes addressed by an id search the scope for the mailbox HOLDING it. A unified list returns ids from
  every account, so opening the first would 404 on mail plainly on screen. The predicate is `id OR
  thread_id` because these routes take both and the id spaces overlap by design.
- sync-status across mailboxes reports the OLDEST stamp. "Synced as of" is a claim about everything on
  screen and stops being true at the laggard; the freshest would call a unified inbox current while one
  account had not been reached for a day.
- Sending honours the named account and REFUSES an id that is not the caller's, rather than falling back to
  the default. Replying from the wrong account leaks which addresses belong to one person and breaks the
  thread for the recipient.
- queryAcross has no `descending` option. It had one for twenty minutes; a test caught that it flipped the
  merge while every caller's SQL still said ORDER BY date DESC, so asking for the oldest mail returned the
  oldest of the newest page. The requirement — mailboxes hand back newest-first — is now written down and
  pinned by a test that demonstrates the silent wrongness rather than asserting the fix.

UI is next: the picker for switch-or-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 00:45:57 +00:00
pastilhasandClaude Opus 5 3db9534614 compose and add-account are real forms, so enter submits them
Compose's To/Cc/Bcc/Subject fields, and each of the three add-account
forms, sat in a plain div — enter did nothing and the button was the
only way in. Wrapped each in a <form onSubmit>, marked every button
that isn't the submit action type="button" (attach, cancel, cc/bcc
toggles, the recipient-suggestion picks), so none of them accidentally
submit now that a form exists around them. The compose body stays a
contenteditable div, which never triggers native enter-to-submit, so
Cmd/Ctrl+Enter wasn't needed to keep newlines working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:45:21 +00:00
Claude bc9934fc55 art: icon and splash, drawn to the current brief
A gliding envelope as the hero with Officer Duck riding it, and a post-office
sorting desk for the splash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:49:19 +01:00
Claude 7c0767763f icon: transparent corners, so the tile is a tile
The app icon is flattened onto black because iOS rejects an alpha channel, and
copying that straight over made every dock tile an opaque black square. The tile
wants the frame's own rounded edge with the corners cut away — which is what the
icon this replaces already had, and what tile: bare means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:28:48 +01:00
Claude d168a07660 icon: the app's own artwork, at 256
The dock tile takes assets/icon.png when the plugin ships one and falls back to
the manifest's lucide glyph when it does not. This is the same icon the phone
shows, resized — one mark per product, wherever it appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:04:09 +01:00
pastilhasandClaude Opus 5 91b978c043 mount the accounts modal in the empty state, so a fresh install can add its first account
Clicking "Add Account" on a new install did nothing at all — no modal, no
error, nothing in the console.

EmailList early-returns an onboarding empty state when there is no mail, and
that branch carries the "Add Account" button. But <AccountsModal /> was only
mounted in the OTHER return — the one that renders when mail exists. The button
flipped the shared EMAIL_ACCOUNTS_OPEN global and nothing was listening.

A catch-22, and it closed the only door in: the sole other trigger is in the
toolbar, which is also in the mail-exists branch. So an install with no account
could never reach the dialog that adds one, and the failure was silent in both
directions — nothing threw, and the state flag really did flip.

Mount it in both branches. The empty state is exactly when it is needed most.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:50:46 +00:00
Claude Opus 5 37d7c3c010 carry the email work queue over from the platform 2026-08-18 01:00:30 +00:00
Claude Opus 5 980a9d3c38 email, extracted from the officer platform
IMAP and Gmail sync into a per-account SQLite store, with the mail read and
composed in the browser.

moved whole from src/servers/sidecar/email (14 files), src/servers/api/email,
officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the
way out:

- credentials are encrypted. the column was plaintext jsonb holding an IMAP app
  password that the SMTP transport read straight out of the row; it is now
  AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and
  opened in queries.ts alone so nothing above it can hold ciphertext by
  accident. no plaintext fallback on read - a value that does not verify is an
  error.
- the router derives its prefix from mountPrefix() instead of the literal
  '/api/email', which was only correct for a first-party publisher.
- the frontend became panels.ts + layout.ts. the two panel appTypes that were
  null are now declared, because the platform screen used to override them by
  panel id and a plugin has no such hook.
- account management became a modal. it was a section in the platform's
  Settings, which a plugin cannot contribute to, and a panel does not work
  either: PluginScreen renders the workspace locked, so a panel outside the
  default layout is unreachable. it follows Compose, which had already solved
  this.
- 17 palette classes replaced with semantic tokens.

not verified: this has never run. it was switched off in the platform on
2026-08-13 and its table was never created, so nothing has exercised sync,
sending or the UI since. the first install is the first test.
2026-08-18 00:29:50 +00:00