email leaves the platform for plugins/email

the sidecar, the router, the table, the screens and the account manager are
gone from src/ entirely - deleted, not commented, because a commented mount is
what assertPermissionTotality cannot see.

three reverse imports had to go first, and they are what decided the boundary.
core may not import a plugin's queries, and one of these had already proved
why: getEmailAccounts was awaited at the top level of the agent sidecar, so
when the table went away on 2026-08-13 the rejection took officer-agent into a
pm2 restart loop with every live chat session inside it (fbb6d6c7).

both survivors now read the DISK instead. findEmailAccountName() in
data-path.ts picks the newest emails.db under an account's email_accounts/
directory - no import, no query, and nothing to throw when the plugin is not
installed. the layout was already the platform's, so this costs no coupling.
the agent keeps its email_db tool and an email-context chat still opens in the
right mailbox.

the google oauth callback no longer appends /email to the dock: a plugin's
route is the plugin's business.

app-store/catalogue.ts entry deleted, and that is the whole point rather than
tidying. permissionAvailability derives from sidecar_installs and a plugin only
ever gets a plugin_installs row, so a leftover entry makes the permission
permanently unavailable - denied route, no dock tile - on a machine where the
plugin is installed and healthy. it has bitten headscale and nearly music.

two app-store tests used email as their config-only fixture; repointed at
notify, which is the same shape.

google-auth stays in core and the plugin imports it via @@/. that direction is
allowed and google-auth knows nothing about mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:28:13 +00:00
co-authored by Claude Opus 5
parent 4655d374ec
commit 98c37c4ce9
44 changed files with 58 additions and 5583 deletions
+34 -1
View File
@@ -1,5 +1,5 @@
import { join, resolve } from 'node:path';
import { chmodSync, mkdirSync, readFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
// ── The install root, and why it is derived rather than configured ──
@@ -197,6 +197,39 @@ export const getEmailDbPath = (ownerEmail: string, accountEmail: string) =>
export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
/**
* Which mail account's store to use, found on DISK rather than by asking the database.
*
* Email is a plugin (`plugins/email/`) as of 2026-08-18, and the platform may not import a plugin's
* queries — that direction is the one the plugin system forbids, and here it is also the direction that has
* already broken this server: `getEmailAccounts` was awaited at the top level of the agent sidecar, and
* when the table went away on 2026-08-13 the rejection took `officer-agent` into a PM2 restart loop with
* every live chat session inside it (`fbb6d6c7`).
*
* Two callers still legitimately need an answer — the agent's `email_db` MCP tool, and a chat opened
* against a mailbox — and neither needs to know anything about mail to get one. The directory layout is
* already the platform's (see `USER_DIRS`), so reading it answers the question with no coupling: no import,
* no query, and nothing to throw if the plugin is not installed.
*
* Newest by mtime, because the most recently synced account is the one a fresh question is most likely
* about. Returns the DIRECTORY NAME (the account's address), which is what both callers compose with.
*/
export function findEmailAccountName(ownerEmail: string): string | null {
try {
const root = getEmailAccountsDir(ownerEmail);
const candidates = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== 'attachment_cache')
.map((entry) => ({ name: entry.name, db: join(root, entry.name, 'emails.db') }))
.filter((candidate) => existsSync(candidate.db))
.map((candidate) => ({ ...candidate, at: statSync(candidate.db).mtimeMs }))
.sort((a, b) => b.at - a.at);
return candidates[0]?.name ?? null;
} catch {
// No directory, no permission, no plugin. All of them mean "no account", which is a normal state.
return null;
}
}
// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the
// 32-char Linux limit are the last trace of the per-container architecture, where this really did name
// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried