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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user