feat(email-ingress): schema for IMAP config + email inboxes (LC-77) #195

Merged
longjacksonle merged 1 commit from feat/lc-77-schema into main 2026-05-25 18:43:19 +02:00

Summary

Three migrations plus the supporting type updates so commit 3's IMAP poll loop has somewhere to land. No runtime behavior yet; the email-ingress feature stays fully gated behind commit 3's spawn_email_poll.

Migrations

  • settings/0005_imap_inbox_config.sql: singleton encrypted-at-rest config row mirroring vapid_keypair (settings/0003). AES-256-GCM sealed password_encrypted + password_nonce; plaintext host/port/tls/username/folder/ingress_domain/enabled. CHECK (id = 1) enforces the singleton.
  • chat/0048_email_inboxes.sql: per-room inboxes table. LC-74 webhooks (chat/0041) mirror: ON DELETE CASCADE on room_id, soft-delete via revoked_at, HMAC-only secret_hash, audit columns (created_by, created_at, last_used_at).
  • chat/0049_messages_email_inbox_id.sql: ALTER TABLE messages ADD COLUMN email_inbox_id INTEGER REFERENCES email_inboxes(id) ON DELETE SET NULL. Parallel to webhook_id. The FK is in place because email_inboxes exists at migration time, unlike webhook_id which had to predate incoming_webhooks in the LC-74 ordering.

Named decisions

  • Uploads uploader_id="" sentinel sticks. file_uploads.uploader_id (chat/0012_uploads.sql:3) is TEXT NOT NULL with no FK constraint, so an empty-string sentinel works for the email-ingress attachment path the same way messages.user_id='' already works for webhook posts. No schema change needed in this PR; commit 5 will call db::uploads::insert_upload(..., uploader_id="", ...) directly.
  • SMTP password stays plaintext for now. IMAP password is sealed via the VAPID precedent in this PR, but the existing settings.smtp_pass row remains plaintext. The inconsistency is real and worth fixing; tracked as the LC-77-SMTP-SEAL followup (own ticket, own PR) so scope here stays bounded.

Type updates

  • models::Message + db::chat::RawMessage: new email_inbox_id: Option<i64> field, parallel to webhook_id.
  • db::chat: every SELECT FROM messages projection (5 sites) appends email_inbox_id; row_to_raw mapper appends row.get("email_inbox_id").
  • routes/admin.rs, routes/room.rs (3 sites), routes/ws.rs: every Message {} struct literal adds the new field. Pass-through sites use raw.email_inbox_id; the webhook-finalize site (routes/room.rs:805) sets it to None (a webhook post is never also an email-inbox post).
  • db::email_inbox (new): identity(pool, id) -> Option<EmailInboxIdentity { name, avatar_url }>. Mirrors db::webhooks::identity field-for-field; the render layer in commit 3 will drop it in alongside the webhook arm.

Test drift cleanup

CLAUDE.md test-maintenance category 2 (migration-list drift) materialized exactly as predicted: 18 test files hand-roll the chat migration list (17 use array-form include_str! blocks, 1 uses the verbose per-block form in db_private_rooms.rs), and 7 hand-roll the settings list (all array form). Appended the two new chat migrations and the one new settings migration to each via the file's existing pattern. The drift surfaced as routes_uploads::send_message_with_attachment_renders_inline_image returning 500 from POST /room/1/messages because sqlx hit the missing email_inbox_id column at one of the projections that selects it.

Test plan

  • cargo check --workspace and cargo check --no-default-features --features lets-chat-server/saas both clean.
  • All 51 lets-chat-server test binaries pass under both default and saas feature sets.
  • New tests/db_email_inbox_schema.rs: 3 round-trip tests cover all three new schemas plus the CASCADE / SET NULL FK behaviors plus the CHECK (id = 1) singleton constraint.
  • cargo clippy -p lets-chat-server --tests -- -D warnings clean.
  • cargo fmt --check clean for in-scope files. Pre-existing views/math.rs + views/markdown.rs fmt diffs predate this branch (LC-59 carryover on main); tracked as LC-77-FMT-CARRYOVER followup.

Anti-scope

  • No IMAP poll loop. That lands in commit 3 with the dependencies (mail-parser, async-imap) and the runtime gate.
  • No admin UI. Commit 4.
  • No MIME parsing. Commit 5.
  • No SMTP password migration to sealed-at-rest. Separate followup ticket.

Next

