Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
- **ORM**: Drizzle ORM 0.45.x with Drizzle Kit 0.31.x
- **Validation**: Zod 4.x + `@hono/standard-validator`
- **OpenAPI**: `hono-openapi` + `@scalar/hono-api-reference`
- **Database**: SQLite (default, via `@libsql/client`), PostgreSQL, MySQL
- **Database**: SQLite at runtime (`drizzle-orm/bun-sqlite`); PostgreSQL and MySQL schemas are kept in sync, but runtime support isn't wired up yet (#10)
- **Mail**: IMAP (`imapflow`) + SMTP (`nodemailer`) + `postal-mime` parsing
- **Crypto**: `elliptic` (ECC), custom encryption utilities
- **Scheduling**: `cron` package
Expand Down Expand Up @@ -77,13 +77,16 @@ src/

- **API versioning**: Routes live under `src/api/versions/v{n}/routes/`. Each route module has an `index.ts` (router) and `model.ts` (Zod schemas).
- **OpenAPI specs**: Use `hono-openapi` decorators on route handlers via `APIRouteSpec`/`APIResponseSpec` helpers in `specHelpers.ts` (`summary`, `description`, `tags`). Tags come from `DOCS_TAGS` in `versions/v1/docs/index.ts`. When adding a new tag, also register it in the `tags` array **and** an `x-tagGroups` group in `versions/v1/index.ts`, or it renders orphaned in Scalar. The spec is served at `/docs/v1/openapi` and the Scalar UI at `/docs/v1` (both mounted in `api/index.ts`, gated by `DLA_DISABLE_DOCS`).
- **Database**: Schema files are per-dialect in `src/db/schema/`. Migrations managed via Drizzle Kit, schema ones with `bun db:sqlite:generate` and data ones with `drizzle-kit generate --custom --name=…`; both are plain SQL under `drizzle/migrations/sqlite/` and run from `DB.init()` when `DLA_DB_AUTO_MIGRATE` is set. A data migration is written to be idempotent (guard with `WHERE NOT EXISTS`) and can only use what SQL can see — the mail account connection data is encrypted, so an address or credential is not reachable from a migration.
- **Database**: Schema files are per-dialect in `src/db/schema/`. Migrations managed via Drizzle Kit, schema ones with `bun db:sqlite:generate` and data ones with `drizzle-kit generate --custom --name=…`; both are plain SQL under `drizzle/migrations/sqlite/` and run from `DB.init()` unless `DLA_DB_AUTO_MIGRATE=false`. A data migration is written to be idempotent (guard with `WHERE NOT EXISTS`) and can only use what SQL can see — the mail account connection data is encrypted, so an address or credential is not reachable from a migration.
- **Auth**: JWT-based auth via `authHandler.ts`. Middleware in `src/api/versions/v1/middleware/auth.ts`.
- **Validation**: Zod schemas in `model.ts` files, validated via `@hono/standard-validator`.
- **Tests**: Integration-heavy. `bunfig.toml` preloads `tests/helpers/preload.ts`, which builds the app with `API.init()` but never binds a port — requests go through `API.getApp().request()` in-process, so the suite runs while a dev server is up. Mock IMAP servers do listen: `11143` for the shared one, `11144`–`11148` for per-test servers.
- **Config**: Environment-based config in `src/utils/config.ts`. See `example.env` for required vars.
- **Crypto**: ECC-based encryption/signing utilities in `src/utils/crypto/`.
- **Docker**: `docker/Dockerfile` packages a binary compiled beforehand (`bun run compile linux-x64-baseline --no-version-tag`, as CI does) together with `drizzle/migrations`, which the binary reads from disk. A relative `DLA_DB_MIGRATION_DIR` therefore has to be resolved next to the executable (`process.execPath`), not against `import.meta.dir`, which is Bun's virtual `/$bunfs/root` in a compiled binary. `docker/docker-compose.yml` runs the full stack (API + Delivr-Web) from the ghcr.io images; it requires *public* URLs, since the web client's SSR server calls the API through the same `DELIVR_API_URL` as the browser.
- **Docker**: `docker/Dockerfile` packages a binary compiled beforehand (`bun run compile linux-x64-baseline --no-version-tag`, as CI does) together with `drizzle/migrations`, which the binary reads from disk. A relative `DLA_DB_MIGRATION_DIR` therefore has to be resolved next to the executable (`process.execPath`), not against `import.meta.dir`, which is Bun's virtual `/$bunfs/root` in a compiled binary. `docker/docker-compose.yml` runs the API alone and `docker/docker-compose.prodlike.yml` the full stack (API + Delivr-Web) from the ghcr.io images; it requires *public* URLs, since the web client's SSR server calls the API through the same `DELIVR_API_URL` as the browser.
- **Client addresses**: login rate limiting keys on the socket address (`getConnInfo` from `hono/bun`). In-process test requests have no socket and fall back to `unknown`, so one test serves the app on an ephemeral port to cover the socket path.
- **Deleting users and mail accounts** goes through `AccountDeletionService`. SQLite doesn't enforce the schema's foreign keys, so every table that references a user or mail account must be cleaned up there, or its rows are orphaned.
- **Transactions are only atomic with a synchronous callback.** The bun-sqlite driver commits as soon as the `transaction()` callback returns, so an `async` callback commits at its first `await` and nothing after it rolls back. Many existing routes still pass `async` callbacks; new code that relies on rollback (like `AccountDeletionService`) must use `.run()`/`.all()`/`.get()` without `await`.

