9.5 KiB
Extracting email into a plugin — plan of record
Started 2026-08-17. The runbook is plugins/EXTRACTING-A-PLUGIN.md and it is the authority; this file only
records what is specific to email — the boundary, the decisions taken, and what they cost.
Status: mapped, decisions taken, not yet moved.
The owner asked for a full extraction with no interim step ("no temporary steps"), and was explicit that
speed is not the constraint. Email is therefore NOT being switched back on in place first, even though that
is a four-line change — doing so would create email_accounts as a core-owned table that the extraction
would immediately have to hand over.
What email actually is
~5,500 lines, and the shape is better than its reputation.
| part | where | lines | difficulty |
|---|---|---|---|
| sidecar | src/servers/sidecar/email/ (14 files) |
3,697 | easy — self-contained, already announces email:server through the generalised ${string}:server union |
| api router | src/servers/api/email/router.ts |
22 | trivial — a createSidecarProxy with a 1800s timeout that is load-bearing |
| database | officer_db/src/email/ |
85 | easy — ONE table, email_accounts, already carrying the email_ prefix a plugin requires |
| frontend | officer-web/Screens/Dashboard/Email/ |
1,150 | easy — already a WorkspaceView with three panels |
| accounts UI | Settings/IntegrationsSettings/EmailAccounts.tsx |
509 | moves into the plugin |
| types | workspaces/types/email.ts |
29 | moves whole |
Two findings that made this smaller than the survey suggested:
- The frontend is already panel-shaped.
EmailScreenrendersWorkspaceViewwith acomponentsmap ofemail-list/email-reader/email-chatand adefaultLayout. The plugin contract wants exactly that, declared asweb/panels.ts+web/layout.ts. This is a re-declaration, not a rewrite. - Theme debt is 17 palette classes, all in the status/badge colours. The runbook requires paying this down during the extraction; at this size that is minutes, not a project.
src/workspaces/emailer/ is NOT part of this. It is the outbound transactional mailer — React Email
templates, sendMail, getTransport — used by forgot-password.ts, server-settings/smtp.ts and the
queue engine, all core. It shares the word "email" and the nodemailer dependency and nothing else.
The boundary, and the one thing that decides it
A plugin importing platform code is fine (@@/…). The reverse is not, and email has three of them.
google-auth is not a problem
Five sidecar files import getValidGoogleAccessToken / refreshGoogleAccessToken from
api/integrations/google-auth, which core integrations.ts also uses. The survey called this the hardest
problem in the extraction; it is not one at all. That direction is plugin→platform, which the runbook
explicitly permits, and google-auth is generic Google OAuth that knows nothing about mail. It stays in
core and the plugin imports it via @@/. The only real work is that the import style is inconsistent
today (../../ in three files, @@/ in two), so a mechanical rewrite would miss cases.
The real problem: core reads email's database
Three sites, and the first has already taken the platform down once (fbb6d6c7, 2026-08-13 — an unguarded
top-level await on getEmailAccounts rejected and put officer-agent into a PM2 restart loop, taking every
live chat session with it).
sidecar/claude/user-instance.ts— queriesemail_accounts, picks the first enabled one, and bakesOFFICER_EMAIL_DBinto the agent's MCP config so theemail_dbtool can read it.api/chat/websocket.ts—resolveEmailCwdqueries the same table so a chat withcontext: 'email'runs in that account's directory.api/integrations/integrations.ts— the Google OAuth callback appends/emailto the user's dock.
Note the email_db tool is not in this repository. It lives in PI_TOOLS_DIRS, resolved at runtime, so
it is invisible to grep and will not follow the extraction wherever the boundary lands.
Decisions
The owner was asked about the first two and had stepped away; both are taken on the lower-risk option and both are reversible. Revisit them before believing this document is settled.
1. Keep email_db working, by convention rather than by import — TAKEN, REVERSIBLE
Core stops importing getEmailAccounts and instead finds the newest DATA_PATH/<user>/email_accounts/*/emails.db
on disk. Same for resolveEmailCwd.
- Removes the code dependency that caused the crash — no core file imports anything email again.
- Keeps the feature. "Ask the agent about my mail" is a large part of why this product is worth running, and silently deleting it during a refactor is not a trade the refactor gets to make on its own.
- Costs a vocabulary leak: core still knows the directory name
email_accounts. The runbook says the platform must not know a plugin's vocabulary, so this is a deliberate, recorded exception rather than an oversight. The clean version is decision 1c below.
Rejected for now: clean cut (purest, but removes a working feature by fiat) and plugin ships its own MCP tool (correct long-term; needs a plugin→agent tool mechanism that does not exist, so it would block this extraction on new platform work).
2. Account management becomes a panel in the email workspace — TAKEN, REVERSIBLE
EmailAccounts.tsx leaves core Settings and becomes a panel. The plugin contract allows panels and nothing
else, so this is the shape available; the alternative is a second plugin route. User-visible change: you
will add and remove mail accounts from /email, not from /settings/integrations.
3. Encrypt the credentials — TAKEN
email_accounts.credentials is jsonb holding an IMAP app password in the clear. It is encrypted through
officerdb/crypto under its own email secret-store purpose as part of the move.
This is normally the kind of scope creep to refuse. It is free here for one reason: the table does not
exist and has never held a row — it was commented out of schema.ts on 2026-08-13 and db:push therefore
never created it. There is no migration, no backfill and no compatibility window. Doing it later would need
all three.
4. email_accounts stays in USER_DIRS — TAKEN
Core pre-creates the directory for every account at 0700. Follows from decision 1: core has to know the path
anyway, and having two places create it differently is the bug USER_DIRS exists to prevent.
Order
Runbook order, with email's specifics:
plugins/email/skeleton +manifest.ts.appNameis the directory name.- Sidecar — 14 files, whole. Rewrite the five
google-authimports to@@/. - Database —
officer_db/src/email/→plugins/email/db/. Schema takesofficerdb/auth/schemafor theusers.idFK, the one reference a plugin may make. Encryptcredentials(decision 3). - API — the 22-line proxy router →
plugins/email/api/router.ts, exportingrouter. - Frontend — Screens/Email + EmailAccounts →
web/panels.ts+web/layout.ts. Pay down the 17 palette classes here. - Core cleanup — delete, never comment.
hono.tsmount + import,registry.tsentry (bothapiAND the still-liveroutes: ['/email']),App.tsxroutes,Screens/Dashboard/index.tsx,usePageTitle.ts, the Settings section,types/index.ts, both database barrels, and the three reverse imports above. - Delete the
app-store/catalogue.tsentry. Not optional and not cosmetic:permissionAvailabilityderives fromsidecar_installs, a plugin only ever gets aplugin_installsrow, so a leftover catalogue entry makes the permission permanentlyunavailableand blanks the screen on a machine where the plugin is installed and healthy. This has bitten twice already — headscale, and nearly music. bunx tsgountil clean. It finds the wiring that greps miss.- Verify live through the runbook's table: install → API → screen → dock → permissions → disable → enable → uninstall → purge → install again. Purge is the only honest test of a first install.
- Its own public repository, a README, and a marketplace catalogue row — an extraction is not finished when it installs from disk.
What is NOT known yet
Whether email works. It has been off since 2026-08-13 and email_accounts has never existed on this
machine, so nothing has exercised IMAP sync, Gmail sync, sending or the UI in that time. The sidecar starts
cleanly and its only error is the missing table — which is evidence the code did not rot, and not evidence
that it works.
So the first install is also the first functional test this feature has had in four days, and a failure then is as likely to be pre-existing as extraction damage. Diff against git history before assuming which.