diff --git a/.env.example b/.env.example index d5b09b5a..9a16431b 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,9 @@ OP_ACCOUNT="team-futo.1password.com" # POLAR_WEBHOOK_SECRET= # POLAR_PRODUCT_ID=d9bc9054-eb91-46c9-846e-dd5bbb8f0041 # POLAR_APPLY_DISCOUNT_ID=391d9f04-889a-44ff-95f1-98cee8a5b0db + +# Postmark: defaults target the local mock (compose/Tilt). Set both to send +# real email from a dev machine. +# POSTMARK_API_URL="https://api.postmarkapp.com" +# POSTMARK_SERVER_TOKEN="op://yucca_tf_staging/POSTMARK_SERVER_TOKEN/password" +# EMAIL_FROM_ADDRESS="FUTO Backups " diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edd9e8d2..27c3e9a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: run: sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc || true # Cluster bring-up and the workspace install touch none of the same files, - # so they overlap. Only @common/server is built: the suites run from + # so they overlap. Only the @common/* libs are built: the suites run from # TypeScript source via ts-jest, so `mise prepare`'s other packages are # never loaded here. - name: Stand up k3d infra, install deps and build shared libs @@ -87,6 +87,7 @@ jobs: infra=$! mise run install:frozen mise run common:server:build + mise run common:emails:build wait "$infra" || { echo "::error::k3d infra failed"; cat /tmp/k3d-infra.log; exit 1; } echo "::group::k3d infra log"; cat /tmp/k3d-infra.log; echo "::endgroup::" diff --git a/.mise/tasks/yucca-admin-api/env b/.mise/tasks/yucca-admin-api/env index 432f7b19..e32820a6 100755 --- a/.mise/tasks/yucca-admin-api/env +++ b/.mise/tasks/yucca-admin-api/env @@ -24,6 +24,12 @@ export TOPOLOGY_FILE=${TOPOLOGY_FILE:-./topology.dev.json} export LEGACY_SITE_CODE=${LEGACY_SITE_CODE:-local} export LEGACY_STORAGE_CLUSTER_CODE=${LEGACY_STORAGE_CLUSTER_CODE:-local-dev} +# Invite emails: the compose mock Postmark (delivering into Mailpit on :8025). +# Point POSTMARK_API_URL + a real token at Postmark to test actual delivery. +export POSTMARK_API_URL=${POSTMARK_API_URL:-http://localhost:8093} +export POSTMARK_SERVER_TOKEN=${POSTMARK_SERVER_TOKEN:-dev token} +export WEB_BASE_URL=${WEB_BASE_URL:-http://localhost:5173} + export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092} export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID} export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret} diff --git a/CLAUDE.md b/CLAUDE.md index 24e31e01..deb80857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ followed by `tilt:ci-e2e` (the apps the e2e suites touch). Ceph converging outwe in the integration job, so that job dropped it and its S3-backed suites moved to the e2e one. Ports: `5173` web · `3020` yucca-api · `3030` yucca-admin-api · `3010` michael · -`8092` mock-oidc · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs. +`8092` mock-oidc · `8025` mailpit · `8093` mock-postmark · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs. ### Infrastructure commands @@ -110,7 +110,9 @@ Zod-validated `env.ts`, JWT auth guards via `@AuthRoute()`, OTel from `@common/s | `yucca-metrics-worker` | NestJS | 5-min cron: RadosGW usage → meter tables → per-connection rollup (`connectionMetrics`, billing floor), OTel gauges. | | `redis` (valkey) | | Shared platform cache (ephemeral; keys `yucca:::*`). Primary-region only. | | `mock-oidc-provider` | Node | Dev/test OIDC IdP (code + device flow). | -| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**. | +| `mock-postmark-provider` | Node | Dev/test Postmark API mock; delivers into the Mailpit inbox. | +| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**, Postmark `EmailRepository` (`@common/server/email`). | +| `emails` (`@common/emails`) | Svelte lib | Transactional email templates (better-svelte-email, web theme), prebuilt to JS for the NestJS apps. See `docs/email.md`. | **Frontend** (`packages/web`): SvelteKit 5 + Tailwind 4, `@immich/ui`, lingui i18n (`mise web:lingui:*`; compiled locales are generated, not edited), generated API client. diff --git a/Tiltfile b/Tiltfile index 2ef6d291..ab9f21f5 100644 --- a/Tiltfile +++ b/Tiltfile @@ -216,9 +216,11 @@ docker_build( live_update=[ sync('./packages/yucca-admin-api', '/app/packages/yucca-admin-api'), sync('./packages/common', '/app/packages/common'), + sync('./packages/emails', '/app/packages/emails'), # yucca-admin-api/src/schema is a symlink into yucca-api; keep it synced. sync('./packages/yucca-api', '/app/packages/yucca-api'), run('cd /app && pnpm --filter @common/server build', trigger=['./packages/common/src']), + run('cd /app && pnpm --filter @common/emails build', trigger=['./packages/emails/src']), ], ) @@ -333,7 +335,7 @@ APP_WIRING = { # calls it at boot. Tilt builds an image only when ready to deploy, so that # edge parked this build (and web's) behind Rook-Ceph instead of alongside. 'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True}, - 'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_keypair': True}, + 'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True}, 'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user', 'yucca-topology'], 'dev_env': True}, # Likewise: the dev server reaches yucca-api per request, not at boot. 'yucca-web': {'build': 'web', 'deps': []}, diff --git a/charts/apps/yucca-admin-api/values.yaml b/charts/apps/yucca-admin-api/values.yaml index 1ffbd1f9..8c9e3ce5 100644 --- a/charts/apps/yucca-admin-api/values.yaml +++ b/charts/apps/yucca-admin-api/values.yaml @@ -43,6 +43,9 @@ oidcLogoutRedirectUri: http://localhost:3030 secretData: OIDC_ADMIN_CLIENT_ID: "client ID" OIDC_ADMIN_CLIENT_SECRET: "client secret" + # Any value satisfies the mock Postmark; prod gets the real token from the + # TF-provisioned Secret. + POSTMARK_SERVER_TOKEN: "dev token" # OPT-IN dev signing key for CLI session JWTs. The project's well-known # local-dev ES256 keypair (the same one committed in .mise/tasks/*/env and the @@ -79,6 +82,13 @@ env: value: http://victoria-metrics:8428/opentelemetry/v1/metrics - name: OTEL_LOGGING value: http://victoria-logs:9428/insert/opentelemetry/v1/logs + # Invite emails land in the dev mock (→ Mailpit); the prod env list in the + # base HelmRelease omits this so the real Postmark default applies. + - name: POSTMARK_API_URL + value: http://yucca-mock-postmark:8093 + # Invite links resolve via the Tilt web port-forward. + - name: WEB_BASE_URL + value: http://localhost:5173 # Probes keep `tilt ci`/Flux honest: without them a crash-looping dev process # still counts as Ready (this masked two real bugs). The startupProbe budgets diff --git a/docs/email.md b/docs/email.md new file mode 100644 index 00000000..e9e3758e --- /dev/null +++ b/docs/email.md @@ -0,0 +1,69 @@ +# Email + +Yucca sends transactional email through [Postmark](https://postmarkapp.com/). The first (and so +far only) sender is the invite flow in yucca-admin-api; anything that later needs to email a user +builds on the same three pieces. + +## Architecture + +| Piece | Where | Role | +|---|---|---| +| `@common/emails` | `packages/emails` | Svelte email templates (better-svelte-email, Tailwind 4) styled after the web UI's `@immich/ui` theme, prebuilt to plain JS. Exposes `render*Email(props) → { subject, htmlBody, textBody }`. | +| `EmailRepository` | `@common/server/email` | Postmark HTTP client over global `fetch` (`POST /email`, `POST /email/batch`, ≤500 per batch). Stamps `From`, the `outbound` message stream, and an optional `Tag`. | +| Callers | e.g. `AllowlistService` | Render a template, hand the result to `EmailRepository`, record the outcome. | + +Services wire `EmailRepository` into their `providers` array like any other repository; templates +are imported as functions. The renderer never runs in the request path of the web app — emails are +rendered inside the NestJS process from the prebuilt `@common/emails` dist. + +**Degraded mode:** without `POSTMARK_SERVER_TOKEN` the repository logs and skips every send. +Environments that don't care about email keep working; nothing crashes at boot. + +## Invite flow + +`POST /allowlist/invite` and `POST /allowlist/invite-batch` (yucca-admin-api, driven by yuctl) +email every affected entry whose `userAllowlist.inviteEmailSentAt` is still null, then stamp it on +success. Re-running an invite is therefore a safe retry that only reaches the not-yet-emailed +entries; a rejected address stays null and is retried next time. The email carries the invite code +and links to `${WEB_BASE_URL}/login/invite`. + +## Configuration + +| Env var | Meaning | Default | +|---|---|---| +| `POSTMARK_SERVER_TOKEN` | Postmark server API token (secret) | unset → log-and-skip | +| `POSTMARK_API_URL` | Postmark endpoint; dev points it at the mock | `https://api.postmarkapp.com` | +| `EMAIL_FROM_ADDRESS` | `From` header | `FUTO Backups ` | +| `WEB_BASE_URL` | Base for links in emails (yucca-admin-api) | `http://localhost:5173` | + +The boundary rule from [feature-flags.md](feature-flags.md) applies: these are deployment config +(env/Secret/cluster-settings), not feature flags. In staging/prod the token rides in the +TF-provisioned `yucca-admin-api` Secret; the from-address and `WEB_BASE_URL` come from the base +HelmRelease + cluster-settings. + +## Local dev + +Nothing leaves the machine. The app speaks the real Postmark wire protocol to an in-repo mock, +which delivers into a [Mailpit](https://mailpit.axllent.org/) inbox: + +- **compose (`mise dev`)**: `mock-postmark-provider` on `localhost:8093`, Mailpit UI on + `http://localhost:8025`. The yucca-admin-api dev env defaults point at the mock. +- **k3d/Tilt**: `yucca-mock-postmark` + `yucca-mailpit` (dev-only HelmReleases under + `kubernetes/apps/dev/local`), same ports via the Tilt port-forwards. +- **e2e/tests**: assert through Mailpit's REST API (`GET /api/v1/messages`, search, message body); + unit/integration tests mock or override `EmailRepository` instead. + +To send *real* email from a dev machine, set `POSTMARK_API_URL` + `POSTMARK_SERVER_TOKEN` in +`.env` (see `.env.example`). + +## Adding a template + +1. Add `packages/emails/src/emails/.svelte` (compose with `src/lib/layout.svelte` and + `@better-svelte-email/components`; Tailwind classes only — the renderer inlines them). +2. Export a typed `renderEmail()` from `packages/emails/src/index.ts`. +3. Preview while iterating: `pnpm --filter @common/emails preview`. +4. Call it from a service and pass the result to `EmailRepository.send`/`sendBatch` with a `tag`. + +Email HTML is its own dialect: no CSS variables, no `oklch()`, table-friendly markup — which is +why `src/theme.ts` mirrors the `@immich/ui` palette as literal hex and templates never import web +components directly. diff --git a/packages/yucca-admin-api/Dockerfile b/packages/yucca-admin-api/Dockerfile index 4c343fc2..6518f97f 100644 --- a/packages/yucca-admin-api/Dockerfile +++ b/packages/yucca-admin-api/Dockerfile @@ -42,7 +42,7 @@ RUN pnpm install --frozen-lockfile # 2. Copy sources, build workspace libs yucca-admin-api imports at runtime COPY . ./ -RUN pnpm --filter @common/server build +RUN pnpm --filter @common/server build && pnpm --filter @common/emails build EXPOSE 3030 CMD ["pnpm", "--filter", "yucca-admin-api", "start:dev"] diff --git a/packages/yucca-admin-api/openapi-specs.json b/packages/yucca-admin-api/openapi-specs.json index b4888505..ab5685cf 100644 --- a/packages/yucca-admin-api/openapi-specs.json +++ b/packages/yucca-admin-api/openapi-specs.json @@ -838,6 +838,88 @@ ] } }, + "/api/settings": { + "get": { + "operationId": "listSettings", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsListResponseDto" + } + } + } + } + }, + "tags": [ + "Settings" + ] + } + }, + "/api/settings/{scope}": { + "put": { + "operationId": "setSettings", + "parameters": [ + { + "name": "scope", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsValueDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SettingsEntryResponseDto" + } + } + } + } + }, + "tags": [ + "Settings" + ] + }, + "delete": { + "operationId": "deleteSettings", + "parameters": [ + { + "name": "scope", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "tags": [ + "Settings" + ] + } + }, "/api/features": { "get": { "operationId": "listFeatures", @@ -1268,6 +1350,14 @@ "worm": { "type": "boolean" }, + "siteCode": { + "type": "string", + "description": "Stable internal site code" + }, + "storageClusterCode": { + "type": "string", + "description": "Stable, globally unique internal storage cluster code" + }, "connectionId": { "type": "string" }, @@ -1285,6 +1375,8 @@ "id", "name", "worm", + "siteCode", + "storageClusterCode", "connectionId", "connectionType", "user", @@ -1322,6 +1414,10 @@ "worm": { "type": "boolean" }, + "site": { + "type": "string", + "description": "Internal site code to place the repository in; defaults to the topology default site" + }, "connectionType": { "type": "string", "enum": [ @@ -1412,6 +1508,10 @@ "type": "string", "nullable": true }, + "inviteEmailSentAt": { + "type": "string", + "nullable": true + }, "createdAt": { "type": "string" } @@ -1508,6 +1608,63 @@ "count" ] }, + "SettingsValueDto": { + "type": "object", + "properties": { + "restic_pack_size_mib": { + "type": "number" + }, + "connections_math": { + "type": "string", + "description": "Client-evaluated expression: integers, cores, min, max, + - * /" + } + } + }, + "SettingsEntryDto": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "'global', 'site:' or 'cluster:'" + }, + "value": { + "$ref": "#/components/schemas/SettingsValueDto" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "scope", + "value", + "updatedAt" + ] + }, + "SettingsListResponseDto": { + "type": "object", + "properties": { + "settings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SettingsEntryDto" + } + } + }, + "required": [ + "settings" + ] + }, + "SettingsEntryResponseDto": { + "type": "object", + "properties": { + "entry": { + "$ref": "#/components/schemas/SettingsEntryDto" + } + }, + "required": [ + "entry" + ] + }, "FeatureFlagDefDto": { "type": "object", "properties": { diff --git a/packages/yucca-admin-api/package.json b/packages/yucca-admin-api/package.json index 1fdbbdb8..7847f942 100644 --- a/packages/yucca-admin-api/package.json +++ b/packages/yucca-admin-api/package.json @@ -20,6 +20,7 @@ "dist" ], "dependencies": { + "@common/emails": "workspace:^", "@common/server": "workspace:^", "@immich/sql-tools": "catalog:", "@nestjs/common": "catalog:", diff --git a/packages/yucca-admin-api/src/app.module.ts b/packages/yucca-admin-api/src/app.module.ts index 7f9eb324..e9f512d6 100644 --- a/packages/yucca-admin-api/src/app.module.ts +++ b/packages/yucca-admin-api/src/app.module.ts @@ -1,3 +1,4 @@ +import { EmailRepository } from '@common/server/email'; import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel'; import { Module } from '@nestjs/common'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; @@ -61,6 +62,7 @@ export const controllers = [ export const providers = [ WideContextRepository, LoggerRepository, + EmailRepository, DatabaseRepository, DatabaseService, OidcRepository, diff --git a/packages/yucca-admin-api/src/dto/allowlist.dto.ts b/packages/yucca-admin-api/src/dto/allowlist.dto.ts index 51ad635c..4ab6b001 100644 --- a/packages/yucca-admin-api/src/dto/allowlist.dto.ts +++ b/packages/yucca-admin-api/src/dto/allowlist.dto.ts @@ -21,6 +21,9 @@ export class AllowlistEntryDto { @ApiProperty({ type: 'string', required: false, nullable: true }) inviteUsedAt!: Date | null; + @ApiProperty({ type: 'string', required: false, nullable: true }) + inviteEmailSentAt!: Date | null; + @ApiProperty({ type: 'string' }) createdAt!: Date; } diff --git a/packages/yucca-admin-api/src/env.ts b/packages/yucca-admin-api/src/env.ts index 3dc73042..48b54adb 100644 --- a/packages/yucca-admin-api/src/env.ts +++ b/packages/yucca-admin-api/src/env.ts @@ -34,6 +34,8 @@ const schema = z.object({ LEGACY_SITE_CODE: z.string(), LEGACY_STORAGE_CLUSTER_CODE: z.string(), + WEB_BASE_URL: z.url().default('http://localhost:5173'), + OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)), OIDC_ADMIN_CLIENT_ID: z.string(), OIDC_ADMIN_CLIENT_SECRET: z.string(), diff --git a/packages/yucca-admin-api/src/services/allowlist.service.spec.ts b/packages/yucca-admin-api/src/services/allowlist.service.spec.ts new file mode 100644 index 00000000..995f82f1 --- /dev/null +++ b/packages/yucca-admin-api/src/services/allowlist.service.spec.ts @@ -0,0 +1,97 @@ +import { AllowlistService } from 'src/services/allowlist.service'; +import { Mocks, newMocks } from '../../test/mocks'; + +const row = (overrides: object = {}) => ({ + id: 'entry-1', + email: 'user@example.com', + inviteCode: 'ABC123DEF4', + invited: true, + inviteUsed: false, + inviteUsedAt: null, + inviteEmailSentAt: null, + createdAt: new Date('2026-08-01'), + ...overrides, +}); + +describe(AllowlistService.name, () => { + let mocks: Mocks; + let sut: AllowlistService; + + beforeEach(() => { + mocks = newMocks(); + sut = new AllowlistService(mocks.allowlist as never, mocks.email as never, mocks.logger); + }); + + describe('invite', () => { + it('emails newly invited entries and records the send', async () => { + const created = row(); + const sent = row({ inviteEmailSentAt: new Date('2026-08-19') }); + mocks.allowlist.create.mockResolvedValue(created as never); + mocks.allowlist.update.mockResolvedValue(sent as never); + mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 0, message: 'OK' }]); + + const { items } = await sut.invite({ emails: ['User@Example.com'] }); + + expect(mocks.email.sendBatch).toHaveBeenCalledWith([ + expect.objectContaining({ to: 'user@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }), + ]); + const [messages] = mocks.email.sendBatch.mock.calls[0]; + expect(messages[0].htmlBody).toContain('ABC123DEF4'); + expect(messages[0].htmlBody).toContain('/login/invite'); + expect(messages[0].textBody).toContain('ABC123DEF4'); + expect(mocks.allowlist.update).toHaveBeenCalledWith('entry-1', { inviteEmailSentAt: expect.any(Date) }); + expect(items).toEqual([sent]); + }); + + it('does not resend to entries that already got their invite email', async () => { + const delivered = row({ inviteEmailSentAt: new Date('2026-08-02') }); + mocks.allowlist.getByEmail.mockResolvedValue(delivered as never); + + const { items } = await sut.invite({ emails: ['user@example.com'] }); + + expect(mocks.email.sendBatch).not.toHaveBeenCalled(); + expect(mocks.allowlist.update).not.toHaveBeenCalled(); + expect(items).toEqual([delivered]); + }); + + it('keeps rejected sends retryable', async () => { + const created = row(); + mocks.allowlist.create.mockResolvedValue(created as never); + mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 406, message: 'Inactive recipient' }]); + + const { items } = await sut.invite({ emails: ['user@example.com'] }); + + expect(mocks.allowlist.update).not.toHaveBeenCalled(); + expect(items).toEqual([created]); + expect(mocks.logger.warn).toHaveBeenCalled(); + }); + + it('returns entries unchanged when the batch send fails outright', async () => { + const created = row(); + mocks.allowlist.create.mockResolvedValue(created as never); + mocks.email.sendBatch.mockRejectedValue(new Error('postmark down')); + + const { items } = await sut.invite({ emails: ['user@example.com'] }); + + expect(items).toEqual([created]); + expect(mocks.logger.error).toHaveBeenCalled(); + }); + }); + + describe('inviteBatch', () => { + it('invites the oldest staged entries and emails them', async () => { + const staged = row({ invited: false }); + const invited = row(); + const sent = row({ inviteEmailSentAt: new Date('2026-08-19') }); + mocks.allowlist.oldestStaged.mockResolvedValue([staged] as never); + mocks.allowlist.update.mockResolvedValueOnce(invited as never).mockResolvedValueOnce(sent as never); + mocks.email.sendBatch.mockResolvedValue([{ to: staged.email, errorCode: 0, message: 'OK' }]); + + const { items } = await sut.inviteBatch({ count: 1 }); + + expect(mocks.allowlist.update).toHaveBeenNthCalledWith(1, 'entry-1', { invited: true }); + expect(mocks.allowlist.update).toHaveBeenNthCalledWith(2, 'entry-1', { inviteEmailSentAt: expect.any(Date) }); + expect(items).toEqual([sent]); + }); + }); +}); diff --git a/packages/yucca-admin-api/src/services/allowlist.service.ts b/packages/yucca-admin-api/src/services/allowlist.service.ts index ecc69f1a..a7f996d6 100644 --- a/packages/yucca-admin-api/src/services/allowlist.service.ts +++ b/packages/yucca-admin-api/src/services/allowlist.service.ts @@ -1,3 +1,6 @@ +import { renderInviteEmail } from '@common/emails'; +import { EmailMessage, EmailRepository } from '@common/server/email'; +import { LoggerRepository } from '@common/server/otel'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { AllowlistAddRequestDto, @@ -9,13 +12,18 @@ import { AllowlistListQueryDto, AllowlistListResponseDto, } from 'src/dto/allowlist.dto'; +import { env } from 'src/env'; import { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository'; import { generateInviteCode } from 'src/utils/invite-code'; import { resolveLimit } from 'src/utils/pagination'; @Injectable() export class AllowlistService { - constructor(private readonly allowlist: UserAllowlistRepository) {} + constructor( + private readonly allowlist: UserAllowlistRepository, + private readonly email: EmailRepository, + private readonly logger: LoggerRepository, + ) {} list(query: AllowlistListQueryDto): Promise { return this.allowlist.list({ cursor: query.cursor, limit: resolveLimit(query.limit) }); @@ -54,7 +62,7 @@ export class AllowlistService { } } - return { items }; + return { items: await this.sendInviteEmails(items) }; } async inviteBatch(dto: AllowlistInviteBatchRequestDto): Promise { @@ -65,7 +73,46 @@ export class AllowlistService { items.push(await this.allowlist.update(entry.id, { invited: true })); } - return { items }; + return { items: await this.sendInviteEmails(items) }; + } + + private async sendInviteEmails(items: AllowlistEntryDto[]): Promise { + const pending = items.filter((entry) => !entry.inviteEmailSentAt); + if (pending.length === 0) { + return items; + } + + const messages: EmailMessage[] = []; + for (const entry of pending) { + const inviteUrl = new URL('/login/invite', env.WEB_BASE_URL).href; + messages.push({ + to: entry.email, + tag: 'invite', + ...(await renderInviteEmail({ inviteCode: entry.inviteCode, inviteUrl })), + }); + } + + const results = await this.email.sendBatch(messages).catch((error: unknown) => { + this.logger.error(error, 'Failed to send invite emails'); + return null; + }); + if (!results) { + return items; + } + + const updated = new Map(); + for (const [index, result] of results.entries()) { + const entry = pending[index]; + if (result.errorCode === 0) { + updated.set(entry.id, await this.allowlist.update(entry.id, { inviteEmailSentAt: new Date() })); + } else { + this.logger.warn( + { email: entry.email, errorCode: result.errorCode }, + `Invite email rejected: ${result.message}`, + ); + } + } + return items.map((entry) => updated.get(entry.id) ?? entry); } private async createEntry(email: string, invited: boolean) { diff --git a/packages/yucca-admin-api/test/allowlist.integration-spec.ts b/packages/yucca-admin-api/test/allowlist.integration-spec.ts index 64fa1529..8af8a810 100644 --- a/packages/yucca-admin-api/test/allowlist.integration-spec.ts +++ b/packages/yucca-admin-api/test/allowlist.integration-spec.ts @@ -1,3 +1,4 @@ +import { EmailMessage, EmailRepository } from '@common/server/email'; import { MetricService } from '@common/server/otel'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; @@ -12,6 +13,9 @@ const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token']; describe('AllowlistController (e2e)', () => { let app: INestApplication; + const sendBatch = jest.fn((messages: EmailMessage[]) => + Promise.resolve(messages.map((message) => ({ to: message.to, errorCode: 0, message: 'OK' }))), + ); beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -23,6 +27,8 @@ describe('AllowlistController (e2e)', () => { .useValue(newMetricServiceMock()) .overrideProvider(OidcRepository) .useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) }) + .overrideProvider(EmailRepository) + .useValue({ send: jest.fn(), sendBatch }) .compile(); app = moduleFixture.createNestApplication(); @@ -37,6 +43,7 @@ describe('AllowlistController (e2e)', () => { beforeEach(async () => { await testUtils.resetDatabase(); + sendBatch.mockClear(); }); describe('GET /allowlist', () => { @@ -165,6 +172,41 @@ describe('AllowlistController (e2e)', () => { ]); }); + it('emails invited entries and records the send', async () => { + const { body } = await request(app.getHttpServer()) + .post('/api/allowlist/invite') + .set('Cookie', authCookie) + .send({ emails: ['fresh@example.com'] }) + .expect(201); + + expect(sendBatch).toHaveBeenCalledTimes(1); + const [messages] = sendBatch.mock.calls[0]; + expect(messages[0]).toEqual( + expect.objectContaining({ to: 'fresh@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }), + ); + expect(messages[0].htmlBody).toContain(body.items[0].inviteCode); + expect(body.items[0].inviteEmailSentAt).toEqual(expect.any(String)); + await expect(testUtils.getAllowlistEntry('fresh@example.com')).resolves.toEqual( + expect.objectContaining({ inviteEmailSentAt: expect.any(Date) }), + ); + }); + + it('does not resend to entries that already got their invite email', async () => { + await testUtils.createAllowlistEntry({ + email: 'already@example.com', + invited: true, + inviteEmailSentAt: new Date('2026-08-01'), + }); + + await request(app.getHttpServer()) + .post('/api/allowlist/invite') + .set('Cookie', authCookie) + .send({ emails: ['already@example.com'] }) + .expect(201); + + expect(sendBatch).not.toHaveBeenCalled(); + }); + it('leaves already-invited entries untouched', async () => { const invited = await testUtils.createAllowlistEntry({ email: 'already@example.com', invited: true }); diff --git a/packages/yucca-admin-api/test/mocks.ts b/packages/yucca-admin-api/test/mocks.ts index 430d0cca..fcce4921 100644 --- a/packages/yucca-admin-api/test/mocks.ts +++ b/packages/yucca-admin-api/test/mocks.ts @@ -1,6 +1,8 @@ +import type { EmailRepository } from '@common/server/email'; import type { LoggerRepository, WideContextRepository } from '@common/server/otel'; import type { DatabaseRepository } from 'src/repositories/database.repository'; import type { OidcRepository } from 'src/repositories/oidc.repository'; +import type { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository'; export type RepositoryInterface = Pick; @@ -20,6 +22,24 @@ export const newOidcRepositoryMock = (): jest.Mocked> => { + return { + send: jest.fn(), + sendBatch: jest.fn(), + }; +}; + +export const newUserAllowlistRepositoryMock = (): jest.Mocked> => { + return { + list: jest.fn(), + getByEmail: jest.fn(), + create: jest.fn(), + update: jest.fn(), + deleteByEmail: jest.fn(), + oldestStaged: jest.fn(), + }; +}; + export const newLoggerRepositoryMock = (): jest.Mocked> => { return { debug: jest.fn(), @@ -45,6 +65,8 @@ export const newMocks = () => { return { database: newDatabaseRepositoryMock(), oidc: newOidcRepositoryMock(), + email: newEmailRepositoryMock(), + allowlist: newUserAllowlistRepositoryMock(), logger: newLoggerRepositoryMock(), wideContext: newWideContextRepositoryMock(), metrics: newMetricServiceMock(), diff --git a/packages/yucca-admin-api/test/testUtils.ts b/packages/yucca-admin-api/test/testUtils.ts index 64dc414f..9f65d5de 100644 --- a/packages/yucca-admin-api/test/testUtils.ts +++ b/packages/yucca-admin-api/test/testUtils.ts @@ -29,11 +29,13 @@ export const testUtils = { email, inviteCode, invited = false, + inviteEmailSentAt, createdAt, }: { email: string; inviteCode?: string; invited?: boolean; + inviteEmailSentAt?: Date; createdAt?: Date; }) => { return getDb() @@ -42,6 +44,7 @@ export const testUtils = { email, inviteCode: inviteCode ?? randomUUID().slice(0, 10).toUpperCase(), invited, + ...(inviteEmailSentAt ? { inviteEmailSentAt } : {}), ...(createdAt ? { createdAt } : {}), }) .returningAll() diff --git a/packages/yucca-api/src/schema/migrations/20260819120000-AddInviteEmailSentAtToUserAllowlist.ts b/packages/yucca-api/src/schema/migrations/20260819120000-AddInviteEmailSentAtToUserAllowlist.ts new file mode 100644 index 00000000..88f8885c --- /dev/null +++ b/packages/yucca-api/src/schema/migrations/20260819120000-AddInviteEmailSentAtToUserAllowlist.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "userAllowlist" ADD "inviteEmailSentAt" timestamp with time zone;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "userAllowlist" DROP COLUMN "inviteEmailSentAt";`.execute(db); +} diff --git a/packages/yucca-api/src/schema/tables/userAllowlist.table.ts b/packages/yucca-api/src/schema/tables/userAllowlist.table.ts index 1b877efd..1e647cf6 100644 --- a/packages/yucca-api/src/schema/tables/userAllowlist.table.ts +++ b/packages/yucca-api/src/schema/tables/userAllowlist.table.ts @@ -20,6 +20,9 @@ export class UserAllowlistTable { @Column({ type: 'timestamp with time zone', nullable: true }) inviteUsedAt!: Date | null; + @Column({ type: 'timestamp with time zone', nullable: true }) + inviteEmailSentAt!: Date | null; + @Column({ type: 'timestamp with time zone', default: () => 'now()' }) createdAt!: Generated; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffd8aaf3..ee3f59bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -837,6 +837,9 @@ importers: packages/yucca-admin-api: dependencies: + '@common/emails': + specifier: workspace:^ + version: link:../emails '@common/server': specifier: workspace:^ version: link:../common