Commit 3 builds on this branch's main once merged: spawn_email_poll, address resolution (Delivered-To/X-Original-To/To/Cc precedence), synthetic-actor send via db::chat::insert_email_inbox_message, exhaustive email_ingress::drop log taxonomy, and the MessageActor::EmailInbox template arm.

## Summary Three migrations plus the supporting type updates so commit 3's IMAP poll loop has somewhere to land. No runtime behavior yet; the email-ingress feature stays fully gated behind commit 3's `spawn_email_poll`. ## Migrations - **`settings/0005_imap_inbox_config.sql`**: singleton encrypted-at-rest config row mirroring `vapid_keypair` (`settings/0003`). AES-256-GCM sealed `password_encrypted` + `password_nonce`; plaintext `host`/`port`/`tls`/`username`/`folder`/`ingress_domain`/`enabled`. `CHECK (id = 1)` enforces the singleton. - **`chat/0048_email_inboxes.sql`**: per-room inboxes table. LC-74 webhooks (`chat/0041`) mirror: `ON DELETE CASCADE` on `room_id`, soft-delete via `revoked_at`, HMAC-only `secret_hash`, audit columns (`created_by`, `created_at`, `last_used_at`). - **`chat/0049_messages_email_inbox_id.sql`**: `ALTER TABLE messages ADD COLUMN email_inbox_id INTEGER REFERENCES email_inboxes(id) ON DELETE SET NULL`. Parallel to `webhook_id`. The FK is in place because `email_inboxes` exists at migration time, unlike `webhook_id` which had to predate `incoming_webhooks` in the LC-74 ordering. ## Named decisions - **Uploads `uploader_id=""` sentinel sticks.** `file_uploads.uploader_id` (`chat/0012_uploads.sql:3`) is `TEXT NOT NULL` with no FK constraint, so an empty-string sentinel works for the email-ingress attachment path the same way `messages.user_id=''` already works for webhook posts. No schema change needed in this PR; commit 5 will call `db::uploads::insert_upload(..., uploader_id="", ...)` directly. - **SMTP password stays plaintext for now.** IMAP password is sealed via the VAPID precedent in this PR, but the existing `settings.smtp_pass` row remains plaintext. The inconsistency is real and worth fixing; tracked as the `LC-77-SMTP-SEAL` followup (own ticket, own PR) so scope here stays bounded. ## Type updates - `models::Message` + `db::chat::RawMessage`: new `email_inbox_id: Option<i64>` field, parallel to `webhook_id`. - `db::chat`: every `SELECT FROM messages` projection (5 sites) appends `email_inbox_id`; `row_to_raw` mapper appends `row.get("email_inbox_id")`. - `routes/admin.rs`, `routes/room.rs` (3 sites), `routes/ws.rs`: every `Message {}` struct literal adds the new field. Pass-through sites use `raw.email_inbox_id`; the webhook-finalize site (`routes/room.rs:805`) sets it to `None` (a webhook post is never also an email-inbox post). - `db::email_inbox` (new): `identity(pool, id) -> Option<EmailInboxIdentity { name, avatar_url }>`. Mirrors `db::webhooks::identity` field-for-field; the render layer in commit 3 will drop it in alongside the webhook arm. ## Test drift cleanup CLAUDE.md test-maintenance category 2 (migration-list drift) materialized exactly as predicted: 18 test files hand-roll the chat migration list (17 use array-form `include_str!` blocks, 1 uses the verbose per-block form in `db_private_rooms.rs`), and 7 hand-roll the settings list (all array form). Appended the two new chat migrations and the one new settings migration to each via the file's existing pattern. The drift surfaced as `routes_uploads::send_message_with_attachment_renders_inline_image` returning 500 from `POST /room/1/messages` because sqlx hit the missing `email_inbox_id` column at one of the projections that selects it. ## Test plan - [x] `cargo check --workspace` and `cargo check --no-default-features --features lets-chat-server/saas` both clean. - [x] All 51 `lets-chat-server` test binaries pass under both default and saas feature sets. - [x] New `tests/db_email_inbox_schema.rs`: 3 round-trip tests cover all three new schemas plus the `CASCADE` / `SET NULL` FK behaviors plus the `CHECK (id = 1)` singleton constraint. - [x] `cargo clippy -p lets-chat-server --tests -- -D warnings` clean. - [x] `cargo fmt --check` clean for in-scope files. Pre-existing `views/math.rs` + `views/markdown.rs` fmt diffs predate this branch (LC-59 carryover on `main`); tracked as `LC-77-FMT-CARRYOVER` followup. ## Anti-scope - No IMAP poll loop. That lands in commit 3 with the dependencies (mail-parser, async-imap) and the runtime gate. - No admin UI. Commit 4. - No MIME parsing. Commit 5. - No SMTP password migration to sealed-at-rest. Separate followup ticket. ## Next Commit 3 builds on this branch's `main` once merged: `spawn_email_poll`, address resolution (`Delivered-To`/`X-Original-To`/`To`/`Cc` precedence), synthetic-actor send via `db::chat::insert_email_inbox_message`, exhaustive `email_ingress::drop` log taxonomy, and the `MessageActor::EmailInbox` template arm.
feat(email-ingress): schema for IMAP config + email inboxes (LC-77)
Some checks failed
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
Check / clippy + fmt + tests (pull_request) Failing after 13s
Create release / Create release from merged PR (pull_request) Has been skipped
b590185a34
Three migrations plus the supporting type updates so commit 3's IMAP poll loop has a place to land. No runtime behavior yet; the email-ingress feature is gated entirely by commit 3's spawn_email_poll.