## Architecture Notes

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ speaking IMAP and SMTP so your inbox stays yours.
- 🔐 **JWT auth + API keys** — token-based sessions plus scoped API keys for programmatic access.
- 🔒 **ECC crypto** — mail-backend credentials protected with elliptic-curve encryption and signing.
- 📖 **First-class OpenAPI** — every route is documented via `hono-openapi` and browsable through an embedded [Scalar](https://scalar.com) reference.
- 🗄️ **Bring your own database** — SQLite out of the box, with PostgreSQL and MySQL fully supported through Drizzle.
- 🗄️ **Zero-setup database** — SQLite out of the box, with no separate database server to run. PostgreSQL and MySQL schemas exist and runtime support is planned ([#10](https://github.com/Delivr-Project/Delivr-API/issues/10)).
- ⏰ **Scheduled tasks** — background jobs via the `cron` package.
- ✅ **Integration-tested** — a mock IMAP/SMTP harness exercises the real request paths.

Expand All @@ -47,7 +47,7 @@ speaking IMAP and SMTP so your inbox stays yours.
| ORM | **Drizzle ORM** + Drizzle Kit |
| Validation | **Zod 4.x** + `@hono/standard-validator` |
| API Docs | `hono-openapi` + `@scalar/hono-api-reference` |
| Database | SQLite · PostgreSQL · MySQL |
| Database | SQLite (PostgreSQL · MySQL planned) |
| Mail | `imapflow` (IMAP) · `nodemailer` (SMTP) · `postal-mime` |
| Crypto | `elliptic` (ECC) |

Expand Down Expand Up @@ -87,7 +87,7 @@ All configuration is environment-based (see [`example.env`](./example.env)):
| `DLA_ENCRYPTION_KEY` | **Required. 32-character** key for credential encryption | — |
| `DLA_DB_CONNECTION_URL` | Database connection string / path | `./data/db.sqlite` |
| `DLA_DB_AUTO_MIGRATE` | Run migrations on startup | `true` |
| `DLA_DB_MIGRATION_DIR` | Dir with the migrations files | `has to be set manually` |
| `DLA_DB_MIGRATION_DIR` | Dir with the migrations files. The compiled binary resolves a relative path next to the executable, not the working directory | `has to be set manually` |
| `DLA_LOG_DIR` | Log output directory | `./data/logs` |
| `DLA_CONFIG_BASE_DIR` | Config base directory | `./config` |
| `DLA_SMTP_HOST` | Outbound SMTP host for system mail (e.g. password-reset emails) | — |
Expand All @@ -112,8 +112,8 @@ The attachment limit counts the files' raw size. Base64 encoding makes the sent
| `bun run start` | Production entry point |
| `bun run db:sqlite:generate` | Generate SQLite migrations |
| `bun run db:sqlite:migrate` | Run SQLite migrations |
| `bun run db:postgresql:generate` · `:migrate` | PostgreSQL migrations |
| `bun run db:mysql:generate` · `:migrate` | MySQL migrations |
| `bun run db:postgresql:generate` · `:migrate` | PostgreSQL migrations (schema only, runtime support planned) |
| `bun run db:mysql:generate` · `:migrate` | MySQL migrations (schema only, runtime support planned) |

## 🗺️ Project Structure

Expand Down
2 changes: 1 addition & 1 deletion docker/docker-compose.prodlike.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ services:
DELIVR_ENABLE_SIGNUP: false

depends_on:
api:
delivr-api:
condition: service_healthy
28 changes: 28 additions & 0 deletions drizzle/migrations/sqlite/0015_purge_orphaned_user_data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- `DELETE /admin/users/:userId` used to remove only the user row, its sessions,
-- API keys and password resets. SQLite doesn't enforce the schema's foreign
-- keys, so the user's mail accounts — including their encrypted IMAP/SMTP
-- credentials — identities, special-use mappings and preferences stayed behind,
-- unreachable through the API. Remove them.
--
-- Only rows whose owner no longer exists match, so a repeated run is a no-op.
-- Dependents of a mail account go first, while the account still tells us whose
-- it was.
DELETE FROM `mail_identities`
WHERE `mail_account_id` NOT IN (
SELECT `id` FROM `mail_accounts` WHERE `owner_user_id` IN (SELECT `id` FROM `users`)
);
--> statement-breakpoint
DELETE FROM `mail_account_special_use`
WHERE `mail_account_id` NOT IN (
SELECT `id` FROM `mail_accounts` WHERE `owner_user_id` IN (SELECT `id` FROM `users`)
);
--> statement-breakpoint
DELETE FROM `mail_accounts` WHERE `owner_user_id` NOT IN (SELECT `id` FROM `users`);
--> statement-breakpoint
DELETE FROM `user_preferences` WHERE `user_id` NOT IN (SELECT `id` FROM `users`);
--> statement-breakpoint
DELETE FROM `sessions` WHERE `user_id` NOT IN (SELECT `id` FROM `users`);
--> statement-breakpoint
DELETE FROM `api_keys` WHERE `user_id` NOT IN (SELECT `id` FROM `users`);
--> statement-breakpoint
DELETE FROM `password_resets` WHERE `user_id` NOT IN (SELECT `id` FROM `users`);
Loading
Loading