Repository navigation
Plugin Queries
A report is how a plugin reads more than one message: mail volume, category breakdowns, conversation lists, contact statistics. The plugin asks TideMail structured, read-only queries; TideMail validates them, runs them against its own cache, and hands back sanitized results.
Query permissions allow TideMail to disclose selected structured data to a plugin. They do not grant direct database access.
A plugin never gets a database path, a database handle, SQL, message bodies, HTML, raw headers, attachments, or any way to change mail. Every method, field, scope, filter, and limit is on a fixed allowlist.
For the statistics queries (analytics.*) see analytics.md.
A complete, tested example is
examples/plugins/analytics/.
capabilities = ["report.run"]
[permissions]
analytics_read = true # only what you need; see the table belowreport.run makes the plugin runnable as a report: in TideMail, open
Plugins (experimental) from the command palette, select the plugin, and
press enter. The final report appears in the plugin result window.
The capability grants no data by itself. Each query needs its permission:
| Query | Permission |
|---|---|
query.messages |
messages_query |
query.threads |
threads_query |
query.annotations |
annotations_query |
query.classification |
annotations_query and analytics_read
|
analytics.* |
analytics_read |
No permission implies another: analytics_read never returns message
records, and messages_query never returns annotations.
The process model does not change: every call is still one process, one JSON request on stdin, one JSON response on stdout, exit. A report is a short series of such calls, called rounds.
round 1 TideMail → report.run {round: 1, context}
plugin → {queries: {…}, state: …}
TideMail validates and runs the queries
round 2 TideMail → report.run {round: 2, context, state, results: {…}}
plugin → {queries: {…}, state: …} ask again (next page, follow-up)
… or → {report: …} done
Each round is a fresh process with the usual 5 second timeout, trimmed
environment, and output limits (security.md). Nothing is
bidirectional and nothing stays running. Carry anything you need between
rounds in state: TideMail echoes it back verbatim and never reads it.
{
"api": 1,
"type": "request",
"request_id": "5c1e0b9a6f2d7e3a4b8c9d01",
"method": "report.run",
"data": {
"round": 2,
"context": {
"account_name": "Work",
"mailbox_name": "INBOX",
"now": "2026-09-27T12:00:00-05:00",
"timezone": "America/Chicago"
},
"state": {"page": 1},
"results": {
"volume": {"group_by": "day", "received": 428, "sent": 73, "buckets": []}
}
}
}| Field | Notes |
|---|---|
round |
1 for the first call. |
context |
Where the report was started: the folder or account selected in TideMail's sidebar (either may be absent), the report's fixed clock now, the local timezone, and views, the structured-view version TideMail draws (absent on builds without views). The same in every round. |
state |
Whatever your previous response put in state; absent in round 1. |
results |
Your previous round's queries' results, under the names you gave them; absent in round 1. |
Either ask:
{"queries": {"volume": {"method": "analytics.volume", "range": "30d"}}, "state": {"page": 1}}or finish:
{"report": "Last 30 days: 428 received, 73 sent"}or finish with a structured view that TideMail draws in the user's theme (tiles, sparklines, bars, heatmaps, tables; see views.md):
{"view": {"title": "Mail", "blocks": [{"type": "stats", "items": [{"label": "Received", "value": "428"}]}]}}- Exactly one of
queries,report, andview; none, or more than one, fails the report. - Query names are yours: 1 to 32 characters of
a-z,0-9,_,.,-. -
reportis display-only. A string is shown line by line as is; an object or array is shown as a readable key/value tree. TideMail strips control and escape characters: plugins cannot draw terminal UI or choose colors. For color and charts, return aview; TideMail picks the colors.
default_limit = 100
max_limit = 500
max_message_ids = 500
max_contacts = 50
max_rounds = 8
max_queries = 8
max_state_bytes = 65536
max_results_bytes = 4194304
report_timeout_s = 30
max_volume_buckets = 2000
- At most 8 rounds and 8 queries per round; a report still asking for queries in round 8 fails.
-
stateat most 64 KiB; one round's results at most 4 MiB of JSON. - The whole report must finish within 30 seconds.
- Invalid or unpermitted queries fail the whole round before any of them runs, with an error naming the query: fail closed, nothing disclosed.
query.messages
query.threads
query.annotations
query.classification
analytics.volume
analytics.categories
analytics.attention
analytics.response_times
analytics.contacts
Every query is an object with method and that method's parameters. A
parameter the method does not take, an unknown field, or an unknown value is
an invalid_query error: typos never silently widen a query.
{
"method": "query.messages",
"scope": "inbox",
"fields": ["id", "sender", "subject", "date", "read"],
"limit": 100,
"sort": "date_desc",
"cursor": "",
"filters": {"read": false, "date_from": "2026-09-01T00:00:00Z"}
}Result:
{
"messages": [
{"id": 1234, "sender": "Ann <ann@example.com>", "subject": "Invoice", "date": "2026-09-26T10:00:00-05:00", "read": false}
],
"next_cursor": "eyJkIjoxNzkwNTQy…"
}fields (required) lists exactly what you want back; nothing else is
returned. The allowlist is the metadata message.metadata already sends,
never bodies:
id
message_id
sender
recipients
cc
reply_to
subject
date
read
starred
has_attachment
account_name
mailbox_name
flags
sender, recipients, cc, and reply_to are header display strings
(Name <addr>, comma-separated). date is RFC 3339 in the report's timezone.
id is TideMail's local row number: use it with query.annotations and
query.classification, but it is not stable across cache resets.
scope (required) selects folders:
all_cached
current_account
current_mailbox
inbox
sent
| Scope | Covers |
|---|---|
inbox |
every account's inbox |
sent |
every Sent folder |
all_cached |
every cached folder, including Trash and Junk |
current_account |
the account selected when the report started |
current_mailbox |
the folder selected when the report started |
The current_* scopes fail with scope_unavailable when the report was not
started from an account or folder. A message copied into several folders is
listed once per copy.
filters (optional): read, starred, has_attachment (booleans),
account, mailbox (names, case-insensitive), date_from, date_to
(RFC 3339; [date_from, date_to)). There are no subject or sender filters
and no expressions.
sort: date_desc (default) or date_asc. limit: 1 to 500,
default 100.
A page with more after it carries next_cursor. Pass it back as cursor in
an otherwise identical query (usually in the next round, remembering where
you are in state); the last page has no next_cursor.
Cursors are opaque keyset positions, not offsets: messages arriving between
rounds do not shift pages. A cursor is bound to its query's scope, filters,
sort, and context; reusing it with a different query fails with
invalid_cursor.
{"method": "query.threads", "scope": "all_cached", "fields": ["thread_id", "participants", "message_count", "latest_date", "waiting_on_them"], "limit": 50}Result: {"threads": [...], "next_cursor": "…"}, newest conversation first.
thread_id
participants
message_count
latest_sender
latest_subject
latest_date
latest_from_me
needs_you
waiting_on_them
- Conversations come from TideMail's own threading (Message-ID, In-Reply-To,
References), the same as the message list, over the messages in
scope: withinbox, your own replies in Sent are not part of the thread; useall_cachedfor whole conversations. Unrelated messages with the same subject stay separate. - Copies of one message in several folders count once.
-
thread_idis an opaque hash for matching threads within one report. It can change when the conversation's cached messages change, so do not store it as a permanent key. -
participants:[{"name": "Ann", "address": "ann@example.com"}], at most 50. -
needs_youandwaiting_on_themare TideMail's current local state, the same as its Needs You and Waiting on Them views. No plugin or online service is asked. - Filters:
account,mailbox,date_from,date_to.limitandcursorwork as for messages; the order is always newest first.
Stored plugin annotations: what plugins said, including other plugins.
{"method": "query.annotations", "message_ids": [1234, 1235], "plugin_ids": ["smart"], "keys": ["category"]}{"annotations": [{"message_id": 1234, "plugin_id": "smart", "key": "category", "value": "billing", "confidence": 0.9}]}message_ids (1 to 500) is required; plugin_ids and keys (at most 32
each) narrow the result. Unknown IDs are skipped. User corrections never
appear here.
TideMail's effective classification: plugin annotations with the user's corrections applied, which is what TideMail itself shows and acts on. Each field says where it came from.
{"method": "query.classification", "message_ids": [1234]}{"classifications": [{
"message_id": 1234,
"needs_reply": {"value": true, "source": "plugin"},
"urgent": {"value": false, "source": "none"},
"important": {"value": false, "source": "none"},
"category": {"value": "personal", "source": "user"}
}]}source is plugin, user (a correction the user made), or none. Only
the current state is available, never a history of corrections. Because it
reveals the user's own decisions, it needs both annotations_query and
analytics_read.
A failed query ends the report, and TideMail shows query "name": code: message on its status line.
| Code | Meaning |
|---|---|
invalid_query |
Unknown method, parameter, field, scope, filter, or value, or a limit out of range. |
permission_denied |
The manifest does not declare the query's permission. |
scope_unavailable |
current_account or current_mailbox without a selected account or folder. |
invalid_cursor |
The cursor is not from an earlier page of this same query. |
query_failed |
TideMail could not read its cache. |
- Only header metadata ever leaves TideMail: never bodies, HTML, raw headers, summaries, attachments, OAuth tokens, passwords, API keys, file paths, or other plugins' settings or secrets.
- Strings are stripped of control, format (bidi, zero-width), and separator characters before they reach a plugin.
- Prefer
analytics.*: they return counts, not records, and need onlyanalytics_read. - Everything is read-only. No query can mark, star, move, delete, archive, send, edit, snooze, or correct anything.
tidemail plugin test runs a full report for plugins that declare
report.run, against a synthetic mailbox in a throwaway database: never your
real mail. Use --report-fixture FILE to supply your own mailbox. See
developer-tools.md.