Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1c367456b | ||
|
|
bd040a5e21 |
@@ -0,0 +1,216 @@
|
|||||||
|
---
|
||||||
|
name: 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
|
||||||
|
---
|
||||||
|
|
||||||
|
**There is one database per ACCOUNT, and the owner may have several.**
|
||||||
|
|
||||||
|
```
|
||||||
|
$OFFICER_EMAIL_ACCOUNTS_DIR/ one directory per connected address
|
||||||
|
andre.padez@gmail.com/emails.db
|
||||||
|
someone.else@work.com/emails.db
|
||||||
|
attachment_cache/ NOT an account — skip it
|
||||||
|
```
|
||||||
|
|
||||||
|
`$OFFICER_EMAIL_DB` points at exactly **one** of them: the most recently synced. That is the right guess
|
||||||
|
for "what did X say yesterday" and the wrong one for anything that means *all* my mail — you will search
|
||||||
|
one mailbox and report as though you searched every one.
|
||||||
|
|
||||||
|
So: for a question about a specific account, or a quick lookup, use `$OFFICER_EMAIL_DB`. For anything
|
||||||
|
that spans the mailbox, **list `$OFFICER_EMAIL_ACCOUNTS_DIR` first** and query each `emails.db` you find,
|
||||||
|
or `ATTACH` them into one connection. Say which accounts you actually covered.
|
||||||
|
|
||||||
|
Query with the `email_db` tool, or with `sqlite3` directly.
|
||||||
|
|
||||||
|
**Two things measured on a real 19,000-message database (2026-08-20), because the schema alone misleads:**
|
||||||
|
|
||||||
|
- **`read` is almost never populated.** 93 rows of 19,097 had it set. The documented unread query
|
||||||
|
(`WHERE read = 0`) therefore returns essentially the whole mailbox. Do not report an unread count from
|
||||||
|
it without saying what it actually measures.
|
||||||
|
- **`labels` is a comma-separated string, not a set.** Real values include `updates,inbox` and
|
||||||
|
`important,updates,inbox`, so `labels = 'inbox'` misses most of the inbox. Use `LIKE '%inbox%'`, and
|
||||||
|
mind that `promotions,inbox` matches too.
|
||||||
|
|
||||||
|
There is a full-text index (`emails_fts`) over the same rows — prefer it to `LIKE '%...%'` on `text_body`
|
||||||
|
for anything word-shaped, since the table holds ~0.5 GB of bodies.
|
||||||
|
|
||||||
|
# Email Database Schema
|
||||||
|
|
||||||
|
The email database is a SQLite file containing synced emails from the user's connected accounts (Gmail, IMAP, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### emails
|
||||||
|
|
||||||
|
Primary table. One row per email message.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | TEXT PK | Unique message ID (e.g. Gmail message ID) |
|
||||||
|
| `integration` | TEXT | Email provider (`gmail`, `imap`) |
|
||||||
|
| `email_account` | TEXT | Account address this email belongs to |
|
||||||
|
| `from_name` | TEXT | Sender display name |
|
||||||
|
| `from_address` | TEXT | Sender email address |
|
||||||
|
| `from_domain` | TEXT | Sender domain (extracted from address) |
|
||||||
|
| `to_address` | TEXT | Recipient address |
|
||||||
|
| `cc` | TEXT | CC recipients |
|
||||||
|
| `subject` | TEXT | Email subject line |
|
||||||
|
| `date` | TEXT | ISO 8601 date string (`YYYY-MM-DD HH:MM:SS`) |
|
||||||
|
| `snippet` | TEXT | Short preview of body text |
|
||||||
|
| `html` | TEXT | Full HTML body (may be large) |
|
||||||
|
| `text_body` | TEXT | Plain text body |
|
||||||
|
| `attachment_count` | INTEGER | Number of attachments (0 if none) |
|
||||||
|
| `read` | INTEGER | Read status (0 = unread, 1 = read) |
|
||||||
|
| `deleted` | INTEGER | Soft-delete flag (0 = active, 1 = deleted) |
|
||||||
|
| `labels` | TEXT | Comma-separated labels (e.g. `INBOX,IMPORTANT`) |
|
||||||
|
|
||||||
|
**Indexes**: `date`, `from_domain`, `from_address`, `integration`, `email_account`, `labels`.
|
||||||
|
|
||||||
|
### attachments
|
||||||
|
|
||||||
|
One row per email attachment.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `id` | INTEGER PK | Auto-increment ID |
|
||||||
|
| `email_id` | TEXT FK | References `emails(id)`, cascades on delete |
|
||||||
|
| `idx` | INTEGER | Attachment index within the email |
|
||||||
|
| `filename` | TEXT | Original filename |
|
||||||
|
| `size` | INTEGER | Size in bytes |
|
||||||
|
| `content_type` | TEXT | MIME type (e.g. `application/pdf`, `image/png`) |
|
||||||
|
| `content` | TEXT | Base64-encoded file content |
|
||||||
|
|
||||||
|
### sync_meta
|
||||||
|
|
||||||
|
Internal sync state. Generally not useful for querying.
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `key` | TEXT PK | Metadata key |
|
||||||
|
| `value` | TEXT | Metadata value |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Queries
|
||||||
|
|
||||||
|
### Recent emails
|
||||||
|
```sql
|
||||||
|
SELECT id, date, from_name, from_address, subject, snippet
|
||||||
|
FROM emails
|
||||||
|
WHERE deleted = 0
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search by subject or body
|
||||||
|
```sql
|
||||||
|
SELECT id, date, from_address, subject
|
||||||
|
FROM emails
|
||||||
|
WHERE deleted = 0
|
||||||
|
AND (subject LIKE '%keyword%' OR text_body LIKE '%keyword%')
|
||||||
|
ORDER BY date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emails from a specific sender
|
||||||
|
```sql
|
||||||
|
SELECT id, date, subject, snippet
|
||||||
|
FROM emails
|
||||||
|
WHERE from_address = 'sender@example.com' AND deleted = 0
|
||||||
|
ORDER BY date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emails from a domain
|
||||||
|
```sql
|
||||||
|
SELECT id, date, from_address, subject
|
||||||
|
FROM emails
|
||||||
|
WHERE from_domain = 'example.com' AND deleted = 0
|
||||||
|
ORDER BY date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Top senders
|
||||||
|
```sql
|
||||||
|
SELECT from_address, from_name, count(*) AS total
|
||||||
|
FROM emails
|
||||||
|
WHERE deleted = 0
|
||||||
|
GROUP BY from_address
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Top domains
|
||||||
|
```sql
|
||||||
|
SELECT from_domain, count(*) AS total
|
||||||
|
FROM emails
|
||||||
|
WHERE deleted = 0
|
||||||
|
GROUP BY from_domain
|
||||||
|
ORDER BY total DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unread count
|
||||||
|
```sql
|
||||||
|
SELECT count(*) AS unread FROM emails WHERE read = 0 AND deleted = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emails with attachments
|
||||||
|
```sql
|
||||||
|
SELECT e.id, e.date, e.from_address, e.subject, e.attachment_count
|
||||||
|
FROM emails e
|
||||||
|
WHERE e.attachment_count > 0 AND e.deleted = 0
|
||||||
|
ORDER BY e.date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### List attachments for an email
|
||||||
|
```sql
|
||||||
|
SELECT a.filename, a.size, a.content_type
|
||||||
|
FROM attachments a
|
||||||
|
WHERE a.email_id = 'MESSAGE_ID';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Find attachments by type
|
||||||
|
```sql
|
||||||
|
SELECT e.id, e.date, e.subject, a.filename, a.size, a.content_type
|
||||||
|
FROM attachments a
|
||||||
|
JOIN emails e ON e.id = a.email_id
|
||||||
|
WHERE a.content_type LIKE 'application/pdf%'
|
||||||
|
ORDER BY e.date DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Email volume by month
|
||||||
|
```sql
|
||||||
|
SELECT strftime('%Y-%m', date) AS month, count(*) AS total
|
||||||
|
FROM emails
|
||||||
|
WHERE deleted = 0
|
||||||
|
GROUP BY month
|
||||||
|
ORDER BY month DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emails by label
|
||||||
|
```sql
|
||||||
|
SELECT id, date, from_address, subject
|
||||||
|
FROM emails
|
||||||
|
WHERE labels LIKE '%INBOX%' AND deleted = 0
|
||||||
|
ORDER BY date DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full email content
|
||||||
|
```sql
|
||||||
|
SELECT id, date, from_name, from_address, to_address, cc, subject, text_body, html, labels
|
||||||
|
FROM emails
|
||||||
|
WHERE id = 'MESSAGE_ID';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Soft deletes**: Always filter `deleted = 0` unless you want deleted emails.
|
||||||
|
- **Labels**: Stored as comma-separated text. Use `LIKE '%LABEL%'` to filter.
|
||||||
|
- **Date sorting**: The `date` column is TEXT in ISO format, so lexicographic sorting works correctly.
|
||||||
|
- **Large content**: `html` and `text_body` can be very large. Use `snippet` for previews, only select full body when needed.
|
||||||
|
- **Attachment content**: The `content` column in `attachments` is base64-encoded. It can be very large — avoid selecting it unless you need the actual file data.
|
||||||
|
- **SQL reference**: For full SQLite syntax, functions, and operators, refer to the **sqlite** tool documentation.
|
||||||
+60
-4
@@ -63,6 +63,17 @@ export const EmailList = () => {
|
|||||||
// with a message open should not close it.
|
// with a message open should not close it.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const folder = searchParams.get('folder') ?? 'inbox';
|
const folder = searchParams.get('folder') ?? 'inbox';
|
||||||
|
/*
|
||||||
|
WHICH MAILBOX, on the same reasoning as the folder above: addressable state, so a query param rather
|
||||||
|
than component state. Reload, share the URL or cmd-click a mailbox and you land where you meant to.
|
||||||
|
|
||||||
|
Absent means `all` HERE and only here. The routes themselves treat absent as "first enabled", which
|
||||||
|
`mail-scope.ts` keeps deliberately so the mobile app and the agent's email_db tool — neither of which
|
||||||
|
knows accounts exist — carry on working. That default is right for a caller that cannot choose and
|
||||||
|
wrong for a person looking at a screen: someone with three accounts opening their mail wants all of
|
||||||
|
it, not whichever one happens to sort first.
|
||||||
|
*/
|
||||||
|
const account = searchParams.get('account') ?? 'all';
|
||||||
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -76,7 +87,7 @@ export const EmailList = () => {
|
|||||||
// cannot any more, because the folder pills are links and nobody handles their click.
|
// cannot any more, because the folder pills are links and nobody handles their click.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [debouncedSearch, folder]);
|
}, [debouncedSearch, folder, account]);
|
||||||
|
|
||||||
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
const { data: emailAccounts = [], refetch: refetchAccounts } = useQuery({
|
||||||
queryKey: ['email-accounts'],
|
queryKey: ['email-accounts'],
|
||||||
@@ -87,14 +98,18 @@ export const EmailList = () => {
|
|||||||
const hasAccounts = emailAccounts.length > 0;
|
const hasAccounts = emailAccounts.length > 0;
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
|
// `account` is in the key: switching mailbox has to refetch, and without it react-query would serve
|
||||||
|
// the previous mailbox's page from cache under the new selection.
|
||||||
|
queryKey: isSearching
|
||||||
|
? ['email-search', debouncedSearch, page, account]
|
||||||
|
: ['email-messages', page, folder, account],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
isSearching
|
isSearching
|
||||||
? client.get<{ messages: EmailSummary[]; total: number }>(
|
? client.get<{ messages: EmailSummary[]; total: number }>(
|
||||||
`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`,
|
`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}&account=${account}`,
|
||||||
)
|
)
|
||||||
: client.get<{ messages: EmailSummary[]; total: number }>(
|
: client.get<{ messages: EmailSummary[]; total: number }>(
|
||||||
`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`,
|
`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}&account=${account}`,
|
||||||
),
|
),
|
||||||
// Keep the previous results visible while the next query loads, so switching search terms/pages
|
// Keep the previous results visible while the next query loads, so switching search terms/pages
|
||||||
// never bails to the full-panel loading view (which would unmount the search input and drop focus).
|
// never bails to the full-panel loading view (which would unmount the search input and drop focus).
|
||||||
@@ -271,6 +286,47 @@ export const EmailList = () => {
|
|||||||
<AccountsModal />
|
<AccountsModal />
|
||||||
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
<div className="flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/*
|
||||||
|
THE MAILBOX PICKER, and only when there is a choice to make.
|
||||||
|
|
||||||
|
One account and this is a control that can say exactly one thing, taking space in a toolbar
|
||||||
|
that is already tight on a phone. It appears when a second account arrives and disappears if
|
||||||
|
one is removed.
|
||||||
|
|
||||||
|
Real links, like the folder pills beside it: built from the LIVE params so the folder and any
|
||||||
|
open email survive the switch, and so cmd-click, middle-click and Back all behave. See
|
||||||
|
platform docs/navigation-audit.md — selection state is a URL, never a channel.
|
||||||
|
*/}
|
||||||
|
{emailAccounts.length > 1 ? (
|
||||||
|
<div className="flex items-center gap-1 border-r border-border pr-1 mr-1">
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: 'All mail', title: 'Every account' },
|
||||||
|
...emailAccounts.map((a) => ({
|
||||||
|
id: String(a.id),
|
||||||
|
// The address distinguishes two mailboxes; a display name often does not.
|
||||||
|
label: a.email.split('@')[0] ?? a.email,
|
||||||
|
title: a.email,
|
||||||
|
})),
|
||||||
|
].map((choice) => {
|
||||||
|
const isActive = account === choice.id;
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
next.set('account', choice.id);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={choice.id}
|
||||||
|
to={{ search: `?${next}` }}
|
||||||
|
className={`max-w-[7rem] truncate rounded px-2 py-1 text-xs transition-colors cursor-pointer ${
|
||||||
|
isActive ? 'bg-accent font-medium' : 'opacity-60 hover:opacity-100 hover:bg-accent/50'
|
||||||
|
}`}
|
||||||
|
title={choice.title}
|
||||||
|
aria-current={isActive ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{FOLDERS.map((f) => {
|
{FOLDERS.map((f) => {
|
||||||
const Icon = f.icon;
|
const Icon = f.icon;
|
||||||
const isActive = folder === f.key;
|
const isActive = folder === f.key;
|
||||||
|
|||||||
Reference in New Issue
Block a user