Example
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.
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.
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!
Alice reported a bug, Bob confirmed it, Eve closed it. The mailing list is the conversation; BONE indexes it into a queryable database of reports, states, authors and threads.
As a user
Bug, Patch, Request
The subject label sets the report type: mainly [BUG], [PATCH] and [FR/TODO/POLL] (feature requests); see report types.
BONE creates a patch report when:
- the subject starts with
[PATCH], directly or as Re: [PATCH] ... with a .patch or .diff attachment (the v2/v3 reply workflow);
- the email attaches a
git format-patch file whose inner Subject: starts with [PATCH].
Inline or quoted diffs, and git diff > x.patch attached to an unrelabelled reply, stay regular replies: attach a real git format-patch file or relabel with [PATCH] instead. BONE stores a patch attached to a [BUG] or [FR] email as metadata and leaves the type unchanged.
Updating reports
A command is a case-sensitive keyword starting a line: in a reply, Confirmed. acks a bug, Applied. or Fixed. closes a patch, Canceled cancels a request. A command ends with punctuation (. , ; : ? !), whitespace or end of line; Urgent and Important require punctuation or end of line (see priority commands).
Triggers change state and apply only to the closest parent report, never to their own email (Acked in a new bug does not confirm it). Annotations set properties on the report the email creates, or else on the closest parent (Urgent. in a new bug raises its priority). See the complete list of commands.
Retracting updates
Retract a state or clear a property with the matching "Not"/"No" command:
Not acked.
Not owned.
Not closed.
Not urgent.
Not important.
No deadline.
No expiry.
No topics.
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>
Like every command, retraction is open to everyone unless a per-command :scope tightens it (see Commands); each update references its email, so you can audit and reverse it.
Crediting a third party
Anyone can act on behalf of someone else with a -by line (bare address or Name <addr>) sent to the BONE inbox:
Acked-by: a-user@example.com
Owned-by: a-user@example.com
Closed-by: a-user@example.com
Owned-by: A User <a-user@example.com>
The credited address holds the state; BONE keeps the posting email for audit.
Setting topics
A report may carry several topics (short strings without whitespace). You can set them from the subject:
- inside the label:
[BUG parser] sets "parser", [BUG parser input] sets "parser" and "input";
- as colon prefixes after the label:
[BUG] parser: crash on empty input sets "parser", [BUG] parser: input: crash sets "parser" and "input". Each topic must be a single word followed by : and a space; a sentence containing a colon (or a URL) sets no topic.
A Topics: reply adds each word as a topic; -word removes one, wherever it came from:
Topics: easy.
Topics: -parser lexer.
On a report opened as [BUG parser input], the second line leaves "input" and "lexer". Spaces separate words and a comma does not, so BONE ignores a prose line like Topics: a, b, and c.. Matching is case-insensitive; re-adding a present topic does nothing. A word consists of letters, digits, _ and -, possibly joined by . or / (org-demo.html, net/mlx5e); you cannot remove subject topics with other characters this way. No topics. removes all topics.
Setting a deadline
Deadline: 2026-03-10.
The value is an ISO date (yyyy-MM-dd) or a duration (2d, 3w, 1m, 1y) counted from the sending day (2d sent June 1 > June 3); trailing punctuation is optional. BONE shows the deadline in JSON, as an Org =DEADLINE: line, and in notification emails. No deadline. removes it.
Setting an expiry date
A per-report expiry date schedules automatic closing:
Expiry: 2026-06-15.
Expiry: 30d.
Expiry: takes the same values as Deadline:; BONE closes the report once that day is over (UTC). The expiry date appears in JSON as :expiry, as an Org :EXPIRY: property, and as a date-column tooltip in the HTML index. No expiry. removes it. To expire reports at their deadline, use :inactive-after :deadline in the source-level expiry rules (see report expiry).
Superseding a report
A report can replace another (newer patch version, better bug report). In the old report's thread, write:
Superseded-by: <new-message-id@example.com>
In the new report's thread (reply or opening mail), write:
Supersedes: <old-message-id@example.com>
A message-id is <id@host>, bare id@host, or the last path segment of an archive URL such as https://list.orgmode.org/orgmode/id@host/. Both reports must have the same type (bug, patch or request).
A report has at most one superseder: among repeated Superseded-by:, Supersedes: or Duplicate-of: lines in one email (both closing commands compete), the last wins; against Not closed. in the same email, the closure wins. On a closed report, a new closure replaces the old one: reason, credit and relation follow the last command. Undo with Not superseded-by: (old thread) or Not supersedes: (new thread).
Supersession of patches
A new patch report automatically supersedes an earlier open patch report in its thread with the same base subject (ignoring Re:=/=Fwd:, [TAG] brackets, parenthesized version markers, case and punctuation), covering re-sent unversioned patches:
[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 (3) stays open. The reply must itself be a patch report (see above); a plain Re: without a patch is discussion. Versioned patches ([PATCH v2]) match on version and topic instead; see Patch series.
Resolving a bug or request with a patch
A .patch or .diff reply to an open bug or request (nearest parent) credits you Acked and Owned on it (only Owned if you reported it). Closing that patch with Applied. or Fixed. closes the parent with reason :resolved; cancelling the patch retracts the implicit credit; superseding it transfers Owned to the new author, while Acked stays with whoever first confirmed the parent.
Marking a report as a duplicate
Duplicate-of: <target-message-id@example.com>
This command takes the same message-id forms and has the same type constraint as Superseded-by:. BONE closes the duplicate with reason :canceled and links it to the target; undo with Not duplicate-of:.
Cross-referencing reports
Related-to: <other-message-id@example.com>
This command links any pair of reports and takes the same message-id forms as Superseded-by:; several lines post several links. Undo with Not related-to:.
Voting on requests
Requests ([POLL], [TODO] and aliases) accept votes in replies:
+1 or 1+ -- positive;
-1 or 1- -- negative;
+0, 0+, -0 or 0- -- abstention.
Each voter has one vote per report; a new vote replaces the old one.
As a maintainer
Maintainers use the same email channel as users, with broader permissions and extra report types.
Announcements, releases, changes
Maintainers can post announcements ([ANN]), releases ([REL]) and upcoming changes for a future release ([CHG]). A [REL v2.0] report closes every open [CHG v2.0] report with reason :resolved and cross-links them to the release.
Events (ICS calendar support)
When an [ANN] report has an attached .ics file or inline VCALENDAR content, BONE stores the calendar data with the report and exports:
- Individual
.ics files in events/<mid-hash>/.
- Combined calendars:
events/announcements.ics (all), events/announcements-open.ics and events/announcements-closed.ics.
events.json, events.org, events.xml (open announcements with ICS content) and events-closed.* (closed ones).
[ANN event] sets the topic to "event" for filtering, but BONE includes any announcement with ICS content whatever its topic.
Managing roles and permissions
Report commands need no permission (see Commands); only role controls require one:
- Each source has exactly one lead maintainer.
- Any maintainer can promote a user to maintainer.
- Only the lead maintainer can demote a maintainer back to user.
Write to the BONE inbox, with a bare address or the Display Name <addr> form:
Add maintainer: dev@example.com (any maintainer)
Remove maintainer: dev@example.com (lead maintainer only)
Add maintainer: Dev User <dev@example.com>
State-changing actions (commands and role controls) must go through the source's public channel -- mailing list or alias -- so every BONE instance on the same source sees the same state.
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 need whitespace (or the end of the subject) after the closing ]; BONE ignores malformed labels.
By default, announcements, releases and changes require maintainer permission; set :restricted-types per source to change this (see Source options).
All <...> parts are optional and positional. For [REL] and [CHG], a single value is the <version> ([REL 2.0] > version "2.0"); give both for topic and version: =[REL parser 2.0]. For [PATCH], <version> is a v\d+ token (e.g. v2) and <n/m> 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 closes the previous series and starts a new one: thus [PATCH parser v2 0/3] supersedes the earlier v1 series.
Cover letter commands
A command in a reply to the cover letter [PATCH 0/m] applies to every patch in the series: Closed. closes the whole series, Deadline: 30d sets the deadline on every patch. The exceptions are Supersedes: and Related-to: (and their Not variants), which name a specific target and stay on the cover only.
Review trailers
BONE collects git person trailers from replies to a patch: unquoted Acked-by:, Reviewed-by:, Tested-by:, Reported-by:, Suggested-by:, Signed-off-by: and Co-developed-by: lines that start a line and carry an email address. BONE exports them verbatim in the report JSON's trailers field, for patch-applying clients (e.g. gnaw.el) to fold into the commit message, as b4 does.
Cover-letter trailers apply to every patch. Reviewed-by: and Acked-by: are also commands (they ack the report, crediting the named address); other keys never change the report's status.
Customising subject labels
Override the default labels globally or per source in config.edn through :labels; unlisted types keep their defaults. The precedence order is per source > global > built-in.
;; 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"]}}
Commands
A command is a keyword at the start of a line in a thread reply.
Triggers and annotations
- A trigger changes a report's state -- open/closed, acked, owned, superseded, duplicate -- and only acts on existing reports, never on the mail carrying it, 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. It applies to the carrying mail when that mail becomes a new report, otherwise to the nearest report in the thread. Examples:
Urgent., Important., Topics:, Deadline:, Supersedes:, Related-to:.
Colon-prefixed commands (Topics:, Deadline:, Expiry:, Acked-by:, Owned-by:, Closed-by:, Superseded-by:, Supersedes:, Duplicate-of:, Related-to:) need whitespace after the colon: Topics: parser works, but BONE silently ignores Topics:parser.
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's status combines three states, which BONE also expresses as a number:
:acked
- someone took the next sensible action -- confirmed a bug, reviewed a patch, approved a request.
:owned
- someone claimed the report.
:closed
- the report is resolved, canceled, or expired.
| (un)acked | (0)1 |
| (un)owned | (0)2 |
| (un)closed | (4)0 |
For example, an acked, unowned and open report has status 1+0+4 = 5.
You can ack or own only bugs, patches and requests; you can still mark announcements, releases and changes urgent or important, and close them.
Priority commands
| Command keyword | Effect on report |
Urgent. | Mark as urgent |
Important. | Mark as important |
Urgent and Important need punctuation (. , ; : ? !) or end of line after them; BONE rejects a bare space. BONE expresses priority as a number:
| (un)important | (0)1 |
| (un)urgent | (0)2 |
For example, an important and urgent report has priority 1+2 = 3.
Multiple commands
Put several keywords in one 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 propagate through threads and series:
All commands
Report commands are open to everyone by default; per-command :scope overrides in config.edn can restrict them to :maintainer (or :setter-or-maintainer for retraction commands). Role controls always require maintainer rights.
The six 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 | Topics: parser, Deadline: 30d |
No X. | clear an annotation | No deadline., No topics. |
X-by: <addr> | credit the update to 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> |
"Not" negates a state; "No" removes a property (deadline, expiry, topics). Every word setting acked, owned, urgent or important -- built-in or per-source :words synonym -- has a Not <word> form: Not confirmed. undoes Confirmed.. Not closed. is the only reopening form.
Triggers
This table lists triggers in the order they typically appear in a report's life:
| ID | Syntax / Words | Permission | Report types |
:acked | Acked, Confirmed, Approved | any user | bug, patch, request |
:acked-by | Acked-by: addr@host, Reviewed-by: addr@host | any user | bug, patch, request |
:unacked | Not acked, Not confirmed, Not approved | any user | bug, patch, request |
:owned | Owned | any user | bug, patch, request |
:owned-by | Owned-by: addr@host | any user | bug, patch, request |
:unowned | Not owned | any user | 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 | any user | all |
:unclosed | Not closed | any user | all |
| vote up | +1 or 1+ | any user | request |
| vote down | -1 or 1- | any user | request |
| vote null | +0 / 0+ / -0 / 0- | any user | request |
:superseded-by | Superseded-by: <msg-id> | any user | bug, patch, request |
:unsuperseded-by | Not superseded-by: <msg-id> | any user | bug, patch, request |
:duplicate-of | Duplicate-of: <msg-id> | any user | bug, patch, request |
:unduplicate-of | Not duplicate-of: <msg-id> | any user | bug, patch, request |
Annotations
Priority
| ID | Syntax / Words | Permission | Report types |
:urgent | Urgent. | any user | all |
:unurgent | Not urgent | any user | all |
:important | Important. | any user | all |
:unimportant | Not important | any user | all |
Classification
| ID | Syntax / Words | Permission | Report types |
:topic | Topics: [-]word ... | any user | all |
:untopic | No topics | any user | all |
Scheduling
| ID | Syntax / Words | Permission | Report types |
:deadline | Deadline: 2025-03-19 or Deadline: 30d | any user | all |
:undeadline | No deadline | any user | all |
:expiry | Expiry: 2025-06-15 or Expiry: 30d | any user | all |
:unexpiry | No expiry | any user | all |
Relations
| ID | Syntax / Words | Permission | Report types |
:supersedes | Supersedes: <msg-id> | any user | bug, patch, request |
:unsupersedes | Not supersedes: <msg-id> | any user | bug, patch, request |
:related-to | Related-to: <msg-id> | any user | all |
:unrelated-to | Not related-to: <msg-id> | any user | all |
Sent to a patch-series cover letter, these two relations stay on the cover (see Cover letter commands).
All controls
A control manages a role; you must send it 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 HTML pages and JSON, Org and RSS feeds.
Exported pages and feeds
bb export all generates, per source:
- HTML:
index.html (reports), data.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, logs), suffixed .txt unless named *.txt or *.log.
events/<mid-hash>/ -- .ics attachments from announcements; events/announcements{,-open,-closed}.ics aggregate their 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 hold the 50 latest items; maintainer reports carry <category>maintainer</category> and role:maintainer in the description, for filtering. HTML pages advertise their feeds, all.xml first, via <link rel"alternate" type="application/rss+xml">= (RSS autodiscovery); set :base-url to make these links absolute, as the specification recommends.
Export is incremental: it covers only the sources modified since the last export. --force rebuilds everything, and bb export {json|rss|org|html|stats|text|events} builds one format.
| Flag | Purpose | Configurable |
-n <source> | restrict to one source | -- |
-p <N> | filter by minimum priority | -- |
-s <N> | filter by minimum status | -- |
--html-page-size <N> | paginate HTML report table (client-side rendering) | CLI only |
--html-columns <C> | HTML table columns to show (CSV subset; default: all) | CLI only |
--html-columns-sort | default sort column for the HTML table (default: date) | CLI only |
--closed-retention | exclude reports closed before given ISO date or duration | CLI only |
--topics-filter | restrict to topics (comma-separated, case-insensitive) | CLI only |
--force | force a full re-export | CLI only |
Durations take the forms Nd, Nw, Nm, Ny (--closed-retention 1y keeps reports closed in the last 12 months). A run with -p, -s or --topics-filter is never incremental, and the next run is a full re-export.
Vote transparency
Each vote is an entity recording voter and source email; a per-source votes.json makes them auditable:
{
"<11@test.org>": {
"+1": [{"voter": "alice@example.org", "message-id": "<12@test.org>"}],
"-1": [{"voter": "bob@example.org", "message-id": "<13@test.org>"}],
"0": []
}
}
A new vote replaces the voter's previous one. BONE derives the per-report JSON counts (votes, votes-up, votes-down, votes-null) from these entities at export time.
Browsing BONE reports online
The HTML site filters reports with key:value tokens: space is AND, | separates OR clauses, a comma in a value is OR. type:bug s:crash | topic:latex keeps bugs whose subject matches "crash", plus all reports with a latex topic. A bare word (or unknown key) searches the subject. A leading - negates the whole token, comma alternatives included: -type:patch, -acked:*.
Double quotes make a value (or bare token) literal: spaces, commas, | and slashes lose their meaning, so subject:"preamble shuffling" matches that phrase. An empty value matches nothing.
/.../ is a case-insensitive regexp on text fields (from:, subject: and bare words, topic:, source:, mid:, acked:, owned:, closed:): subject:/v[0-9]+/. It spans the whole value (a , is literal), and you cannot use | inside it: OR two regexps with two clauses (s:/a/ | s:/b/).
| Long form | Short | Matches |
from:bzg | f: | from address or name |
subject:crash,latex | s: | subject (a bare word matches subject) |
similar:a+b+c | | subjects sharing 3 of the +-joined words |
topic:agenda | T: | any of the report's topics |
source:emacs | S: | source name (one char: exact) |
type:patch | t: | report type |
priority:2 | p: | exact priority (0-3) |
mid:<...> | m: | message-id |
acked:bzg owned:* closed:* | a: o: c: | by person (or * for any) |
urgent:* important:false | u: i: | marks (set or not) |
flags:AO | F: | Flags letters, all required (A O C R E S) |
att:~+ | A: | Att glyphs, all required (. ~ + x @ #) |
date:3d date:A..B | d: | created (durations Nd/Nw/Nm, ranges) |
deadline:2m | D: | due within / on (forward) |
expired:10d | e: | expiring within (forward) |
closed:true | c:true | include closed reports (transient) |
A lone date is that exact day; open range ends stay open (date:2026-01-01..). date: looks backward from today, deadline: and expired: forward.
type:, flags: and att: compare literally: no * (type:* matches nothing), quotes or regexps. For "priority at least 2", use p:2,3.
closed:true bypasses the Open filter without toggling the button (clearing the search restores it). Any closed: or flags: token naming a close reason (C R E S) also includes closed reports.
gnaw.el shares this syntax, with Emacs regexps and source: also matching its local one-letter source identifiers.
Deploying BONE
BONE reads a single config.edn; validate it after editing with bb test-config.
Setting up an instance
Here is a minimal configuration, with one 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 of maps, each with a unique :name (BONE uses it to key the watermark and prefix logs) and a :type (:imap or :maildir). Optional sections default to :db {:path "data/bone-db"}, stderr logging at :info, and :ingest {:fetch {:limit 50}}; see config.edn.example for the annotated reference.
To keep IMAP/SMTP credentials out of config.edn, use one of these forms:
:password-file "/etc/bone/imap.pwd" ; trimmed contents read at startup
:password #bone/env "BONE_IMAP_PWD" ; resolved from the environment
BONE fails at startup if the file or variable is missing; the two forms exclude each other. The file form suits systemd's LoadCredential=imap-pwd:/etc/bone/imap.pwd and Docker secrets (/run/secrets/imap-pwd).
For Maildir, :path is the parent directory and :folder the subfolder ("" when :path itself holds cur/, new/, tmp/). Watch mode uses filesystem events, not 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
For each source, BONE publishes a self-contained reports/config.edn (linked from docs.html under Configuration): it folds in the global :labels, :commands, :expiry, etc. and strips :mailboxes, :db, :logging and :notifications. To reproduce the dashboard, replace its placeholder :mailboxes with your source (a Maildir of your subscription or your IMAP account) and run bb export.
reports/meta.json holds the source identity (:list / :alias / :to, :list-archive, :archive-format-string, :base-url), project links (:website, :contribute-url) if set, and :reports-files (all.json, all-open.json, all-closed.json, per-type files like patches-open.json). gnaw uses its List-Id as the stable key binding a published source to a mailbox path from its local config; gnaw --add-source offers only :reports-files when adding a tracker by base URL.
Multiple mailboxes
:mailboxes can mix IMAP and Maildir, e.g. an upstream list account plus a Maildir fed by 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"}]}
- Batch (
clj -M:run, default) walks :mailboxes in order, logging and skipping any that fails to open or fetch; expiry and stale-pending flush run once at the end if at least one succeeded.
- Watch (
clj -M:run -- --watch) runs one thread per mailbox with its own reconnect/backoff; a shared atom runs the daily expire/flush exactly once per 24 hours.
BONE keys watermarks (IMAP UIDs, Maildir baselines) by :name in Datalevin; sources are global, and BONE matches them by List-Id / X-Original-To / Delivered-To whatever the mailbox. Renaming a mailbox orphans its watermark and restarts from the :fetch baseline; clj -M:run -- --fresh wipes the DB, orphans included.
BONE shallow-merges a per-mailbox :ingest map (:fetch, :max-size, :max-attachment-size) over the top-level one; the CLI option --fetch overrides both.
{: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 From: and does not verify DKIM, SPF or DMARC: the upstream MTA must filter forged mail. Hence:
- Report commands (creation,
Acked., Owned., votes, closing) trust From:. A forgery can vandalise a report, with bounded damage: anyone can retract any update by command, and the audit trail keeps the offending message-id.
Add maintainer / Remove maintainer require a maintainer (or lead) issuer; nobody can remove the lead. Announcements and releases require maintainer status. These checks are only as strong as the upstream filtering.
We recommend this posture:
- An MTA injecting
Authentication-Results on every delivered mail (Postfix + OpenDKIM/OpenDMARC, or a provider doing so).
- The MTA rejecting -- not just tagging -- DMARC failures for domains with
p=reject or p=quarantine.
- Legitimate list mail often shows
dmarc=fail (the list rewrites headers); BONE does not read Authentication-Results to tell.
- A personal Maildir watched by one user limits exposure to what reaches your inbox.
Without trusted upstream filtering, treat maintainer changes and announcements as advisory; audit the source Message-Id before acting.
Monitoring your local mail
To watch your own mail on one machine:
- Use a Maildir source; watch mode reacts to filesystem events (no polling, no IMAP credentials).
- Skip SMTP notifications (you read the mailbox anyway): omit
:notifications or set :enabled false.
- One source suffices, matched on
:to or :list depending on the header your MDA stamps.
For background runs, install the user systemd unit docs/deploy/bone.service (see Running BONE in production).
Source types and matching
A source has exactly one type key, which sets the header it matches:
:list -- the List-Id header (bare identifier, e.g. "my-list.example.org"). Mail counts as list mail when List-Id or X-BeenThere is present.
:alias -- X-Original-To, Envelope-To, X-Envelope-To or Delivered-To. Mail counts as alias mail when the original recipient differs from To=/=Cc, or when several distinct Delivered-To values are present.
:to -- Delivered-To, for a dedicated mailbox.
Matching is exact and case-insensitive, never by substring (:list "my-list" does not match my-list.example.org). BONE checks every value of a repeated header and every address of a comma-separated recipient list, and discards at ingestion the emails that match no source.
Source options
| Key | Description |
:name | Source identifier (required) |
:list / :alias / :to | Exactly one: source type and header to match |
:maintainers | Initial maintainers, e.g. ["dev@example.org"]; the first is lead (Managing roles and permissions) |
:list-archive | URL of the list archive (shown in HTML reports) |
:archive-format-string | Message-id URL template (%s replaced), used without Archived-At header |
:base-url | Base URL serving this source's public directory (absolute RSS autodiscovery links) |
:website | Project website, linked from the source name in page titles and footer |
:contribute-url | Contribution guide, target of the "Create new" button on the reports page |
:post-address | Posting address shown on docs.html (mailbox sources fall back to :to) |
:labels | Label overrides (customising subject labels) |
:commands | Command overrides (customising commands) |
:report-types | Restrict detected and exported types, e.g. #{:bug :patch} |
:restricted-types | Types only maintainers may 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} silences this source's notifications (default: enabled) |
:awaiting-delay | Duration before flagging "awaiting reply", e.g. "14d" (default: 14 days) |
:periods | Time-windowed overrides (Source periods) |
Source periods
When a source's vocabulary or maintainers changed over time, declare the history with :periods, a vector of time-windowed overrides. Each entry may set :start / :end (ISO yyyy-MM-dd) plus any of :maintainers, :commands, :command-syntax, :labels, :restricted-types; omitted fields inherit from the source level (the current state). BONE resolves the effective config for each email from its date.
{: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"]}]}
These rules apply:
- 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 may omit :end (still active).
bb test-config rejects gaps, overlaps, non-ISO dates and a :start not strictly before :end.
- At startup, sync recomputes maintainer tenure at each boundary: it adds declared maintainers missing there, and closes previously-active maintainers absent from the list declared at the boundary's
:start.
Add/Remove maintainer: controls in emails still mutate tenures in real time between boundaries.
- Sync is idempotent: re-running on an existing DB never duplicates tenures nor reinstates maintainers that a past mail control closed. To replay from scratch, run
clj -M:run -- --fresh (see Rebuilding history).
Report expiry
BONE can auto-close reports of any type whose age and state match the rules you configure, with close reason "expired". Each entry under :expiry maps a report type keyword to a rule map:
:inactive-after (required) -- when the report becomes a candidate:
- a duration (e.g.
"30d"): the thread's last email is older than that (any reply resets);
- an ISO date (e.g.
"2026-06-01"): the date is over (UTC), regardless of activity;
:deadline: the report's deadline day is over (no deadline => never expires).
:max-status (optional, 0--3) -- do not expire the report if its activity score is higher. The score counts only acked and owned (candidates are always open): 0 unacked and unowned, 1 acked, 2 owned, 3 both.
:max-priority (optional, 0--3) -- do not expire the report if its priority is higher.
A report expires only when all conditions hold.
: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}}
You can set :expiry 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 -- the fetch window for the first run (no watermark yet), by default {:limit 50}. It takes exactly one of three disjoint map shapes; BONE rejects mixed keys and an empty map:
| 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).
:max-attachment-size -- BONE stores the text of .patch, .diff, .ics, text/plain and text/x-log attachments; it skips larger ones (default 1 MB, 1,048,576 characters) with a warning and keeps only their file name, size and content type.
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 :subscribers key maps a recipient address to a vector of subscriptions, one per source: a recipient of three sources gets three emails per bb notify run. BONE blind-copies :admin-bcc (an address or a vector) on every digest, so you can audit deliverability and catch bounces.
Each subscription accepts optional filters:
:min-priority (default 1) -- applies only to the high-volume "unacked & unowned" section; reports you own (with or without deadline) always appear.
:subject-match / :topic -- substring match on the report subject / topic (all sections).
In operation:
bb notify keeps no per-recipient cadence: it sends at every run, so schedule it with cron or a systemd timer.
bb test-config checks that every subscribed :source matches an existing source :name.
data/.last-notify-failures.edn records the last successful send per (subscriber, source) so that bb notify does not mail failed-command entries again at every run; delete it to replay all current failures next time.
- BONE keeps command failures for the digest and
bb maintenance --failures, and never mails them to the failing command's author. 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 rotation is automatic. Email alerts require SMTP configuration.
Running BONE in production
- Watch mode under systemd
- The daemon stays up, reacts to IMAP IDLE or Maildir filesystem events, and reconnects on failure. Install the unit file
docs/deploy/bone.service 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 connects, fetches mail since the watermark, digests, expires, and exits. Chain bb export and bb notify after it; see docs/deploy/crontab.example.
Either mode can run from an uberjar to avoid Clojure startup cost; see Quick start in README.org.
Customising commands
You can customise command keywords globally or per source.
Overriding words, permissions, and report types
Each entry is a map with at least one of :words, :scope, :report-types:
:commands {:closed {:words ["Canceled" "Fixed" "Wontfix"]}
:acked {:words ["Approved" "LGTM"]
:scope :maintainer} ;; Restrict to maintainers
:deadline {:report-types
#{:bug :patch :request}} ;; Restrict to actionables
:unclosed {:scope :setter-or-maintainer}} ;; Override scope only
:scope overrides the command's default permission:
:user -- anyone
:maintainer -- maintainers only
:setter-or-maintainer -- the address that set the attribute, or any maintainer. It is valid only on unset commands whose attribute BONE tracks by a ref to the pose-email: :unacked, :unowned, :unclosed, :unurgent, :unimportant, :untopic, :undeadline, :unexpiry, :unsuperseded-by, :unsupersedes, :unduplicate-of; bb test-config rejects it elsewhere.
:report-types replaces the command's default report types (absent => registry default).
- You can override any trigger or annotation listed in All commands, but not role controls.
The precedence order is per-source :commands > global :commands > built-in defaults.
Syntax mode: :loose vs :strict
:command-syntax sets whether commands need a ! prefix. The default :loose accepts both; :strict requires it on every command, avoiding false positives on common words ("Done.", "Closed.", "Applied.") 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 |
| Topics | Topics: event | !Topics: 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 |
You can set it globally or per source (per-source wins):
:command-syntax :strict
Replaying archives under a different convention
If a source's vocabulary or syntax mode changed over time (e.g. it moved from :loose to :strict, or accepted Done. only in one era), declare per-era :commands, :command-syntax and :maintainers in :periods (see Source periods); BONE reads each email with the config in force at its date.
Maintenance
bb maintenance reports and prunes orphan emails -- emails no report references and no maintainer sent. Stop the daemon before running it.
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
Rebuilding history
You declare a source's history (vocabulary, syntax, maintainers) with :periods (see Source periods). To apply edited periods, or any config change, retroactively, wipe the DB and replay:
clj -M:run -- --fresh # interactive confirm, then full replay
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 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 closes the v1 series and opens a v2 series. The maintainer applies it, which closes v2 (resolved):
From: eve@project.org
Subject: Re: [PATCH parser v2 0/2] Refactor tokenizer
Applied, thanks!
Maintainer triage
A bug has gone unanswered for a while; the maintainer triages it on 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 can add a maintainer via the list; only the lead maintainer can Remove maintainer:.
From: eve@project.org
To: bugs-list@project.org
Subject: Role update
Add maintainer: newdev@project.org
Automatic expiry and override
Take this config:
:expiry {:bug {:inactive-after "90d" :max-status 0}}
At the next expiry run, BONE closes with reason "expired" a bug whose last email is 91 days old and that is still unacked and unowned. To keep it open, a maintainer marks it owned, lifting it above :max-status 0; No expiry. clears any per-report expiry date previously set:
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.