Skip to content

feat: Dead Drop / Mailbox auto-responder (async per-source message store)#3538

Merged
Yeraze merged 11 commits into
Yeraze:mainfrom
TheWISPRer:feature/dead-drop-mailbox
Jun 21, 2026
Merged

feat: Dead Drop / Mailbox auto-responder (async per-source message store)#3538
Yeraze merged 11 commits into
Yeraze:mainfrom
TheWISPRer:feature/dead-drop-mailbox

Conversation

@TheWISPRer

@TheWISPRer TheWISPRer commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a Dead Drop / Mailbox feature — an asynchronous, per-source message store ("mesh voicemail"). A node DMs the connected radio msg <name> <text>; MeshMonitor holds the message until the named recipient retrieves it with inbox / inbox play. The recipient need not be online when the message is sent.

It's implemented as a fifth auto-responder responseType: 'mailbox', so it reuses the existing auto-responder machinery (DM gating, per-node cooldown, parameter extraction, message chunking, per-source scoping) rather than reinventing it. Configuration is entirely through the existing Auto Responder UI — no scripts required.

Commands (DM-only)

Command Description
msg <name> <text> Store a message for <name> (short name, long name, or !nodeid)
inbox Show count + which senders are waiting
inbox play Release up to 5 oldest messages (header + body per message)
inbox play <name> Release only messages from one sender
inbox delete <id> Delete one message by its 4-char id
inbox clear Delete already-played messages

How it fits the architecture

  • Schema src/db/schema/deadDrop.tsdead_drop_messages, all three backends (SQLite/PostgreSQL/MySQL), per-sourceId.
  • Migration src/server/migrations/094_create_dead_drop.ts (registered in src/db/migrations.ts; migrate-db TABLE_ORDER/SOURCE_SCOPED_TABLES updated).
  • Repository src/db/repositories/deadDrop.ts (Drizzle only) → databaseService.deadDrop.
  • Service src/server/services/deadDropService.ts — command parser/executor (repo injected for testability).
  • Integration src/server/meshtasticManager.tsresponseType === 'mailbox' dispatch branch.
  • Settings validation src/server/routes/settingsRoutes.ts — accepts the mailbox type (no response field required).
  • UI — "Mailbox" option in the Auto Responder editor (auto-responder/types.ts, TriggerItem.tsx, AutoResponderSection.tsx).

Design notes:

  • Recipient matching is by name as typed, resolved against the requesting node's identity forms (short/long name, !hex, node number) at retrieval — the DM sender context proves identity, avoiding a fragile store-time node lookup.
  • Per-source: each connected radio keeps its own mailbox.
  • Metadata and body are separate messages, so a near-200-byte body is never truncated by the header line.
  • Limits: 180-byte body, 5 per inbox play, 20 pending per recipient/sender, 7-day expiry.
  • Commands are the canonical msg / inbox verbs (DM-only).

Testing

  • Unit/integration: repository (per-source isolation, caps, expiry filtering), service (every command + edge cases), settings-save validation, migration registry, and migrate-db table-list sync. Full npm run test:run passes with 0 failures.
  • Live over-the-air: validated end-to-end on real Meshtastic hardware across three nodes — store → inboxinbox play (header + body delivery) → inbox clear — with the dead_drop_messages rows confirmed (created → played → deleted) at each step.

Docs

  • docs/features/automation.md — new Mailbox (Dead Drop) section.
  • docs/internal/dev-notes/DEAD_DROP_TESTING.md — architecture + testing brief.

@Yeraze

Yeraze commented Jun 18, 2026

Copy link
Copy Markdown
Owner

I'm curious, why did you implement this directly in-tree and not as a plugin/script ?

@Yeraze Yeraze left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review (high-effort, 8 finder angles + verification). Ranked most→least severe. 🔴 #1 (message loss) and #2 (unbounded dead_drop_messages growth) I'd treat as blockers; 🟠 #3#5 are functional bugs; 🟡 #6#8 hardening; 🟢 #9#10 altitude/cleanup. Per-source scoping, the migration recipe (count + last-name), the raw-SQL ban, and settings-key registration all check out — no convention violations. Nice tests.