Migrations:

- settings/0005_imap_inbox_config.sql: singleton encrypted-at-rest config row mirroring vapid_keypair (settings/0003). AES-256-GCM sealed password + nonce; plaintext host/port/tls/username/folder/ingress_domain/enabled. CHECK (id = 1) enforces the singleton.
- chat/0048_email_inboxes.sql: per-room inboxes table. LC-74 webhooks (chat/0041) mirror: ON DELETE CASCADE on room_id, soft-delete via revoked_at, HMAC-only secret_hash, audit columns (created_by, created_at, last_used_at).
- chat/0049_messages_email_inbox_id.sql: ALTER TABLE messages ADD COLUMN email_inbox_id INTEGER REFERENCES email_inboxes(id) ON DELETE SET NULL. Parallel to webhook_id; lets a synthetic email-actor row coexist with the existing webhook synthetic-actor shape.

Uploads sentinel verification (CLAUDE.md commit-2 prerequisite): confirmed db::uploads::insert_upload(..., uploader_id="", ...) works for the email-ingress attachment path before commit 5 builds on it. file_uploads.uploader_id (chat/0012_uploads.sql:3) is TEXT NOT NULL with no FK constraint, so the empty-string sentinel pattern is fine; no schema change. LC-77 v1 attachments will use the same pattern messages.user_id='' already uses for webhook posts. SMTP-password-still-plaintext finding tracked separately (LC-77-SMTP-SEAL followup).

Type updates:

- models::Message + db::chat::RawMessage: new email_inbox_id: Option<i64> field, parallel to webhook_id.
- db::chat: every SELECT messages projection (5 sites) appends email_inbox_id; row_to_raw mapper appends row.get("email_inbox_id").
- routes/admin.rs, routes/room.rs (3 sites), routes/ws.rs: every Message {} struct literal adds email_inbox_id (passes raw.email_inbox_id through, or None at the webhook-finalize site where the field is always None).
- db::email_inbox (new): identity(pool, id) -> Option<EmailInboxIdentity { name, avatar_url }>, mirrors db::webhooks::identity for the commit 3 render layer.

Test drift cleanup (CLAUDE.md test-maintenance category 2 / migration-list drift): 18 test files hand-roll the chat migration list (17 array form, 1 verbose per-block form in db_private_rooms.rs); 7 hand-roll the settings list (all array form). Appended the two new chat migrations and the one new settings migration to each via the file's existing pattern. The drift surfaced as routes_uploads::send_message_with_attachment_renders_inline_image returning 500 from POST /room/1/messages (sqlx hit the missing email_inbox_id column at the projection that selects it), exactly the symptom CLAUDE.md predicts.

Verification: all 51 lets-chat-server test binaries pass under both default and saas feature sets. New tests/db_email_inbox_schema.rs: 3 round-trip tests covering all three new schemas plus the CASCADE / SET NULL FK behaviors and the singleton CHECK constraint. cargo clippy -p lets-chat-server --tests -- -D warnings clean. cargo fmt --check clean for in-scope files; pre-existing math.rs/markdown.rs LC-59 fmt carryover tracked as the LC-77-FMT-CARRYOVER followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-77-schema 2026-05-25 18:43:19 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!195
No description provided.