8 Commits
Author SHA1 Message Date
pastilhas 211e913501 a restart mid-sync stranded accounts at syncing or queued forever
getAllSyncedAccounts only picks up status = synced, so an account whose sidecar
process died between marking it syncing/queued and clearing that status again
dropped out of the cron and the IDLE watcher silently, with no error to explain
why background sync had stopped. Reset both on startup instead: the job runner
is in-memory, so neither state can be genuinely in flight in a fresh process.
2026-09-14 20:56:24 +01:00
pastilhasandClaude Opus 5 fda9beb80a the skill's name is an identifier, not a label
lowercase-hyphenated and equal to its directory, which is what both harnesses
require to LOAD it. carrying a human title there meant the skill was listed to an
agent and then failed to open. the prose moves to `title`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:11:34 +00:00
pastilhasandClaude Opus 5 1e8b1f6538 release 2026.08.27.01
first dated version. plugins were all sitting on 1.0.0, which said nothing about
when a build was made or which of two copies is newer — the platform already
uses YYYY.MM.DD.NN and this follows it.

the version lives in manifest.ts because a plugin has no package.json; that is
the field the marketplace publishes and the installer compares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:36:19 +00:00
pastilhas 0892161423 AGENTS.md, so both harnesses find the same guidance
opencode discovers AGENTS.md and nothing else; claude code discovers CLAUDE.md.
this repo had neither, so an agent sent to change something here arrived with no
guidance at all under either.

it deliberately does NOT repeat README.md. that file says what the plugin is,
for someone deciding whether to install it. this one answers the other question:
i have to change something in here, what will i break.

the facts in it are read from the repository rather than recalled — the panel
keys from web/panels.ts, the tables from db/schema.ts, the permission keys, the
host binaries and the route claims from manifest.ts. presence is the declaration
everywhere in this system, so the file says what the platform will actually
conclude from these files existing.

CLAUDE.md beside it is a pointer, not a copy: two copies drift and the stale one
is the one somebody reads.
2026-08-27 07:24:08 +00:00
pastilhasandClaude Opus 5 af66cc47ed the chat brief is AGENTS.md, so opencode reads it too
this file is copied verbatim into a chat working directory, where the harness
has to DISCOVER it — and opencode discovers only AGENTS.md. named CLAUDE.md it
reached claude code and nothing else, silently.

the platform still accepts either name, so an installed copy that has not
updated keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:18:32 +00:00
pastilhas 067198ed59 say what this plugin is for, in the store's words
tags are how the marketplace is browsed rather than scrolled: a list of them on
the left, the plugins that carry one on the right. a closed vocabulary
(PLUGIN_TAGS in the platform) because free text becomes homelab, home-lab, Home
Lab and selfhosted inside a month, and a filter list of synonyms is worse than
no filter at all.

declared here rather than curated in the catalogue, so a plugin can say what it
is and nobody edits a table when one ships. the marketplace caches it on sync,
exactly as it already does for label and summary.
2026-08-27 06:49:30 +00:00
pastilhasandClaude Opus 5 c538086f2b the chat says which mailbox it is in, and reads a brief instead of a prefix
the same arrangement proxy-manager got, with one difference that turned out to
matter: an email chat does not run in the plugin's chats directory, it runs in
the ACCOUNT's, beside emails.db.

three things.

the panel declares its chat context. it declared none, and it did not need to
while email was part of the platform — ChatPanelWrapper recognised the screen by
`workspace.id === 'email'` and supplied it. a plugin's workspace key is
`screens/plugin/email`, so that id became `plugin/email` on extraction day and
the branch silently stopped matching. every email chat since has run somewhere
with no mailbox anywhere near it. declaring it here is what that branch's own
comment asked for, and it carries the mailbox too, which a hardcoded screen id
never could — with two accounts the chat follows the picker.

the prompt prefix is now chat/CLAUDE.md. a prefix rides every message: paid per
turn forever, invisible as a design artefact because it lives in a typescript
constant, and visible in the one place it should not be. the file costs nothing
per turn and the person it is about can open it. it says more than the prefix
did, because it can afford to: answer from the local db and not from gmail, the
screen already shows what you would paste back, and a bulk delete over 19k rows
is not reversible from here.