Comment thread src/server/services/deadDropService.ts Outdated
Comment thread src/db/repositories/deadDrop.ts Outdated
Comment thread src/server/services/deadDropService.ts Outdated
Comment thread src/server/meshtasticManager.ts Outdated
Comment thread src/server/services/deadDropService.ts
Comment thread src/server/services/deadDropService.ts Outdated
Comment thread src/server/services/deadDropService.ts Outdated
Comment thread src/server/meshtasticManager.ts Outdated
Comment thread src/server/services/deadDropService.ts Outdated
}
return;

} else if (trigger.responseType === 'mailbox') {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 5th near-duplicate response branch (cleanup/altitude).

The enqueue loop + verifyResponse ? 3 : 1 + cooldown-set + start/duration logging are copy-pasted from the http/script/traceroute/text branches. Five copies must be kept in sync. Consider extracting a shared enqueueAutoResponderReplies(lines, message, packetId, trigger, triggerIdx) helper.

Minor: mailboxTarget re-inlines !hex formatting without the >>> 0 unsigned coerce that deadDropService.nodeIdHex uses (cosmetic — log-only here).

@TheWISPRer TheWISPRer Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that the enqueue loop + verifyResponse ? 3 : 1 + cooldown-set + start/duration logging would be out-of-scope for this PR as it is effectively a 5-branch refactor. I am more than happy to help address this, but I believe that this should be split into a separate pathway as this is an overarching system optimization (scope creep on this PR)

@TheWISPRer

Copy link
Copy Markdown
Contributor Author

@Yeraze Fair question. Honestly, the script path is reasonable. The first version I built of this was a Python script on the existing responseType: script hook.

I moved it in-tree for a few things scripts can't really cover:

  • It's real per-source data. Held messages live in dead_drop_messages alongside everything else. Per-source scoped, through the same Drizzle layer (SQLite/Postgres/MySQL), and included in system backup/restore. A script can only write a sidecar SQLite file under /data, which isn't in backups and is SQLite-only. I hit this with the prototype - the held messages weren't part of any backup.
  • No per-message subprocess. inbox/msg can be hit often; the script hook spawns an interpreter per matching DM. In-tree it's a direct call (~10ms in practice).
  • Configured in the existing Auto Responder UI as a response type, with validation: No script files/interpreters to manage.

That said, if you'd rather keep core lean, I'm glad to repackage it as a maintained script/companion instead. Is there a plugin architecture you have in mind beyond the script responseType? Happy to go whichever way you prefer before I work through the review items.

@TheWISPRer

Copy link
Copy Markdown
Contributor Author

I did consider building this as a UI module/tile (the way the Solar Monitoring report is a card under Analysis & Reports) so there'd be a browser-based way to read your mailbox (out-of-band from receiving it over Meshtastic itself). But since a mailbox isn't really an analytics or reports feature, I figured tying message handling into the existing Auto Responder was a good first step.

I do still plan some version of a recipient-facing mailbox UI as a companion, but as a later/bolt-on release — partly because of the access model and privacy side of it. Recipients are often node operators who don't have a MeshMonitor login, so a browser view would need an out-of-band way to authenticate: e.g. prove you control your node by entering a one-time code DM'd to it on the mesh, which mints a scoped, mailbox-only session (or lets you set up a trusted login just for the mailbox). That's enough design surface on its own that I deliberately kept it out of this PR.

@TheWISPRer TheWISPRer force-pushed the feature/dead-drop-mailbox branch from 11efbb2 to fe3177c Compare June 20, 2026 17:06
@TheWISPRer

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review — pushed fixes for all 10 (fe3177c), and rebased onto 4.11 along the way (only collision was the migration number, renumbered 092 → 093).

Replying inline on the threads too.

@Yeraze

Yeraze commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Re-review of the latest commit (fe3177cf) — all 10 findings resolved ✅

I re-reviewed against the actual code at HEAD (not just the commit message). The prior blockers and functional bugs are genuinely fixed, CI is green.

Blockers — fixed

  • "Claude PR Assistant workflow" #1 Message loss (played-state committed before delivery). handleCommand now returns { responses, playOnDelivery }; the manager maps each body line's index → messageId and calls markDelivered(...) from the delivery-success callback (meshtasticManager.ts mailbox branch), not at build time. A dropped body DM now leaves the message pending.
  • feat: traceroute highlighting and UI improvements #2 Unbounded growth (purgeExpired had no caller). Now invoked in the scheduled databaseMaintenanceService routine (alongside the neighbor/waypoint sweeps + VACUUM), returning a deleted count for logging.

Functional bugs — fixed

Hardening — fixed

Altitude

Residual minor items (non-blocking, follow-ups)

  1. This PR description is stale — it still advertises the "Optional command-keyword prefix (betamsg/betainbox)" feature that fe3177cf removed. The in-repo docs are correct; the description should be trimmed to "canonical msg/inbox only."
  2. Reliability when Verify response is off — played-state now hinges on the delivery callback, and maxAttempts = verifyResponse ? 3 : 1, so a single-attempt unacked send could mark a voicemail played on transmit. A one-line doc rec to enable Verify response on the mailbox trigger would harden this. (Same semantics as the other auto-responder branches, so low priority.)
  3. purgeExpired does count-then-delete (two scans) — cosmetic; could return the delete rowCount directly.

⚠️ Merge blocker — migration number collision (needs rebase)

The branch is currently conflicting with main. Since this PR was opened, main merged 093_autoack_matrix.ts, so this PR's 093_create_dead_drop.ts now collides on the migration number. The conflicts are in src/db/migrations.ts and src/db/migrations.test.ts (both register #93 and assert the count + last-migration name).

To resolve:

  1. Rebase on the latest main.
  2. Renumber the migration 093_create_dead_drop.ts094_create_dead_drop.ts (file, registry.register({ number: 94, … }), and the 093094 reference in this PR's body/docs).
  3. Update src/db/migrations.test.ts to the new count (94) and last-migration name (094_create_dead_drop).
  4. Re-confirm migrate-db TABLE_ORDER / SOURCE_SCOPED_TABLES still include dead_drop_messages.

Verdict

The feature itself is well-structured and convention-respecting (per-source scoping, three-backend schema, raw-SQL ban honored, good tests), and all 10 review findings are resolved. Approving on substance — just needs the rebase + migration renumber above before it can merge. Items 1–3 are optional follow-ups.

— Automated re-review (Claude Opus 4.8)

chrisn and others added 10 commits June 20, 2026 20:05
A per-source 'mesh voicemail': a node DMs the radio `msg <name> <text>`
and the message is held until the named recipient retrieves it via
`inbox` / `inbox play`. Implemented as a new auto-responder
responseType ('mailbox'), reusing the existing DM-gating, per-node
cooldown, param extraction, and per-source scoping.

- DB: dead_drop_messages table (SQLite/PostgreSQL/MySQL) + migration 092
- Repository (Drizzle-only) + DatabaseService.deadDrop accessor
- DeadDropService: command brain (store/inbox/play[/sender]/delete/clear,
  180-byte cap, per-recipient & per-sender caps, 7-day expiry, batch play)
- meshtasticManager: 'mailbox' responseType dispatch branch
- UI: 'Mailbox' response type option + guidance in the auto-responder editor
- Tests: 34 (migration registry, repository perSource, service)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Mailbox response type was only added to the per-trigger edit view
(TriggerItem); the separate Add-Trigger form in AutoResponderSection had
its own type <select> (Text/HTTP/Script) that was missed, so new mailbox
triggers couldn't be created from the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Add button's disabled gate required a non-empty response field for
all types; Mailbox has no response, so the button stayed greyed out.
Exempt mailbox from the response-required check (matches validateResponse).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The settings-save validator required a non-empty response for every
trigger and only allowed text/http/script responseTypes, so saving a
Mailbox trigger failed with a generic 400. Exempt mailbox from the
response-required check and add it to the responseType allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nse text

Regression coverage for the mailbox responseType in the autoResponderTriggers
validator: a mailbox trigger with empty response now saves (200), while
non-mailbox empty responses and unknown responseTypes still 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mailbox service parsed hardcoded msg/inbox, but a trigger configured with
prefixed keywords (e.g. betamsg/betainbox, to coexist with another responder
already using msg/inbox) would fire the handler yet fall through to help. Strip
an optional non-space prefix from the leading verb so prefixed keywords parse
correctly; no-op for plain msg/inbox. Caught by live over-the-air testing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- automation.md: Mailbox response type + 'Mailbox (Dead Drop)' section
  (commands, recipient matching, delivery format, limits, command-prefix
  coexistence, configuration).
- dev-notes/DEAD_DROP_TESTING.md: architecture, automated coverage, and the
  over-the-air validation results (ALTO MF / ALTO LF / ZN Office).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-database migrate-db CLI tracks every schema table in TABLE_ORDER /
SKIP_TABLES; migrationTables.test.ts fails if a new schema table isn't listed.
Add dead_drop_messages to TABLE_ORDER and SOURCE_SCOPED_TABLES (it carries a
sourceId, like auto_favorite_targets). Caught by the full Vitest suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Mark messages played from the delivery-success callback, not at enqueue:
  handleCommand returns { responses, playOnDelivery }; a dropped body DM now
  leaves the message pending instead of losing it. (Yeraze#1)
- Wire purgeExpired into databaseMaintenanceService so expired rows are
  reclaimed daily (purgeExpired returns a count). (Yeraze#2)
- Count the per-recipient cap across the recipient's identity forms via an
  injected node resolver, so it can't be bypassed by addressing one node by
  several name forms. (Yeraze#3)
- Mailbox bypasses the per-node cooldown (interactive flow). (Yeraze#4)
- inbox play <sender> filter matches !hex/node-num forms too. (Yeraze#5)
- Non-DM commands return [] (no unsolicited DM). (Yeraze#6)
- inbox delete returns the same response for not-yours vs non-existent ids
  (no id enumeration). (Yeraze#7)
- Wrap the mailbox dispatch in try/catch like the script branch. (Yeraze#8)
- Remove the command-prefix tolerance: canonical msg/inbox only. (Yeraze#9)
- Use shared nodeIdHex (unsigned coerce) for the mailbox log target. (Yeraze#10)

Docs + tests updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…igger

Played-state is committed from the delivery-success callback; with Verify
response off (maxAttempts=1) a single unacked send could mark a voicemail
played on transmit. Document enabling Verify response so undelivered bodies
resurface. (PR Yeraze#3538 review follow-up)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@TheWISPRer TheWISPRer force-pushed the feature/dead-drop-mailbox branch from fe3177c to fde45d2 Compare June 21, 2026 00:07
@TheWISPRer

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough re-review — all addressed. Pushed fde45d2 (rebased onto main @ 57854ea, v4.11.2).

Merge blocker — migration collision ✅

Rebased onto latest main and renumbered to avoid the 093_autoack_matrix collision:

build:server clean; the dead-drop + migration + settings suites pass (105/105). PR now shows MERGEABLE.

Residual follow-ups

  1. Stale PR description ✅ — trimmed. Dropped the betamsg/betainbox prefix bullet and the "prefix tolerance" test note; the migration ref now reads 094; added a line noting commands are the canonical msg/inbox verbs.
  2. Verify-response reliability ✅ — added a doc rec on the Mailbox trigger to enable Verify response (default on), since played-state hinges on the delivery callback and maxAttempts = verifyResponse ? 3 : 1. With it on, an undelivered body resurfaces on the next inbox play.
  3. purgeExpired count-then-delete — left as-is intentionally. Drizzle's delete result shape differs across the three backends (changes / rowCount / affectedRows), so the count-then-delete keeps the returned number portable without a per-backend branch. Happy to switch to a direct rowCount if you'd prefer — it's cosmetic and N is tiny (expired rows only).

#10 (the 5th enqueue branch) remains the open follow-up as agreed.

Resolve migration-number collision: renumber the dead-drop migration
094_create_dead_drop -> 095_create_dead_drop (keeping main's
094_add_meshcore_node_favorite). Update migrations.ts registry,
migrations.test.ts (count 95, last=create_dead_drop), and the migration's
internal Postgres/MySQL export names. meshtasticManager.ts auto-merged
cleanly (main's Yeraze#3593/Yeraze#3598/Yeraze#3600 changes + the mailbox dispatch coexist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
@Yeraze Yeraze merged commit 49be310 into Yeraze:main Jun 21, 2026
20 checks passed
@Yeraze Yeraze mentioned this pull request Jun 21, 2026
@TheWISPRer TheWISPRer deleted the feature/dead-drop-mailbox branch June 21, 2026 18:22
Yeraze added a commit that referenced this pull request Jun 21, 2026
Bump version to 4.11.3 across all five version files and document the
changes merged since 4.11.2 in the changelog.

Features:
- Dead Drop / Mailbox auto-responder (#3538)
- MeshCore node favoriting (#3588)
- FEM LNA Mode LoRa config on Device Config + Remote Admin (#3599)
- MeshCore CLI bundled in the Docker image (#3587)

Bug fixes:
- meshcore_neighbor_info timestamp BIGINT — PG/MySQL int32 overflow crash (#3602)
- downlink/uplinkEnabled proto3 boolean elision revert (#3594)
- Position-history SNR for directly-heard (0-hop) nodes (#3590)
- MeshCore auto-ack {SNR}/{ROUTE} tokens intermittently blank (#3589)
- macOS x64 DMG re2.node arch (Intel Mac crash) (#3603)
- Mesh request endpoints return 503 when disconnected (#3596)

Also refreshes the CLAUDE.md version header and migration count (96).


Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants