# SocialSecretary Mailbox Specification
## Version 1

*What the Fediverse sends you, and where SocialSecretary puts it.*

The Handoff Envelope Specification defines what a publishing client sends
*to* SocialSecretary. This document defines the other direction: what
SocialSecretary writes for a client to read when the Fediverse responds —
a follower replies to your post, likes it, boosts it, mentions you, or sends
you a private message.

Together the two specifications are the complete interface. A client that
implements both can publish to the Fediverse and read what comes back
without importing a line of SocialSecretary's code. zdat is one such client.

---

## The Contract in One Paragraph

SocialSecretary receives an activity on `/inbox`, verifies its HTTP
signature, checks the sender against the denylist, and — if the activity is
one a publisher would want to know about — writes it as a JSON file into one
of seven subdirectories of `mailboxes/`, chosen by what kind of thing it is.
SocialSecretary never reads these files again. What happens to them next is
entirely the client's business.

---

## Location

```
<baseDir>/actors/<domain>/<actor>/mailboxes/
  replies/
  mentions/
  private/
  likes/
  boosts/
  deletions/
  other/
```

`baseDir`, `domain`, and `actor` come from `socialsecretary_config.mjs`.
SocialSecretary creates each subdirectory on first use; a client should not
assume any of them exists until a file has been written to it, and should
treat a missing subdirectory as "nothing has arrived of that kind."

---

## File Shape

Every mailbox file is a JSON object with exactly two top-level fields:

```json
{
  "receivedAt": "2026-09-06T18:11:01.442Z",
  "activity": { ... }
}
```

| Field | Meaning |
|---|---|
| `receivedAt` | ISO 8601 UTC timestamp of the moment SocialSecretary accepted the activity. This is SocialSecretary's clock, not the sender's. |
| `activity` | The ActivityPub activity exactly as received, byte-for-byte after JSON parsing. SocialSecretary adds nothing to it and removes nothing from it. |

The activity is preserved untouched on purpose. A client that needs a field
SocialSecretary did not anticipate has it; a client that wants to re-verify
anything has the original. SocialSecretary's own additions live outside the
activity, in the wrapper, so the two can never be confused.

### Filename

```
<receivedAt, compact>-<id hash>.json
20260906T181101Z-k3xq9f.json
```

The timestamp is `receivedAt` with separators and milliseconds removed, so
that a plain alphabetical sort of a mailbox directory is chronological order.
The six-character suffix is a hash of the activity's `id`, which keeps two
activities received in the same second from colliding. Neither part carries
information a client needs to parse; the file contents are authoritative.

### Atomic writes

Each file is written to `<name>.json.tmp` and renamed to `<name>.json`. A
client scanning a mailbox will never see a half-written `.json` file. It may
see a `.tmp` file for a few milliseconds; **clients must ignore `*.tmp`**.

---

## Which Mailbox Gets What

One activity goes to exactly one mailbox. Where an activity could qualify
for more than one — a reply is also a mention — SocialSecretary chooses the
most specific and does not duplicate. Deduplication is not a burden to push
downstream.

### `replies/`

A `Create` whose object has `inReplyTo` set, addressed publicly or to
followers. Someone replied to one of your posts, or to a thread you are in.