the panel gets a header, so the conversation picker is scoped to this mailbox's
directory rather than absent. panelId is a prop now instead of the hardcoded
"email-chat" — two of these could never have held different conversations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 18:42:59 +00:00
pastilhasandClaude Opus 5 778d7d3f12 the reader reads the param the platform actually passes
clicking a message did nothing. the reading pane stayed on "Select an email to
read" whatever you opened, and it had been that way since email became a plugin.

the reader asks the URL which message is open. it asked for `emailId`, which was
the param name back when email was part of the platform and declared its own
`/email/:emailId` route. a plugin declares no routes: the platform mounts every
one at `<route>` and `<route>/:section`, so the id arrives as `section`. asking
for `emailId` got undefined, `selectedId` was null on every render, and the pane
rendered its empty state.

nothing else was wrong, which is why this took so long to place — the list
showed, the link navigated, the URL changed, and both API routes return full
data (verified against the real 19k-message store: /messages carries every
snippet, /thread/:id returns 55KB of html). one undefined param, and the whole
right-hand side of the screen looked dead.

still accepts `emailId`, so this keeps working if email ever gets its own route
again. the underlying smell is the platform naming a plugin's param for it —
worth fixing there, not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-26 18:32:29 +00:00
10 changed files with 239 additions and 18 deletions
+62
View File
@@ -0,0 +1,62 @@
# AGENTS.md
Guidance for a coding agent working on **this plugin's own repository**. `CLAUDE.md` beside it only
points here — the two harnesses discover different filenames and one set of instructions has to
reach both.
**This is not what the plugin is.** `README.md` answers that, for a person deciding whether to
install it. This file answers "I have to change something in here — what will I break?"
## Where you are
A plugin repository, cloned into `~/officer-dev/plugins/email`, its own git remote. It is **not**
part of the platform's repository, and `platform/plugins/email` is a different thing — that is
where this plugin gets INSTALLED, by the installer cloning it from gitea. Never edit there; the
next install overwrites it.
Check the checkout with `bun run plugins:check` from the platform repo.
## What it ships
- `api/router.ts` — routes, mounted at `/api/email`
- `sidecar/index.ts` — a process, run by PM2 as `officer-email`
- `db/schema.ts` — tables: `email_accounts`
- `web/` — panels and a layout; the platform renders the screen, not this plugin
- `chat/AGENTS.md` — copied into a chat's working directory as its brief
- `skills/` — read by every agent session, harness-agnostic
Presence is the declaration: the platform decides what this plugin has by what files exist. Adding
`db/schema.ts` gives it tables; deleting `api/router.ts` removes its routes. There is no field to
keep in step.
## What must not change without knowing why
- **Permission key `email`** — stored as the subject of every grant. Renaming one is a data change, not a rename.
- **Panel keys** (`email-list`, `email-reader`, `email-chat`) — a user's stored layout names them.
- **Table names** — they hold real rows on installed machines.
- **`appName`** — the directory name IS the id: route segment, table prefix, sidecar suffix, install key.
## Traps that have already cost time
- **`useClient()` rebuilds its verbs on every render.** Anything putting one in a `useCallback` dependency list gets a new identity every render. This silently disabled every playback report in the Jellyfin player for days; the fix is refs.
- **The reader reads the URL param the PLATFORM passes**, which is `section`, not a name of this plugin's choosing. Asking for the wrong one returns `undefined` and the pane renders its empty state forever — that happened here and looked like a broken reading pane.
- **Snippets are extracted from raw MIME without decoding**, so some read `This is a multi-part message in MIME format.` and others show `m=C3=A1s`. Known, cosmetic, not yet fixed.
## Verifying
From the platform repo, which is where the toolchain lives:
```bash
bunx tsgo # a red typecheck is not done
bun test # 1 known failure: ModelSelector, unrelated
bun run plugins:check
bunx prettier --write <the files you touched>
```
A sidecar change needs `pm2 restart officer-email`; anything in `api/` or `web/` needs
`pm2 restart officer`.
## Related
- `README.md` — what it is and what it claims, for someone installing it
- `skills/` — how an agent USES this, as opposed to changing it
+6
View File
@@ -0,0 +1,6 @@
# CLAUDE.md
**The instructions live in [`AGENTS.md`](AGENTS.md). Read it first — this file only points there.**
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart.
+42
View File
@@ -0,0 +1,42 @@
# Email
This is a chat beside someone's mailbox. **You are running inside the account's own directory**, so the
mail is not somewhere you have to go and find — `emails.db` is right here, and so are its attachments.
## Answer from the local database, not from Gmail
Every question about mail that has already arrived — search, counts, who sends the most, what is unread,
how much space something takes — is answered from `emails.db` in this directory. It is SQLite with an FTS5
index over subject, sender, recipients, snippet and body.
The `email_db` tool queries it. Use that rather than shelling out, and read the `email-db` skill for the
schema and the queries worth knowing before writing your own.
**Do not reach for the Gmail integration to answer a question about existing mail.** It is slower, it is
rate-limited, and it will disagree with what the screen beside you is showing. Gmail is for things the
local copy cannot do — sending, or fetching something that has not synced yet.
## What the person can see while you talk
The list to the left and the message above you are reading the same database you are. So:
- **Do not paste back what is already on screen.** They can see the subject and the sender. Tell them the
thing they asked for.
- **Counts and aggregates are the point.** "How many did GitHub send me last month", "which domains send
me the most", "what is unread from a human" — those are what a database beside a mailbox is for, and
they are the questions the UI cannot answer.
- **A number produced by searching is confidently wrong.** If you counted by fetching pages until you got
bored, say so. `SELECT count(*)` is a different kind of answer from "I found about thirty".
## Deleting and modifying
Deleting mail from the local database does not delete it at Gmail — it removes the local copy, which the
next sync may bring back. Say which one you did. **Confirm before any bulk delete**, and say how many rows
it will touch, because "delete everything from that sender" over 19,000 messages is not reversible from
here.
## The mailbox you are in
The directory you are running in IS the account. With more than one account configured, the chat panel
follows the mailbox picker, so a question is about the mailbox on screen unless they say otherwise. If you
need to be sure which one you are in, look at the directory name — it is the address.
+19 -1
View File
@@ -1,4 +1,4 @@
import { eq, and } from 'drizzle-orm';
import { eq, and, inArray } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { emailAccounts } from './schema';
@@ -71,6 +71,24 @@ export async function getAllSyncedAccounts(): Promise<EmailAccount[]> {
return rows.map(open);
}
/**
* Clear a `syncing` or `queued` status left behind by a process that died mid-sync.
*
* `getAllSyncedAccounts` only picks up `status = 'synced'`, so an account stranded at either — the
* resync route sets `syncing` before awaiting and only clears it after; a first sync sets `queued`
* before handing off to the in-process job runner — silently drops out of both the cron and the IDLE
* watcher forever. A fresh process can't have either genuinely in flight (the job runner is in-memory,
* not persisted), so on startup that state only ever means "orphaned by the last restart." `performResync`
* — what the cron and IDLE watcher call — already handles an account with no prior sync state by doing a
* full fetch, so resetting to `synced` rather than replaying the dedicated first-sync job is safe.
*/
export async function resetStuckSyncingAccounts(): Promise<void> {
await db
.update(emailAccounts)
.set({ status: 'synced' })
.where(inArray(emailAccounts.status, ['syncing', 'queued']));
}
/** Replace an account's stored secret — used when an OAuth token is refreshed. */
export async function updateEmailAccountCredentials(id: number, credentials: Record<string, unknown>): Promise<void> {
await db.update(emailAccounts).set({ credentials: seal(credentials) }).where(eq(emailAccounts.id, id));
+3 -1
View File
@@ -9,11 +9,13 @@ import type { PluginManifest } from '@@/plugins/manifest';
// `appName` is not declared: it is the directory name, so the id cannot disagree with where the code sits.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
version: '2026.08.27.01',
platform: '>=1.0.0',
label: 'Email',
summary: 'IMAP and Gmail accounts, synced locally and searchable offline',
/** the single biggest thing people move off Google. */
tags: ['Personal', 'Degoogling'],
icon: 'Mail',
color: '#38bdf8',
+4 -1
View File
@@ -1,5 +1,5 @@
import { getUserById } from 'officerdb';
import { getAllSyncedAccounts } from '../db/queries';
import { getAllSyncedAccounts, resetStuckSyncingAccounts } from '../db/queries';
import { performResync } from './resync';
const INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
@@ -32,6 +32,9 @@ async function tick() {
export function initEmailCron() {
if (timer) return;
resetStuckSyncingAccounts().catch((err) => {
console.error('[email-cron] Failed to reset stuck accounts:', err instanceof Error ? err.message : err);
});
console.log(`[email-cron] Starting email sync cron (every ${INTERVAL_MS / 60_000} min)`);
timer = setInterval(tick, INTERVAL_MS);
setTimeout(tick, 30_000);
+2 -1
View File
@@ -1,5 +1,6 @@
---
name: Email database
name: email-db
title: Email database
description: Use when querying the user's synced email — searching messages, senders, domains, attachments or counts. Carries the SQLite schema and the queries worth knowing, so you do not have to rediscover them.
version: 1
---
+73 -9
View File
@@ -1,11 +1,75 @@
import { ChatPanelWrapper } from 'officerdev';
import { useMemo } from 'react';
import { useSearchParams } from 'react-router';
import { ChatPanelWrapper, ChatHeader } from 'officerdev';
// The prompt belongs to this panel rather than to the workspace, so the panel that gets it is the one that
// mounts it.
//
// `email_db` is an MCP tool resolved at runtime from PI_TOOLS_DIRS. It is NOT in this repository and does
// not travel with this plugin — the platform points it at the account's SQLite store. If that stops
// happening, this prompt is describing a tool the agent does not have, which is worse than no prompt.
const PROMPT_PREFIX = `You are an email assistant. The user has a local SQLite email database available via the "email_db" tool — use it for all email queries (search, count, stats, aggregations, deletions) unless the user explicitly asks you to use Gmail. Do not use the Gmail integration for questions about existing emails.`;
/**
* A chat, sitting under the message you are reading.
*
* ── Why the panel is ours ──
*
* `PluginScreen` renders the workspace with `appTypes={{ allowed, fallback: allowed[0] }}` where `allowed`
* is this plugin's own panel keys, so a layout naming `officerdev/chat` silently renders the fallback
* instead. That is deliberate — a persisted layout must not be able to mount another plugin's panel here.
* So the panel is ours and the chat inside it is the platform's; `ChatPanelWrapper` is exported from
* `officerdev` for exactly this, and carries the composer, transcript, queue, model picker, session
* persistence and the agent identity bar with no second implementation to fall behind the first.
*
* ── What these chats are told, and where it lives ──
*
* `chat/CLAUDE.md`, in this plugin. The platform writes it into the directory these chats run in, and
* Claude Code reads it from its own cwd at session start.
*
* There WAS a `promptPrefix` here and it is gone. A prefix rides every message: paid per turn forever,
* invisible as a design artefact because it lives in a TypeScript constant, and visible in the one place
* it should not be — three words sent, and a paragraph of preamble going out attached to them. The file
* costs nothing per turn and can be opened by the person it is about.
*/
export const EmailChatPanel = () => <ChatPanelWrapper panelId="email-chat" promptPrefix={PROMPT_PREFIX} />;
/**
* Which mailbox this chat is about.
*
* `context: 'email'` resolves server-side to the ACCOUNT's own directory — the one holding `emails.db` —
* rather than to a chats folder. That is the whole point: the agent starts beside the database it is meant
* to answer from, and its brief can say so.
*
* This panel declared no context at all until now, and it did not need to while email was part of the
* platform: `ChatPanelWrapper` recognised the screen by `workspace.id === 'email'` and supplied it. A
* plugin's workspace key is `screens/plugin/email`, so that id became `plugin/email` and the branch
* stopped matching the day email was extracted — every email chat since has run somewhere with no mailbox
* near it. Declaring it here is what that branch's own comment asked for, and it carries the mailbox too,
* which a hardcoded screen id never could.
*
* `all` is the list's sentinel for the unified view and is NOT an address. Sending it would name a mailbox
* that does not exist, so it is dropped here and the server picks the most recently synced account.
*
* Memoised because it is passed to hooks that key on it. An object rebuilt every render is the trap that
* silently broke playback reporting in the Jellyfin player for days; it costs one line to not repeat.
*/
const useEmailChatContext = () => {
const [searchParams] = useSearchParams();
const account = searchParams.get('account');
const mailbox = account && account !== 'all' ? account : undefined;
return useMemo(() => ({ context: 'email', ...(mailbox ? { contextId: mailbox } : {}) }), [mailbox]);
};
export const EmailChatPanel = ({ panelId }: { panelId: string }) => {
const chatContext = useEmailChatContext();
return (
<div className="flex h-full min-h-0 flex-col">
<ChatPanelWrapper panelId={panelId} chatContext={chatContext} />
</div>
);
};
/**
* The panel's header: the conversation picker, scoped to this mailbox's directory.
*
* A registry `header` is mounted with `{ panelId }` only, so the context is bound here rather than passed —
* which is the whole reason this wrapper exists.
*/
export const EmailChatPanelHeader = ({ panelId }: { panelId: string }) => {
const chatContext = useEmailChatContext();
return <ChatHeader panelId={panelId} chatContext={chatContext} />;
};
+10 -2
View File
@@ -2,7 +2,7 @@ import type { AppRegistryMeta } from 'officerdev';
import { Inbox, Mail, MessageSquare } from 'lucide-react';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
import { EmailChatPanel } from './EmailChatPanel';
import { EmailChatPanel, EmailChatPanelHeader } from './EmailChatPanel';
// `panels.ts`, not `.tsx` — discovery checks for that exact name and refuses the plugin by name if it is
// missing, so the JSX lives in EmailChatPanel.tsx and this file stays a declaration.
@@ -13,5 +13,13 @@ import { EmailChatPanel } from './EmailChatPanel';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'email-list', name: 'Mail', icon: Inbox, component: EmailList, availableOnPanel: false },
{ key: 'email-reader', name: 'Message', icon: Mail, component: EmailReader, availableOnPanel: false },
{ key: 'email-chat', name: 'Email chat', icon: MessageSquare, component: EmailChatPanel, availableOnPanel: false },
{
key: 'email-chat',
name: 'Email chat',
icon: MessageSquare,
component: EmailChatPanel,
// The conversation picker, scoped to this mailbox's own directory.
header: EmailChatPanelHeader,
availableOnPanel: false,
},
];
+18 -3
View File
@@ -1,6 +1,6 @@
import { useParams } from 'react-router';
// Which email is open is `/email/:emailId`. The route pair already existed, but the URL was a mirror
// Which email is open is `/email/:id`. The route pair already existed, but the URL was a mirror
// rather than the state: the selection lived in an `EMAIL_SELECTED` global and two effects copied it
// back and forth with the route. That round-trip is what kept a row a `<button>` — nothing to link to,
// no cmd-click, no middle-click — and it meant Back raced the effect that had just written the URL
@@ -9,5 +9,20 @@ import { useParams } from 'react-router';
/** `search` carries the folder you are reading in (`?folder=sent`), which a bare pathname would drop. */
export const emailPath = (id: string, search = '') => `/email/${encodeURIComponent(id)}${search}`;
/** The open email, or null on the bare `/email` route — which is a real state, not one to redirect away. */
export const useSelectedEmailId = (): string | null => useParams<{ emailId?: string }>().emailId ?? null;
/**
* The open email, or null on the bare `/email` route — which is a real state, not one to redirect away.
*
* ── Why it reads `section` ──
*
* This asked for `emailId` while email was part of the platform and had its own `/email/:emailId` route.
* A plugin does not declare its routes: the platform mounts every one at `<route>` and `<route>/:section`,
* so the param arrives under the platform's name whatever the plugin means by it. Asking for `emailId`
* got `undefined` from the day email became a plugin — the reader then had no selection to show and sat on
* "Select an email to read" however many messages you clicked, with the list and the API both working.
*
* `emailId` is still accepted so the hook keeps working if this ever mounts under its own route again.
*/
export const useSelectedEmailId = (): string | null => {
const params = useParams<{ section?: string; emailId?: string }>();
return params.emailId ?? params.section ?? null;
};