mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
feat(yucca-admin-api): send invite emails from the allowlist flow (#492)
This commit is contained in:
@@ -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 <noreply@backups.futo.cloud>"
|
||||
|
||||
@@ -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::"
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:<service>:<purpose>:*`). 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.
|
||||
|
||||
@@ -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': []},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <noreply@backups.futo.cloud>` |
|
||||
| `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/<name>.svelte` (compose with `src/lib/layout.svelte` and
|
||||
`@better-svelte-email/components`; Tailwind classes only — the renderer inlines them).
|
||||
2. Export a typed `render<Name>Email()` 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.
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:<code>' or 'cluster:<code>'"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@common/emails": "workspace:^",
|
||||
"@common/server": "workspace:^",
|
||||
"@immich/sql-tools": "catalog:",
|
||||
"@nestjs/common": "catalog:",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AllowlistListResponseDto> {
|
||||
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<AllowlistEntriesResponseDto> {
|
||||
@@ -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<AllowlistEntryDto[]> {
|
||||
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<string, AllowlistEntryDto>();
|
||||
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) {
|
||||
|
||||
@@ -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<App>;
|
||||
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 });
|
||||
|
||||
|
||||
@@ -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<T extends object> = Pick<T, keyof T>;
|
||||
|
||||
@@ -20,6 +22,24 @@ export const newOidcRepositoryMock = (): jest.Mocked<RepositoryInterface<OidcRep
|
||||
};
|
||||
};
|
||||
|
||||
export const newEmailRepositoryMock = (): jest.Mocked<RepositoryInterface<EmailRepository>> => {
|
||||
return {
|
||||
send: jest.fn(),
|
||||
sendBatch: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newUserAllowlistRepositoryMock = (): jest.Mocked<RepositoryInterface<UserAllowlistRepository>> => {
|
||||
return {
|
||||
list: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByEmail: jest.fn(),
|
||||
oldestStaged: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
|
||||
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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "userAllowlist" ADD "inviteEmailSentAt" timestamp with time zone;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "userAllowlist" DROP COLUMN "inviteEmailSentAt";`.execute(db);
|
||||
}
|
||||
@@ -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<Date>;
|
||||
}
|
||||
|
||||
Generated
+3
@@ -837,6 +837,9 @@ importers:
|
||||
|
||||
packages/yucca-admin-api:
|
||||
dependencies:
|
||||
'@common/emails':
|
||||
specifier: workspace:^
|
||||
version: link:../emails
|
||||
'@common/server':
|
||||
specifier: workspace:^
|
||||
version: link:../common
|
||||
|
||||
Reference in New Issue
Block a user