What a client will want from `activity.object`: `content` (HTML),
`attributedTo` (the author's actor URL), `inReplyTo` (the URL of your post
they replied to), `published`, `id` (the reply's own URL), and `url`.

`inReplyTo` may point at one of your posts, or at another reply in a thread
below one of your posts. A client that displays threads should match on
the former and be prepared to see the latter.

### `mentions/`

A `Create` whose object carries a `Mention` tag naming your actor, with no
`inReplyTo`, addressed publicly or to followers. Someone mentioned you in a
post that is not a reply.

Same fields as `replies/`. The `tag` array on the object contains
`{ "type": "Mention", "href": "<your actorUrl>", "name": "@you@yourdomain" }`.

### `private/`

A `Create` addressed to your actor URL in `to` and *not* addressed to the
public collection. This is what Mastodon calls a Private Mention. It may or
may not have `inReplyTo`; private addressing takes precedence over reply
routing, so a private reply lands here, not in `replies/`.

**On the word "private."** ActivityPub's direct addressing is directed, not
private in any cryptographic sense. The message was readable by the sender's
server, by yours, and by anyone with administrative access to either. A
client should present these as confidential correspondence and must not
imply an encryption guarantee that does not exist. The mailbox is named
`private/` because "direct message" implies more than the protocol delivers.

### `likes/`

A `Like` activity. `activity.object` is the URL of your post that was liked;
`activity.actor` is who liked it; `activity.id` is the like's own id, which
is what a later `Undo` will reference.

### `boosts/`

An `Announce` activity — a boost, a reshare. Same shape as a like:
`activity.object` is your post's URL, `activity.actor` is who boosted,
`activity.id` is what an `Undo` will reference.

### `deletions/`

Two activity types land here, distinguished by `activity.type`:

**`Delete`** — a post or an account you hold something about has been
removed. `activity.object` is either the URL string of the deleted thing or
a `Tombstone` object whose `id` is that URL. It names one of:

- a post that previously arrived in `replies/`, `mentions/`, `private/`,
  or `other/` — the client should remove or tombstone its copy;
- an actor who previously liked, boosted, or replied — the account was
  deleted; the client may scrub that actor's engagement, or may not — that
  is the client's editorial decision, and SocialSecretary makes no change
  to any mailbox on the client's behalf;
- a follower's actor — the account was deleted, and SocialSecretary has
  already removed them from `followers.json` before writing this file.

**`Undo`** — an engagement was retracted. `activity.object` is the original
`Like` or `Announce` activity, embedded, and `activity.object.id` matches
the `activity.id` of a file that previously arrived in `likes/` or
`boosts/`. The client should find that record and remove it.

A `Delete` or `Undo` is written here **only** if SocialSecretary can match
it to something it previously routed. This is not optional filtering; it is
the difference between a usable mailbox and an unusable one. A large
instance sends a `Delete` to every server that has ever seen an account,
for every post or account that account deletes — on the order of hundreds a
day, essentially none of them relevant. SocialSecretary maintains a
received index of every object id, activity id, and actor URL it has routed
to a mailbox, and drops any `Delete` or `Undo` that matches nothing in it.
Dropped activities are still recorded in `activity_log.jsonl`. A client will
never see a deletion for something it never received.

### `other/`

A `Create` that reached your inbox but neither replies to you, mentions you,
nor addresses you privately — typically a post you were cc'd on, or one a
remote server forwarded for thread completeness. Kept rather than discarded
because the client may find a use SocialSecretary did not anticipate. Most
clients can ignore this mailbox entirely.

### What never reaches a mailbox

`Follow` and `Undo(Follow)` are handled by SocialSecretary itself — they
change `followers.json` — and are not written to any mailbox. `Accept`,
`Reject`, `Update`, `Block`, `Flag`, and unrecognized types are logged to
`activity_log.jsonl` and dropped. Anything that fails signature
verification or matches the denylist is rejected before routing and appears
nowhere but the process log.

---

## The Client's Side of the Contract

SocialSecretary's obligations end when the file is renamed into place. From
that point:

**Consumption is the client's responsibility.** SocialSecretary never
reads, moves, or deletes mailbox files. A file will sit in `replies/` until
the client does something about it. If the client never runs, the
directories grow without bound — except `deletions/`, whose gating keeps it
proportional to actual engagement.

**Recommended discipline: move, don't delete.** After processing a file,
move it to `mailboxes/processed/<same subdirectory>/`. SocialSecretary
never writes to or reads from `processed/`, so it is entirely the client's
space, and it preserves the ability to reprocess — after a bug, after a
schema change, or to rebuild derived state from scratch. The `tools/`
directory's `triage_deletions.mjs` relies on `processed/` to reconstruct
what a client holds. zdat follows this discipline. Clearing `processed/`
is safe at any time; it removes only the ability to reprocess.

**Process `deletions/` last.** A `Like` and its `Undo` can arrive in the
same batch. Processing engagement mailboxes first and `deletions/` last
means the retraction always finds the thing it retracts.

**Ignore `*.tmp`.** See "Atomic writes."

**Correlate by URL.** Mailbox files reference your posts by URL —
`activity.object` for likes and boosts, `activity.object.inReplyTo` for
replies. That URL is the `url` field of the envelope you published. A
client that needs to attach engagement to its own post records must be able
to look up a post by its published URL. SocialSecretary's own
`index/by-url/` does this for the archive; a client keeps its own.

**Do not trust `receivedAt` for ordering across mailboxes.** It is the
order of arrival at your server, which for a busy thread may differ from
the order of publication. `activity.object.published` is the sender's
claim about when they wrote it.

---

## Relationship to `activity_log.jsonl`

Every activity that passes signature verification is appended as one line
to `activity_log.jsonl` in the actor directory, whether or not it was routed
to a mailbox. The log is the audit trail; the mailboxes are the work queue.
A client should read the mailboxes. An operator diagnosing "did that reply
ever arrive" reads the log.

The log has no rotation of its own. See the Operator Guide.

---

*SocialSecretary Mailbox Specification — Version 1 — September 2026*
