Example
Imagine Alice sends this email to a mailing list:
From: alice@email.com
Subject: [BUG] Wrong output
To: project-mailing-list@lists.org
Date: Fri, 6 Mar 2026 16:31:14 +0000
Hi all,
when doing X, I get the wrong output, probably a bug.
Then Bob replies:
From: bob@email.com
Subject: Re: [BUG] Wrong output
To: alice@email.com
Cc: project-mailing-list@lists.org
Date: Fri, 6 Mar 2026 16:38:42 +0000
> when doing X, I get the wrong output, probably a bug.
Confirmed, I see this too, this should not happen.
And Eve, the maintainer, concludes:
From: eve@email.com
Subject: Re: [BUG] Wrong output
To: project-mailing-list@lists.org
Date: Fri, 6 Mar 2026 16:42:03 +0000
>> when doing X, I get the wrong output, probably a bug.
> Confirmed, I see this too, this should not happen.
Fixed in commit bc12c0c, thanks to both of you!
In this exchange:
- Alice reported a bug.
- Bob confirmed it.
- Eve closed it.
All three saw the same emails: the mailing list is the conversation. BONE indexes it so the same information becomes queryable as a database: reports, states, authors, threads.
As a user
Bug, Patch, Request
The subject label determines a report's type. The three main labels are [BUG], [PATCH] and [FR/TODO/POLL] for feature requests; see report types for the full list.
BONE creates a patch report in two cases:
- The subject starts with
[PATCH], either directly or as Re: [PATCH] ... when the reply carries a .patch or .diff attachment (the v2/v3 reply workflow).
- The email attaches a
git format-patch file whose inner Subject: header starts with [PATCH].
A bare inline diff, a git diff > x.patch attached in a reply without re-labelling, or a quoted diff in a discussion stay regular replies. To track such a fix as a patch, attach a real git format-patch file or relabel the subject with [PATCH].
Attaching a patch to a [BUG] or [FR] email leaves the report's type unchanged; the patch is stored as metadata on the parent.
Updating reports
Updating reports is done via a BONE command, a case-sensitive keyword at the start of a line. For example, a reply to a bug starting with Confirmed. marks it acked; a reply to a patch starting with Applied. or Fixed. marks it closed; a reply to a request starting with Canceled marks it canceled.
BONE commands are followed by punctuation (. , ; : ? !), whitespace, or end of line. As an exception, Urgent and Important are stricter: a whitespace is not enough, they require punctuation or end of line, because the same words appear too often in ordinary prose.
There are two kinds of commands: triggers, which change the report state and annotations, which update their properties. Triggers never apply to the email that carries them, only to the closest parent report: for example, a Acked command in a new bug report does not confirm the bug. Annotations apply to the closest parent report too but can also apply to the report the email creates: for example, adding Urgent. in a new bug report increases its priority.
See the complete list of commands for the full registry.
Retracting your own updates
To retract a state you previously set with Acked., Owned., Closed., Urgent. or Important., reply with the matching "Not" command:
Not acked.
Not owned.
Not closed.
Not urgent.
Not important.
Each retraction applies only to your own earlier action. Maintainers have broader retraction rights, described in Undoing updates.
Setting a topic
A topic is a short string attached to a report. Set it from the subject in two ways:
- Inside the label:
[BUG parser] sets the topic to "parser".
- As a colon prefix after the label:
[BUG] parser: crash on empty input also sets the topic to "parser" -- the segment before the first colon after the closing bracket.
To change the topic on an existing report, reply with:
Topic: new-topic.
To remove it, reply with No topic..
Setting a deadline
To set a deadline on a report, reply with:
Deadline: 2026-03-10.
The value is an ISO date (yyyy-MM-dd) or a duration (2d, 3w, 1m, 1y) resolved relative to today. A trailing punctuation mark is optional. Deadlines appear in JSON exports, in Org output as a DEADLINE: line, and in notification emails.
To remove a deadline, reply with No deadline..
Setting an expiry date
A per-report expiry date schedules the automatic closing of a report.
To set an expiry, reply with one of:
Expiry: 2026-06-15.
Expiry: 30d.
Like Deadline:, the value is an ISO date or a duration. The expiry appears in JSON exports under :expiry, in Org output as an :EXPIRY: property, and in the HTML index as a tooltip on the date column.
To expire reports at their deadline, use :inactive-after :deadline in the source-level expiry rules (see report expiry).
To remove a per-report expiry, reply with No expiry..
Superseding a report
One report can replace another: a newer patch version, or a fresh bug report that captures the real issue.
From the old report's thread, you can write:
Superseded-by: <new-message-id@example.com>
From the new report's thread, you can write (in a reply, or in the opening mail of a new bug, patch or request):
Supersedes: <old-message-id@example.com>
A message-id takes one of three forms: <id@host>, the bare id@host, or the last path segment of an archive URL such as https://list.orgmode.org/orgmode/id@host/. Both reports must be of the same type: bug to bug, patch to patch, request to request.
To undo, reply with Not superseded-by: <new-message-id@example.com> from the closed report's thread, or with Not supersedes: <old-message-id@example.com> from the replacement's thread.
Supersession of patches
When a new patch report appears in a thread whose earlier open patch report shares the same base subject (ignoring Re: or Fwd: prefixes and [TAG] brackets), the new report automatically supersedes the previous one. This covers re-sent patches without explicit version numbers.
For example:
[PATCH] A new feature : patch report (1)
Re: [PATCH] A new feature : patch report (2), supersedes (1)
Re: [PATCH] A new feature : patch report (3), supersedes (2)
Only the most recent patch (3) remains open.
The reply must itself qualify as a patch report -- either a Re: [PATCH] subject with an attached patch, or a git format-patch attachment. A plain Re: without a patch carries on as ordinary discussion.
Patches with an explicit version number ([PATCH v2]) follow a separate mechanism that matches on version and topic. See Patch series.
Resolving a bug or request with a patch
Replying to an open bug or request with a .patch or .diff attachment credits you as Acked and Owned on that parent, if the nearest parent is a bug or a request. (When you are the parent's own reporter, only Owned is credited, as you cannot Ack a report you sent.)
Closing that patch with Applied. or Fixed. then propagates the closure to the parent, with reason :resolved. Cancelling the patch retracts the implicit credit. Superseding it transfers Owned to the new author; Acked is historical and stays with whoever first confirmed the parent.
Marking a report as a duplicate
To mark a report as a duplicate of another, reply with:
Duplicate-of: <target-message-id@example.com>
Message-id forms and type constraints match Superseded-by: (see above). BONE closes the duplicate with reason :canceled and links it to the target. To undo, reply with Not duplicate-of: <target-message-id@example.com>.
Cross-referencing reports
To cross-reference another report, reply with:
Related-to: <other-message-id@example.com>
This annotation accepts any pair of reports, including closed ones, and uses the same message-id forms as Superseded-by:. Several Related-to: lines in one email post several links. To undo, reply with Not related-to: <other-message-id@example.com>.
Voting on requests
Requests ([POLL], [TODO] and their aliases) accept votes. Reply to the thread with one of:
+1 or 1+ -- a positive vote.
-1 or 1- -- a negative vote.
+0, 0+, -0 or 0- -- an abstention.
Each voter is counted once per report; the first vote wins.
As a maintainer
A maintainer acts on a source through the same email channel as a regular user, but with broader permissions and a few additional report types. The sections below describe what is specific to that role.
Announcements, releases, changes
A maintainer can post general announcements ([ANN]), share a release ([REL]), or announce an upcoming change ([CHG]) for a future release.
When a [REL] report carries a version in its subject tag ([REL v2.0]), it closes every open [CHG v2.0] report with reason :resolved and cross-links them to the release.
Events (ICS calendar support)
An announcement can carry a calendar event. When an [ANN] report contains an attached .ics file or inline VCALENDAR content, BONE stores the calendar data alongside the report. During export, such announcements feed dedicated event feeds:
- Individual
.ics files in events/<mid-hash>/.
- Combined ICS calendars:
events/announcements.ics -- all announcements with events.
events/announcements-open.ics -- open only.
events/announcements-closed.ics -- closed only.
events.json, events.org, events.xml -- open announcements with ICS content; events-closed.* list closed ones.
Use [ANN event] to set the topic to "event" for easier filtering, but any announcement with ICS content is included regardless of topic.
Acting as an update proxy
A maintainer can update a report on behalf of another user by writing directly to the BONE inbox with a -by line:
Acked-by: a-user@example.com
Owned-by: a-user@example.com
Closed-by: a-user@example.com
The Name <addr> form is also accepted:
Owned-by: A User <a-user@example.com>
Managing roles and permissions
Permissions follow three rules:
- Each source has exactly one lead maintainer.
- Any maintainer can promote another user to maintainer.
- Only the lead maintainer can demote a maintainer back to user.
To change roles, write to the BONE inbox:
Add maintainer: dev@example.com (any maintainer)
Remove maintainer: dev@example.com (lead maintainer only)
BONE also accepts the Display Name <addr> form:
Add maintainer: Dev User <dev@example.com>
State-changing actions (commands and role controls) must be sent through the source's public channel -- the mailing list or alias. Every instance of BONE running on the same source then sees the same state.
Undoing updates
A maintainer can undo any update with the same "Not" or "No" commands available to regular users:
Not acked.
Not owned.
Not closed.
Not urgent.
Not important.
No deadline.
No expiry.
No topic.
Not superseded-by: <new-message-id@example.com>
Not supersedes: <old-message-id@example.com>
Not duplicate-of: <target-message-id@example.com>
Not related-to: <other-message-id@example.com>
A regular user can retract only their own actions (see Retracting your own updates); a maintainer applies the same commands without that restriction.
Report types
BONE detects a report's type from its subject label:
| Tag | Type |
[BUG <topic>] | bug |
[PATCH <topic> <version> <n/m>] | patch |
[POLL <topic>] or [TODO <topic>] | request |
[ANN <topic>] | announcement |
[REL <topic> <version>] | release |
[CHG <topic> <version>] | change |
BONE also accepts the long-form aliases [ANNOUNCEMENT], [RELEASE] and [CHANGE]. Labels are case-sensitive and require whitespace (or end of subject) after the closing ]. BONE ignores malformed labels.
By default, posting an announcement, release or change requires maintainer permission. The set of restricted types is configurable per source through :restricted-types (see Source options).
All <…> parts are optional and positional. For [REL] and [CHG], a single value is read as the <version>: [REL 2.0] sets the version to "2.0". Pass both for topic and version: [REL parser 2.0]. For [PATCH], <version> is a v\d+ token (e.g. v2), and <n/m> is a sequence marker (e.g. 3/5).
Patch series
A patch series groups patches sharing a sequence marker (<n/m>). A cover letter (0/m) or a fresh 1/m patch from the same sender and topic automatically closes the previous series and starts a new one. This is how a version bump supersedes the previous run: [PATCH parser v2 0/3] closes the earlier v1 series.
Cover letter commands
When someone replies to the cover letter [PATCH 0/m] of a patch series, BONE applies the command to every patch in the series, not just to the cover. So Closed. closes the whole series and Deadline: 30d sets the deadline on every patch.
The exceptions are Supersedes: and Related-to: (with their Not variants), which name a specific target and stay on the cover only.
Customising subject labels
The labels above are the built-in defaults. Override them globally or per source in config.edn through :labels. Only the types you list are affected; the others keep their defaults.
;; Global override (applies to all sources)
:labels {:bug ["BUG" "DEFECT"]
:request ["POLL" "FR" "TODO" "RFE"]}
;; Per-source override (inside a source map)
{:name "my-list"
:labels {:announcement ["ANN" "NEWS"]}}
A per-source setting wins over the global :labels, which in turn wins over the built-in defaults.
Commands
A command is a keyword at the start of a line in a thread reply.
Triggers and annotations
BONE distinguishes two kinds of commands:
- A trigger changes a report's state -- open/closed, acked, owned, superseded, duplicate. Triggers never apply to the mail that carries them, even when that mail opens a new thread. Examples:
Closed., Acked., Owned-by:, Superseded-by:, Duplicate-of:.
- An annotation sets or unsets a property -- urgency, importance, topic, deadline, expiry, or a non-closing relation. When the carrying mail itself becomes a new report, the annotation applies to it; otherwise it targets the nearest report in the thread. Examples:
Urgent., Important., Topic:, Deadline:, Supersedes:, Related-to:.
Two annotations are stricter on punctuation: Urgent and Important require punctuation or end of line, never whitespace (see priority commands).
Colon-prefixed commands -- Topic:, Deadline:, Expiry:, Acked-by:, Owned-by:, Closed-by:, Superseded-by:, Supersedes:, Duplicate-of:, Related-to: -- require at least one whitespace character between the colon and the value. Topic: parser is recognised; Topic:parser is silently ignored.
Status commands
| Command keyword | Effect on report |
Acked Confirmed Approved | acked |
Owned | owned |
Canceled Cancelled | closed (canceled) |
Expired | closed (expired) |
Resolved Applied Fixed Closed Completed | closed (resolved) |
A report carries a combination of three states:
:acked
- someone took the next sensible action -- confirmed a bug, reviewed a patch, approved a request.
:owned
- someone claimed ownership of the report.
:closed
- the report is resolved, canceled, or expired.
The numeric form combines these three bits:
| (un)acked | (0)1 |
| (un)owned | (0)2 |
| (un)closed | (4)0 |
An acked, unowned and open report therefore has status 1+0+4 = 5.
Only bugs, patches and requests carry acked or owned. Announcements, releases and changes can still be marked urgent or important, and can still be closed.
Priority commands
| Command keyword | Effect on report |
Urgent. | Mark as urgent |
Important. | Mark as important |
Urgent and Important require punctuation (. , ; : ? !) or end of line. A bare space is rejected: words like "Important" appear too often in ordinary prose to be safe bareword annotations.
These two values combine into the numeric priority:
| (un)important | (0)1 |
| (un)urgent | (0)2 |
A report flagged both important and urgent has priority 1+2 = 3.
Multiple commands
You can use several keywords in the same email, one per line.
From: x@y.z
Date: Thu, 19 Mar 2026 16:45:00 +0100
Subject: Re: [BUG] Crash on startup
> Crash on startup with the new build.
Confirmed.
Urgent.
Important.
Propagation
Some updates ripple beyond the report they were sent to. BONE propagates them through the threading and series graphs:
All commands
A command is a keyword sent in a public reply, by a user or a maintainer. In the "Permission" column, "setter or maintainer" denotes the user who previously set the attribute; a maintainer retains a full override in every case.
Triggers act on existing reports only. Annotations also apply to the carrying mail when that mail itself becomes a new report.
The six patterns
Here are the six possible command patterns:
| Shape | Meaning | Example |
X. | set a state | Acked., Closed., Urgent. |
Not X. | retract a state | Not acked., Not urgent. |
X: <value> | set an annotation | Topic: parser, Deadline: 30d |
No X. | clear an annotation | No deadline., No topic. |
X-by: <addr> | maintainer acts on behalf of X | Acked-by: a@x, Closed-by: a@x |
X: <mid> / Not X: <mid> | pose or retract a relation | Superseded-by: <m@x>, Related-to: <m@x> |
The "Not" form negates a state; the "No" form removes a property (deadline, expiry, topic).
Triggers
Listed in the order they typically appear in a report's life: a contributor acks it, then someone owns it, then it is closed. Vote and relation triggers come last.
| ID | Syntax / Words | Permission | Report types |
:acked | Acked, Confirmed, Approved | any user | bug, patch, request |
:acked-by | Acked-by: addr@host | maintainer | bug, patch, request |
:unacked | Not acked | setter or maintainer | bug, patch, request |
:owned | Owned | any user | bug, patch, request |
:owned-by | Owned-by: addr@host | maintainer | bug, patch, request |
:unowned | Not owned | setter or maintainer | bug, patch, request |
:closed (resolved) | Closed, Resolved, Applied, Fixed, Completed | any user | all |
:closed (canceled) | Canceled, Cancelled | any user | all |
:closed (expired) | Expired | any user | all |
:closed-by | Closed-by: addr@host | maintainer | all |
:unclosed | Not closed | setter or maintainer | all |
| vote up | +1 or 1+ | any user (once) | request |
| vote down | -1 or 1- | any user (once) | request |
| vote null | +0 / 0+ / -0 / 0- | any user (once) | request |
:superseded-by | Superseded-by: <msg-id> | any user | all |
:unsuperseded-by | Not superseded-by: <msg-id> | setter or maintainer | all |
:duplicate-of | Duplicate-of: <msg-id> | any user | all |
:unduplicate-of | Not duplicate-of: <msg-id> | setter or maintainer | all |
Annotations
Grouped by what they describe: priority, classification, scheduling, and inter-report links.
Priority
| ID | Syntax / Words | Permission | Report types |
:urgent | Urgent. | any user | all |
:unurgent | Not urgent | setter or maintainer | all |
:important | Important. | any user | all |
:unimportant | Not important | setter or maintainer | all |
Classification
| ID | Syntax / Words | Permission | Report types |
:topic | Topic: word | any user | all |
:untopic | No topic | setter or maintainer | all |
Scheduling
| ID | Syntax / Words | Permission | Report types |
:deadline | Deadline: 2025-03-19 or Deadline: 30d | any user | bug, patch, request |
:undeadline | No deadline | setter or maintainer | bug, patch, request |
:expiry | Expiry: 2025-06-15 or Expiry: 30d | any user | bug, patch, request |
:unexpiry | No expiry | setter or maintainer | bug, patch, request |
Relations
| ID | Syntax / Words | Permission | Report types |
:supersedes | Supersedes: <msg-id> | any user | all |
:unsupersedes | Not supersedes: <msg-id> | setter or maintainer | all |
:related-to | Related-to: <msg-id> | any user | all |
:unrelated-to | Not related-to: <msg-id> | any user | all |
:supersedes and :related-to are the only relations that stay on the cover when sent through a patch-series cover letter -- see Cover letter commands.
All controls
A control manages a role. Role controls must be sent through the source's public channel.
| ID | Syntax / Words | Permission | Channel |
| role | Add maintainer: addr@host | maintainer | public |
| role | Remove maintainer: addr@host | lead maintainer | public |
Exploring reports
BONE exports its data as HTML pages and as JSON, Org and RSS feeds. This chapter describes the exported files and the companion tools that consume them.
Exported pages and feeds
Running bb export all generates, for each source:
- HTML pages:
index.html (reports table), stats.html (charts), docs.html (user docs).
- Full exports:
all.json, all.org, all.xml.
- Per-type feeds:
bugs.json, patches.xml, requests.org, etc.
votes.json -- per-report vote details (see below).
patches/ -- attached and inline patch files.
text/<mid-hash>/ -- text/plain and text/x-log attachments (backtraces, log files).
events/<mid-hash>/ -- .ics attachments from announcements; combined feeds at events/announcements{,-open,-closed}.ics aggregate VEVENT blocks for subscription.
events.{json,org,xml} and events-closed.{json,org,xml} -- open/closed announcements carrying .ics or inline VCALENDAR content.
RSS feeds carry up to 50 items, most recent first. Export is incremental: only sources modified since the last export are regenerated. Use --force to rebuild everything, or bb export {json|rss|org|html|stats|text|events} to rebuild a single format.
| Flag | Purpose | Configurable |
-n <source> | restrict to one source | -- |
-p <N> | filter by minimum priority | -- |
-s <N> | filter by minimum status | -- |
--page-size <N> | paginate HTML report table (client-side rendering) | CLI only |
--closed-retention | exclude reports closed before given ISO date or duration | CLI only |
--topics-filter | restrict to topics (comma-separated, case-insensitive) | per source |
--force | force a full re-export | CLI only |
Durations accept Nd, Nw, Nm, Ny (e.g. --closed-retention 1y keeps reports closed within the last 12 months).
Vote transparency
BONE stores each vote on a request as a separate entity recording its voter and its source email. Export produces a votes.json file per source so anyone can audit the results:
{
"<11@test.org>": {
"+1": [{"voter": "alice@example.org", "message-id": "<12@test.org>"}],
"-1": [{"voter": "bob@example.org", "message-id": "<13@test.org>"}],
"0": []
}
}
BONE counts each voter once per report; the first vote wins. The per-report JSON still includes summary counts (votes, votes-up, votes-down, votes-null) for display, derived from these entities at export time.
Browsing BONE reports online
The exported HTML site lets you browse a source's reports and filter them through a minimal search syntax:
| Long form | Short | Explanation |
| from:me@example.org | f: | Reports from me@example.org |
| acked:me@example.org | a: | Reports acked by me@example.org |
| owned:me@example.org | o: | Reports owned by me@example.org |
| closed:me@example.org | c: | Reports closed by me@example.org |
| urgent:me@example.org | u: | Reports marked urgent by me@example.org |
| important:me@example.org | i: | Reports marked important by me@example.org |
| subject:match | s: | Reports matching subject |
| topic:match | t: | Reports matching topic |
| priority:[0-4] | p: | Reports matching priority level 0 to 4 |
| mid:<messageid> | m: | Report with message-id <messageid> |
| date:3d.. | d: | Reports from the last 3 days |
| date:2023-01-01..2023-12-31 | d: | Reports from 2023-01-01 to 2023-12-31 |
| deadline:2026-09-01 | D: | Reports with deadline on 2026-09-01 |
| deadline:2026-01-01..2026-06-30 | D: | Reports with deadline in that range |
| deadline:2m | D: | Reports due between now and 2 months from now |
| expired:10d | e: | Reports expired between now and 10 days from now |
| expired:2026-01-01..2026-03-31 | e: | Reports expired in that date range |
| match1 \vert match2 | | Reports matching "match1" or "match2" |
| from:* acked:* … | f:* a:* … | Wildcard for from/acked/owned/closed/urgent/important |
Durations are Nd (days), Nw (weeks), Nm (months). date: looks backward from today; deadline: and expired: look forward.
Deploying BONE
This section addresses anyone running a BONE instance -- organisation, project, or personal mailbox on a single machine. BONE reads its configuration from a single config.edn file; validate it after editing with bb test-config.
Setting up an instance
The shortest valid configuration declares a single IMAP mailbox and one source:
{:mailboxes [{:name "primary"
:type :imap
:host "imap.example.com"
:port 993
:ssl true
:user "imap-login@example.com"
:password "secret"
:folder "INBOX"}]
:sources [{:name "my-list"
:list "my-list.example.org"
:maintainers ["lead@example.org"]}]}
:mailboxes is a vector: every mailbox is a map with a unique :name (used to key its watermark and prefix logs) and a :type of :imap or :maildir. The singleton :mailbox key from older releases is no longer accepted -- migrate to the vector form even if you only have one mailbox.
The :db, :logging and :ingest sections are optional. Defaults: :db {:path "data/bone-db"}, stderr-only logging at :info, and :ingest {:fetch {:limit 50}}. See config.edn.example for the full annotated reference.
To keep IMAP/SMTP credentials out of config.edn itself, either:
:password-file "/etc/bone/imap.pwd" ; trimmed contents read at startup
:password #bone/env "BONE_IMAP_PWD" ; resolved from the environment
Both forms fail loudly at startup if the file or env var is missing, rather than silently using a nil password. The file form pairs well with systemd's LoadCredential=imap-pwd:/etc/bone/imap.pwd (the secret is mounted under $CREDENTIALS_DIRECTORY) and with Docker secrets (/run/secrets/imap-pwd). :password and :password-file are mutually exclusive in the same map.
BONE also reads Maildir folders. Point :path at the parent directory containing cur/, new/ and tmp/, and use :folder to select the subfolder. Set :folder "" when the Maildir at :path already holds cur/, new/ and tmp/ directly. A Maildir source in watch mode uses filesystem events rather than polling.
{:mailboxes [{:name "local"
:type :maildir
:path "/home/me/Mail"
:folder "INBOX"}]
:sources [{:name "my-list" :list "my-list.example.org"}]}
Reproducing a published instance
Every exported source publishes a ready-to-use reports/config.edn, linked from its docs.html under Configuration. It is a complete, self-contained config: the operator's global :labels, :commands, :expiry and so on are folded into the source entry, and everything secret or operator-internal (:mailboxes, :db, :logging, :notifications) is stripped. To get the same dashboard on your own copy of the list, download it, replace the placeholder :mailboxes with your local source (a Maildir of your subscription, or your own IMAP account), and run bb export.
The companion reports/meta.json carries the source identity -- :list / :alias / :to, :list-archive, :archive-format-string and :base-url. Tools such as gnaw use it to bind a published source to a local mailbox path of your choosing: the path is yours and lives only in your local tool config, while meta.json supplies the stable key (the List-Id) to match against.
meta.json also lists :reports-files -- the JSON files in reports/ that actually hold reports (all.json, all-open.json, all-closed.json and the per-type files such as patches-open.json), as opposed to meta.json, votes.json and stats.json. gnaw --add-source reads it to offer only the report files when you add a tracker by its base URL.
Multiple mailboxes
:mailboxes can declare any combination of IMAP and Maildir entries. A typical setup: one IMAP account for an upstream mailing list, and a local Maildir fed by a forge-to-mail bridge such as forge2mail.
{:mailboxes [{:name "upstream"
:type :imap
:host "imap.example.com" :port 993 :ssl true
:user "me@example.com" :password "secret"}
{:name "forge"
:type :maildir
:path "/home/me/Mail/forge"
:folder ""}]
:sources [{:name "my-project" :list "my-project.example.org"}
{:name "issues" :to "issues@example.com"}]}
How the modes behave with several mailboxes:
- Batch (
clj -M:run, the default) iterates over :mailboxes in order. A mailbox that fails to open or fetch is logged and skipped; the run continues with the next one. Expiry and stale-pending flush run once at the end, and only if at least one mailbox succeeded.
- Watch (
clj -M:run -- --watch) spawns one thread per mailbox, each with its own reconnect/backoff loop. The daily expire/flush is gated by a shared atom so it runs exactly once per 24-hour window no matter how many threads are active.
Watermarks are scoped per mailbox: each entry's :name keys its own record in Datalevin, so IMAP UID state and Maildir baseline IDs never mix between mailboxes. Sources are global -- BONE still matches emails to sources by List-Id / X-Original-To / Delivered-To regardless of which mailbox delivered them.
Renaming a mailbox is equivalent to declaring a new one: the old watermark is left orphaned in the DB, and the renamed entry restarts from its :fetch baseline. Wipe the DB with clj -M:run -- --fresh if you want to discard the orphan state.
A mailbox can also carry its own :ingest map -- same shape as the top-level :ingest (:fetch, :max-size, :max-attachment-size). Declared keys win over the global ones (shallow merge); undeclared keys fall back to the global defaults. A --fetch argument on the CLI still overrides every level.
{:mailboxes [{:name "upstream" :type :imap :host "..." :user "..." :password "..."}
{:name "forge"
:type :maildir
:path "/home/me/Mail/forge"
:folder ""
;; Only fetch the last 7 days from the forge bridge,
;; while the upstream mailbox keeps the global default.
:ingest {:fetch {:since "7d"}}}]
:ingest {:fetch {:limit 200}}
:sources [{:name "..." :list "..."}]}
Trust model and MTA requirements
BONE identifies every actor -- author, maintainer, voter, command issuer -- by the From: header of incoming mail. BONE does not verify DKIM, SPF or DMARC itself. It relies on the upstream MTA to filter forged mail before it reaches the Maildir or IMAP folder BONE watches.
This has practical consequences:
- Trivial actions (creating a report,
Acked., Owned., votes, closing a thread) treat the From: address as authoritative. An attacker who successfully delivers a mail with a forged From: can vandalise a report. Damage is bounded: a maintainer can always retract by command, and the audit trail keeps the offending message-id.
- Sensitive actions are stronger by design:
Add maintainer / Remove maintainer / Set lead require the issuer to already be a maintainer (or the lead); the lead maintainer cannot be removed. Announcements and releases require maintainer status. But those checks still rely on From:, so they are only as strong as the upstream filtering.
Recommended deployment posture:
- Run BONE behind an MTA that injects
Authentication-Results for every delivered mail (Postfix + OpenDKIM/OpenDMARC, or any mail provider that supplies them).
- Configure the MTA to reject -- not just tag -- messages that fail DMARC for domains with
p=reject or p=quarantine policies. The default behaviour of most MTAs is to honour the sender's policy, which means domains publishing p=none will still let spoofs through.
- If your sources are public mailing lists (Mailman, sourcehut, etc.), be aware that list mail typically arrives with
dmarc=fail even when legitimate, because the list rewrites headers. Bone cannot distinguish a legitimate Gmail-via-list mail from a spoofed one without reading Authentication-Results itself -- which it does not, for now.
- For a personal Maildir watched by a single user, this whole section is mostly informative: you are the only writer with practical delivery, and the attack surface reduces to whatever can reach your inbox -- which is already your spam problem.
If your deployment exposes BONE to a public list where you cannot trust the upstream filtering, treat maintainer changes and announcements as advisory and audit the source Message-Id before acting on them.
Monitoring your local mail
BONE can run on a single machine to watch your own mail. In that mode:
- The mail source is a Maildir (see the example above). Watch mode reacts to filesystem events -- no polling, no IMAP credentials.
- SMTP notifications are usually unnecessary: you already read the mailbox BONE watches. Either omit
:notifications or set :enabled false.
- One source is enough. Match on
:to or :list depending on which header your local MDA stamps.
To keep BONE in the background, install the user-level systemd unit from docs/deploy/bone.service (see Running BONE in production).
Source types and matching
A source has exactly one type key, which determines how BONE matches incoming emails:
:list -- matches the List-Id header (bare identifier, e.g. "my-list.example.org"). Detected as list mail when List-Id or X-BeenThere is present.
:alias -- matches X-Original-To, Envelope-To, X-Envelope-To, or Delivered-To. Detected as alias mail when the original recipient differs from To=/=Cc, or when multiple distinct Delivered-To values are present.
:to -- matches Delivered-To. For a dedicated mailbox where all emails are delivered directly.
Emails that match no source are discarded at ingestion.
Source options
| Key | Description |
:name | Source identifier (required) |
:list / :alias / :to | Exactly one (determines source type and header to match) |
:maintainers | Initial maintainers as a vector of email strings, e.g. ["dev@example.org"] |
| The first entry is the lead maintainer (see Managing roles and permissions) |
:list-archive | URL of the list archive (shown in HTML reports) |
:archive-format-string | URL template for message-ids (%s is replaced), used when the |
| Archived-At header is absent |
:base-url | Base URL where this source's public directory is served |
:labels | Per-source label overrides (see customising subject labels) |
:commands | Per-source command overrides (see customising commands) |
:report-types | Restrict detected and exported types, e.g. #{:bug :patch} |
:restricted-types | Types that require maintainer status to create (default: #{:announcement :release :change}) |
:export-formats | Output formats, e.g. ["json" "rss"] (default: ["json" "org" "rss"]) |
:expiry | Auto-close rules per report type (see below) |
:notifications | {:enabled false} to silence notifications for this source (default: enabled) |
:awaiting-delay | Duration before flagging "awaiting reply", e.g. "14d" (default: 14 days) |
:periods | Time-windowed overrides for maintainers/commands (see Source periods) |
Source periods
When a source's vocabulary or maintainer list has evolved over time, declare the history inline with :periods -- a vector of time-windowed overrides. Each entry may declare :start / :end (ISO yyyy-MM-dd) plus any of :maintainers, :commands, :command-syntax, :labels, :restricted-types. Fields omitted in a period inherit from the source level (the current state).
{:name "orgmode"
:list "emacs-orgmode@gnu.org"
:maintainers ["current-lead@example.org"] ;; default + current era
:periods
[{:end "2020-01-01"
:maintainers ["old-lead@example.org"]
:commands {:closed {:words ["Done" "Fixed"]}}}
{:start "2020-01-01" :end "2024-01-01"
:maintainers ["mid-lead@example.org" "co@example.org"]}
{:start "2024-01-01" ;; last era, no :end
:maintainers ["current-lead@example.org"]}]}
Rules:
- Periods must be contiguous:
:end of era N equals :start of era N+1.
- Only the first period may omit
:start (unbounded past).
- Only the last period may omit
:end (still active).
bb test-config validates the periods and rejects gaps or overlaps.
- At each boundary, sync recomputes maintainer tenure: it adds declared maintainers missing at the boundary, and closes previously- active maintainers absent from the declared list at the boundary's
:start.
Add/Remove maintainer: controls in emails continue to mutate tenures in real time between boundaries.
- Sync is idempotent: re-running on an existing DB never duplicates tenures and does not reinstate maintainers closed by a past mail control. To replay from scratch, run
clj -M:run -- --fresh.
Report expiry
BONE can automatically close any report type when its age and state match configured rules. It sets the close reason to "expired".
Each entry under :expiry maps a report type keyword to a rule map:
| Rule key | Type | Description |
:inactive-after | string / date / :deadline | Duration ("30d"), ISO date ("2026-06-01"), or :deadline |
:max-status | integer | Don't expire if activity score > this value (0--3) |
:max-priority | integer | Don't expire if priority > this value (0--3) |
All rules are conjunctive: a report expires only when all conditions are met. Only :inactive-after is required; the others are optional filters.
:inactive-after value | Trigger |
Duration (e.g. 30d) | Last email in the thread is older than the duration (any reply resets) |
| ISO date | The fixed date has passed, regardless of activity |
:deadline | The report's deadline is in the past (no deadline => never expires) |
The :max-status value uses an activity score that only counts acked and owned (since expiry candidates are always open reports):
| Score | Meaning |
| 0 | unacked, unowned |
| 1 | acked |
| 2 | owned |
| 3 | acked + owned |
:expiry {:announcement {:inactive-after "30d"}
:release {:inactive-after "6w"}
:change {:inactive-after "3m"}
:bug {:inactive-after "1000d"
:max-status 0 ;; only open, unacked, unowned
:max-priority 0} ;; only non-urgent, non-important
:request {:inactive-after "1000d"
:max-status 0}}
Can be set globally or per source (per-source wins).
Tuning ingestion
:ingest {:fetch {:limit 50} ;; or {:since "30d"} or {:start "…" :end "…"}
:max-size 1048576} ;; skip emails > 1 MB (optional)
:fetch -- first-run fetch window (no watermark yet). Exactly one of three disjoint map shapes (no key mixing, empty map rejected); default is {:limit 50}:
| Config form | CLI shortcut | Meaning |
{:limit N} | --fetch 50 | Latest N messages (positive integer) |
{:since "Nd"} | --fetch 30d | Relative duration from now (Nd, Nw, Nm, Ny) |
{:start "yyyy-MM-dd" :end "..."} | --fetch 2020-01-01 | Absolute half-open window [start, end); both keys optional, at least one required |
:max-size -- skip emails larger than N bytes. No limit by default.
BONE extracts the text content of .patch, .diff, .ics, text/plain and text/x-log attachments and stores it in the database. It skips attachments larger than 1 MB (1,048,576 characters) with a warning, keeping only metadata (filename, size, content-type). Override the limit with :max-attachment-size in :ingest.
Notifications (SMTP)
:notifications
{:enabled true
:smtp {:host "smtp.example.com"
:port 587
:tls true
:user "notify@example.com"
:password "secret"
:from "bone@example.com"
:reply-to "lead@example.com"} ;; optional
:admin-bcc "ops@example.com" ;; optional; string or vector
:subscribers
{"a@example.org"
[{:source "my-list"}]
"b@example.org"
[{:source "my-list" :min-priority 2}
{:source "another-list" :topic "release"}]}}
Each key under :subscribers is a recipient's address. The associated vector lists their subscriptions, one per source -- a recipient on three sources receives three separate emails per bb notify run.
:admin-bcc adds a hidden carbon-copy on every subscriber digest. A single address as a string, or a vector of addresses. Useful to audit deliverability and to catch bounces. The BCC field is not visible to the other recipients (standard SMTP semantics).
Optional per-subscription filters:
| Key | Description |
:min-priority | Min priority for the "unacked & unowned" section (default 1) |
:min-status | Min activity score for the "unacked & unowned" section |
:subject-match | Substring match on the report subject (all sections) |
:topic | Substring match on the report topic (all sections) |
:min-priority and :min-status filter only the "unacked & unowned" section -- the highest-volume one. Reports you own (with or without deadline) always appear regardless of priority or status.
bb notify tracks no per-recipient cadence: it sends every time it is invoked. Schedule it from cron or a systemd timer. bb test-config verifies that every :source named under :subscribers matches an existing source :name.
A small state file, data/.last-notify-failures.edn, records the last successful send per (subscriber, source) so the same failed-command entries are not re-mailed at every run. Delete the file to replay all current failures on the next invocation.
BONE keeps command failures on file for the subscriber digest and for manual inspection via bb maintenance --failures. BONE does not mail the author of a failing command directly: an unsubscribed user who typed Superseded-by: with a typo learns of the failure only if they are themselves on the subscriber list. Maintainers see all failures with :audience :maintainers in their digest.
Logging
:logging {:file "logs/bone.log"
:level :warn
:max-size "10MB"
:backlog 5
:email {:to "ops@example.com"
:level :error}}
Log file rotation is automatic. Email alerts require SMTP to be configured.
Running BONE in production
BONE can be deployed in two modes.
- Watch mode under systemd
- The daemon stays up, reacts to IMAP IDLE or Maildir filesystem events, and reconnects on failure. A ready-to-edit unit file lives in
docs/deploy/bone.service. Install it to /etc/systemd/system/bone.service (root) or ~/.config/systemd/user/ (user), then run systemctl [--user] daemon-reload && systemctl [--user] enable --now bone.
- Batch mode under cron
clj -M:run is a one-shot: it connects, fetches new mail since the watermark, digests, expires, and exits. Chain bb export and bb notify after it. An example crontab lives in docs/deploy/crontab.example.
Either mode can be backed by an uberjar to avoid the Clojure startup cost; see Quick start in README.org.
Customising commands
Command keywords can be customised globally or per source.
Overriding words, permissions, and report types
Each entry is a map with any of :words, :scope, :report-types (at least one must be present):
:commands {:closed {:words ["Canceled" "Fixed" "Wontfix"]}
:acked {:words ["Approved" "LGTM"]
:scope :maintainer} ;; Restrict to maintainers
:deadline {:report-types
#{:bug :patch :request :change}} ;; Widen to changes
:unclosed {:scope :setter-or-maintainer}} ;; Override scope only
:scope overrides the default permission for that command:
:user -- anyone
:maintainer -- maintainer only
:setter-or-maintainer -- the address that previously set the attribute, or any maintainer (maintainers keep their administrative override). Valid only on unset commands whose target attribute is tracked by a ref to the pose-email: :unacked, :unowned, :unclosed, :unurgent, :unimportant, :untopic, :undeadline, :unexpiry, :unsuperseded-by, :unsupersedes, :unduplicate-of. bb test-config rejects this scope on any other command.
:report-types replaces the default set of report types the command applies to. Absent => registry default.
- Any command ID from the table below can be overridden, both triggers and annotations.
- Role controls cannot be overridden.
Per-source settings win over the global :commands, which in turn win over the built-in defaults.
Syntax mode: :loose vs :strict
The :command-syntax key controls whether BONE commands must be prefixed with !. The default is :loose; :strict requires the prefix on every command, removing false positives on common English words ("Done.", "Closed.", "Applied.") that occur in ordinary prose.
| Form | Loose | Strict |
| Bareword | Closed. / !Closed. | !Closed. |
| Negative bareword | Not acked / !Not acked | !Not acked |
-by line | Acked-by: a@b.c | !Acked-by: a@b.c |
| Date line | Deadline: 2026-06-01 | !Deadline: 2026-06-01 |
| Topic | Topic: event | !Topic: event |
| Supersede | Superseded-by: <mid> | !Superseded-by: <mid> |
| Supersede (sym.) | Supersedes: <mid> | !Supersedes: <mid> |
| Role control | Add maintainer: a@b.c | !Add maintainer: a@b.c |
Set globally or per source (per-source wins):
:command-syntax :strict
Replaying archives under a different convention
When a source's vocabulary or syntax mode has changed over time (a list that tightened from :loose to :strict, or accepted Done. only during a specific era), declare the history inline with :periods (see Source periods). Each period carries its own overrides for :commands, :command-syntax and :maintainers; the core resolves the effective config at each email's date.
Maintenance
The bb maintenance task reports and prunes orphan emails. Run it while the daemon is stopped.
bb maintenance # dry run, show orphan counts
bb maintenance --delete # actually delete orphan emails
bb maintenance --verbose # list individual orphan message-ids
bb maintenance --failures # list recent command failures recorded
# for maintainers (denied actions, bad
# syntax, etc.)
bb maintenance --retention 6m # override default orphan retention
# (90 days). Accepts "30d", "6m", "1y"
# or an ISO date.
bb maintenance -n my-source # scope to a single source
An orphan email is one that no report references and that no maintainer has sent.
Rebuilding history
A source's historical evolution -- vocabulary, syntax, maintainer list -- is declared inline through :periods entries. See Source periods for the full format and rules. At startup, BONE materialises the tenure history by syncing at each period boundary, and resolves the effective config for every email from its date.
To replay from scratch after editing periods (or any config change you want reflected retroactively), wipe the DB:
clj -M:run -- --fresh # interactive confirm, then full replay
bb test-config validates :periods on each source: contiguity, ISO date format, :start strictly before :end.
Appendix 1: workflow examples
Patch review cycle
A contributor submits a patch series:
From: carol@dev.org
Subject: [PATCH parser v1 0/2] Refactor tokenizer
Cover letter: this series splits the tokenizer into two passes.
From: carol@dev.org
Subject: [PATCH parser v1 1/2] Extract lexer phase
<inline patch>
From: carol@dev.org
Subject: [PATCH parser v1 2/2] Add second pass
<inline patch>
BONE creates a patch series (cover + 2 patches). The maintainer reviews and asks for changes:
From: eve@project.org
Subject: Re: [PATCH parser v1 1/2] Extract lexer phase
> <inline patch>
The naming is confusing, can you rename foo to bar?
Carol resubmits as v2:
From: carol@dev.org
Subject: [PATCH parser v2 0/2] Refactor tokenizer
v2: renamed foo to bar as requested.
BONE automatically closes the v1 series and opens a v2 series. The maintainer applies:
From: eve@project.org
Subject: Re: [PATCH parser v2 0/2] Refactor tokenizer
Applied, thanks!
The v2 series is now closed (resolved).
Maintainer triage
A bug comes in with no response for a while. The maintainer triages by replying to the list:
From: eve@project.org
To: bugs-list@project.org
Subject: Re: [BUG] Crash on startup
Confirmed.
Owned.
Urgent.
Later, the fix lands:
From: eve@project.org
To: bugs-list@project.org
Subject: Re: [BUG] Crash on startup
Fixed in commit abc123, thanks for reporting!
Role management
Any maintainer adds a new maintainer (via the list):
From: eve@project.org
To: bugs-list@project.org
Subject: Role update
Add maintainer: newdev@project.org
Only the lead maintainer can Remove maintainer:.
Automatic expiry and override
With this config:
:expiry {:bug {:inactive-after "90d" :max-status 0}}
A bug whose last email is 91 days old, still unacked and unowned, is automatically closed with reason "expired" on the next expiry run.
To keep a legitimately old bug open, a maintainer marks it owned (which lifts it above :max-status 0):
From: eve@project.org
To: bugs-list@project.org
Subject: Re: [BUG] Old encoding issue
This is still relevant, keeping it open.
No expiry.
Owned.
Marking the bug owned takes its status above :max-status 0, so the expiry rule no longer applies. No expiry. clears any per-report expiry date previously set.