Skip to content

feat(mail): add outgoing email, group email templates, and assignment emails - #1439

Merged
joshunrau merged 30 commits into
mainfrom
split/mailer
Aug 3, 2026
Merged

feat(mail): add outgoing email, group email templates, and assignment emails#1439
joshunrau merged 30 commits into
mainfrom
split/mailer

Conversation

@thomasbeaudry

@thomasbeaudry thomasbeaudry commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Splits #1434 into three PRs. This is PR 2 of 3 — merges after #1440.

Merge order

Order PR Branch Base
1st #1440 — UX fixes split/ux main
2nd this PR — mailer split/mailer main
3rd #1438 — Spanish + active languages split/language split/mailer

This branch is independent of #1440 and auto-merges with it cleanly. #1438 is stacked on top of this one, because Spanish has to be added to the strings introduced here — see below.

All user-facing strings here are en/fr

Declaring es in LanguageOptions makes es a required key in every t({ ... }) call in apps/web, so it can't be declared until the last PR in the stack. The mailer's new strings are therefore written without Spanish here, and #1438 adds the 117 Spanish lines for them as part of its own diff. Nothing is lost and nothing is left dangling — once #1438 lands, the mail admin page, group email templates, and assignment email form are fully translated.

What's here

  • A mail module in the API backed by nodemailer, with admin-configurable SMTP settings stored on the setup state and never returned to clients. The public setup route exposes only a derived isMailEnabled flag so the client hides all email UI when mail is off.
  • Group-manager-authored, categorized email templates per group (remote assignment / information), one active per category.
  • Emailing a remote assignment link; creating a user sends a welcome email, offering the rendered text for manual copying when delivery fails.
  • Admin mail settings page, group email templates page, plus supporting queries and mutations.

One note on ?lang

Assignment URLs are built here as ${assignment.url}?lang=${language}, but the gateway code that reads that param lives in #1438. Between this merging and #1438 merging, an emailed link opens the instrument in the gateway's default language rather than the recipient's. The email body itself is already correctly localized — only the linked page is affected, and it resolves as soon as #1438 lands.

Lockfile

The 13k-line lockfile diff on #1434 was almost entirely missing prettier formatting, not dependency changes. This branch regenerates from main's lockfile and re-runs prettier, leaving a 6-line diff: nodemailer and @types/nodemailer.

Verification

tsc and eslint clean for schemas, web, api; pnpm test 382 passed / 1 skipped. Four redundant type assertions in GroupEmailTemplates were removed to satisfy no-unnecessary-type-assertion.

🤖 Generated with Claude Code

… emails

Adds a mail module to the API backed by nodemailer, with admin-configurable
SMTP settings stored on the setup state and never returned to clients. The
public setup route exposes only a derived `isMailEnabled` flag so the client
can hide all email UI when mail is off.

Group managers can author named, categorized email templates (remote
assignment / information) per group, with one active template per category.
Remote assignment links can be emailed to a participant, and creating a user
sends a welcome email whose rendered text is offered for manual copying when
delivery fails.

Adds an admin mail settings page, a group email templates page, and the
supporting queries and mutations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread apps/api/src/mail/mail.service.ts Fixed
Comment thread apps/api/src/mail/mail.service.ts Fixed
thomasbeaudry and others added 2 commits July 23, 2026 14:39
The create-assignment dialog now uses a native `<Button>` rather than a libui
`Form`, so its submit control's accessible name comes from its text content.
`getByLabel('Submit')` no longer matches it — matching the pattern already used
for native submit buttons in the instrument render page object — which timed out
the "create a remote assignment and display the link" spec.

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

Copy link
Copy Markdown
Collaborator Author

Updated with latest main (merge commit) and fixed the failing CI.

What was red: the lint-and-test job's End-to-End Tests step — one spec, remote-assignment › should create a remote assignment and display the link, timed out (30s) waiting for the submit button.

Cause: this PR replaces the libui Form in the create-assignment dialog with a native <Button>Submit</Button>. A native button's accessible name comes from its text content, which getByLabel('Submit') does not match — so the page object's submitAssignmentForm() never found it. (The unit suite couldn't catch this; it's an e2e-only locator break.)

Fix: the page object now uses getByRole('button', { name: 'Submit' }), matching the pattern already used for native submit buttons in the instrument-render page object. Verified the locator behavior directly (role matches the native button, label matches 0 — exactly the observed timeout), and confirmed the rest of the spec still holds: the AssignmentEmailForm returns null when mail is disabled (the test default), so the Assignment Link and read-only URL input assertions are unaffected.

Also merged main, which brought in #1440's useNavItems changes — the auto-merge kept both the audit-logs reorder and this PR's Mail nav items. Full unit suite: 403 passed / 1 skipped.

The Amazon SES endpoint host is built by interpolating the configured AWS
region (`email.{awsRegion}.amazonaws.com`). Because the region originates from
admin-supplied mail settings, a crafted value such as `evil.example/` would
redirect the outgoing request to an attacker-controlled host — a server-side
request forgery flagged by CodeQL (js/request-forgery, critical).

Constrain the region to the AWS region character set (lowercase letters,
digits, hyphens) both at the schema boundary and at the point the host is
built, so a value that could break out of the host is rejected.

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

Copy link
Copy Markdown
Collaborator Author

Also fixed the CodeQL failure (separate from the e2e one above).

What it flagged: 2 critical js/request-forgery (SSRF) alerts in mail.service.ts — the outgoing fetch URL depended on admin-supplied config. The vector was the SES branch: the endpoint host is email.{awsRegion}.amazonaws.com, and awsRegion comes from the mail settings, so a crafted value like evil.example/ would repoint the request to an attacker host. (Mailgun's host is a fixed enum-selected pair and its domain is encodeURIComponent'd into the path, so it wasn't exploitable.)

Fix: constrain the region to the AWS region character set (^[a-z0-9-]+$/) in two places — at the schema boundary ($MailConfig) and at the point the host is built in mail.utils.ts, which throws on a bad value. Added unit tests covering both buildSendRequest and buildVerifyRequest rejecting a host-injecting region.

CodeQL now passes with 0 open critical alerts on the PR; full unit suite 405 passed / 1 skipped.

@gdevenyi gdevenyi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review — outgoing mail

A lot of careful work here: the transport abstraction is clean, the SigV4 signing is hand-rolled rather than pulling in the AWS SDK, describeMailError/describeHttpMailError deliberately never leak raw errors, and the ~840 lines of unit tests around mail.utils/mail.service are genuinely good — including the SSRF guard on the SES region. The hasPassword/hasApiKey DTO split is the right shape.

The concerns below are mostly about two definitions of the same thing drifting apart, plus a few client/server mismatches.

Blocking

  1. isMailEnabled is false for every HTTP-transport provider. setup.service.ts derives it from mailConfig.enabled && mailConfig.host, but host is '' on the http transport. So an instance configured with Mailgun/SendGrid/SES/Postmark has MailService.isEnabled() === true (mail sends fine) while the public flag says false — which hides the Mail nav item, makes AssignmentEmailForm return null, and hides the welcome-email language picker. The whole HTTP path is unreachable through the UI. These two predicates need to be one function.

  2. AssignmentEmailForm resolves templates from the wrong group. The component queries /v1/groups/{currentGroup.id} from the app store, but the server resolves the template from assignment.groupId. On /datahub/$subjectId/assignments those differ whenever the assignment belongs to another group: the dropdown lists the wrong group's templates, and the templateId posted isn't found server-side, so it silently falls back to the built-in default. The user sees a template name and gets different content.

  3. SMTP password and provider secrets are stored in plaintext. MailConfig.password / apiKey (the latter being the AWS secret access key for SES) sit unencrypted in SetupStateModel. Anyone with a mongodump, a backup, or read access to the DB has the instance's outbound-mail identity. The code comment says "never returned to clients", which is true and good, but that isn't the exposure that matters here. At minimum this needs an explicit decision recorded; encrypting with a key from $Env (or sourcing the secret from env entirely) would be better.

Should fix

  1. The language set is now defined in five unrelated placesMAIL_LANGUAGE, $LocalizedString's keys, Prisma's LocalizedString type, ALL_LANGUAGES (typed { [key: string]: string }), and $SetupState.activeLanguages: z.array(z.string()). Nothing makes them agree, and activeLanguages will happily accept a code no template can hold. AGENTS.md: one source of truth; everything derived and no loose records where a closed key set is known.

  2. useUpdateGroupMutation lost its success toast for every caller. The onSuccess notification was deleted from the hook and re-added at one call site in group/manage.tsx. Any other caller now saves silently. useUpdateSetupStateMutation already models the right pattern — an optional successNotification on the hook.

  3. Lost-update race on emailTemplates. persist() sends the entire array from the client's cached copy and the service replaces it with set. Two group managers editing concurrently means one set of edits vanishes with no error.

  4. remote-assignment.tsx replaces the libui <Form> and date picker with a free-text <Input type="text" placeholder="YYYY-MM-DD">. That drops the picker, drops libui's validation/error rendering, and parses with new Date(string) — which treats 2026-08-01 as UTC midnight but 2026/08/01 as local. This is also the file that conflicts with #1441.

  5. No e2e coverage. AGENTS.md requires new e2e tests in testing/ alongside unit tests. Nothing here exercises the mail admin page, the templates page, or sending an assignment email. The unit tests are excellent, so this is the one gap.

Coordination with the other open PRs

The stack described in the PR body is stale — #1438 is closed and was replaced by #1442/#1443, which target main rather than being stacked on this branch. The practical consequence is that this branch and #1441/#1442 now overlap:

Conflict Files
#1439 × #1441 apps/web/src/routes/_app/session/remote-assignment.tsx (real content conflict — this PR deletes the <Form> that #1441 modifies; both add the same document.querySelector autofocus effect)
#1439 × #1442 apps/web/src/components/SaveStatus.tsx (add/add)
apps/web/src/utils/languages.ts, activeLanguages in schema.prisma, setup.service.ts, $SetupState are all duplicated verbatim between this PR and #1442

GitHub shows all four as MERGEABLE only because it compares each against main in isolation. Worth deciding an order and rebasing, or moving the shared pieces (SaveStatus, ALL_LANGUAGES, activeLanguages) into whichever lands first.

One correction to the PR body's rationale: declaring es in LanguageOptions does not make es a required key. libui types the argument as { [L in Language]?: string } (TranslateFunction in libui/dist/i18n/types.d.ts) and t() falls back to defaultLanguage at runtime. So the ordering constraint that justified splitting the Spanish strings out doesn't actually exist — which is worth knowing, because it also means nothing will ever force the 380-odd inline t({ en, fr }) calls to gain Spanish.

Comment thread apps/api/src/setup/setup.service.ts Outdated
isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'),
// Non-secret flag so the client can hide email UI when mail is off. The SMTP
// configuration itself is never exposed here (this is a public route).
isMailEnabled: Boolean(savedOptions?.mailConfig?.enabled && savedOptions.mailConfig.host),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

host is only populated on the smtp transport. With transport: 'http' (Mailgun/SendGrid/SES/Postmark) this is '', so isMailEnabled is false even though MailService.isEnabled() returns true and mail delivers successfully.

The client gates every piece of email UI on this flag — the Mail and Email Templates nav items, AssignmentEmailForm (returns null), and the welcome-email language picker in users/create.tsx — so the entire HTTP-provider path is unreachable through the UI.

Two predicates for "is mail on" will keep drifting. Suggest exporting the check from MailService and calling it here, e.g. isMailEnabled: await this.mailService.isEnabled(), or lifting the predicate into schemas/mail so both sides derive it from the same function.

Comment thread apps/api/prisma/schema.prisma Outdated
enabled Boolean
encryption String
host String
password String

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

password and apiKey are persisted in plaintext. For SES, apiKey holds the AWS secret access key, so a database backup or a read-only mongo credential leaks credentials that can send mail as the institution — and, for a broadly-scoped IAM key, potentially more.

The comment above correctly notes these are never returned to clients, but that isn't the exposure that matters; the DB is.

Options, roughly in order of effort: encrypt at rest with a key from $Env (envelope-encrypt just these two fields), or read the secret from an env var and store only non-secret config here. If plaintext is a deliberate accepted risk for this deployment model, please say so explicitly in the comment so the next reader doesn't have to re-derive the decision.

const groupQuery = useQuery({
enabled: Boolean(groupId && setupStateQuery.data.isMailEnabled),
queryFn: async () => $Group.parseAsync((await axios.get(`/v1/groups/${groupId}`)).data),
queryKey: ['group', groupId]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This queries the group from useAppStore().currentGroup, but the server resolves the template from assignment.groupId (assignments.controller.ts). On /datahub/$subjectId/assignments an assignment can belong to a group other than the currently-selected one, and then:

  • the dropdown lists templates the server will never consider, and
  • the templateId posted isn't found in group.emailTemplates, so chosen is undefined and the controller silently falls back to DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.

The user picks "Follow-up reminder" and the participant receives the built-in default, with no error.

Fix: drive this off the assignment's own group. Assignment already carries groupId, so useQuery(['group', assignment.groupId]) would keep client and server in agreement — and would let the component be given the assignment rather than just its id.

Separately, this inline useQuery + axios.get + $Group.parseAsync is duplicated verbatim in GroupEmailTemplates.tsx. Worth a useGroupQuery(groupId) hook alongside the other hooks in @/hooks.

const currentGroup = useAppStore((store) => store.currentGroup);
const activeLanguages = setupStateQuery.data.activeLanguages ?? ['en', 'fr'];
const [recipient, setRecipient] = useState('');
const [language, setLanguage] = useState<string>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

language is initialised once, but the option list it has to belong to (languageDropdownOptions, derived from the selected template's populated languages ∩ instrumentLanguagesactiveLanguages) is recomputed on every render.

Switching to a template that is only authored in French while language === 'en' leaves the Select holding a value with no matching Select.Item — Radix renders an empty trigger, and sendEmail still posts 'en', which pickLocale then resolves by falling back to whatever language the template does have. The visible state and the sent state disagree.

Clamping it keeps the invalid state unrepresentable:

const language = languageDropdownOptions.includes(languageChoice)
  ? languageChoice
  : (languageDropdownOptions[0] ?? 'en');

with languageChoice as the raw state — same pattern already used for templateChoice ?? activeValue just above.

setName('');
setSubject({ [firstLang]: '' });
setBody({ [firstLang]: '' });
document.querySelector('.overflow-y-scroll')?.scrollTo({ behavior: 'smooth', top: 0 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Selecting the scroll container by Tailwind utility class reaches outside the component into whatever ancestor happens to carry overflow-y-scroll today, and breaks silently the moment that layout changes to overflow-y-auto or the class moves. It will also grab the first such element in the document, which may not be this page's scroller.

A ref on the form (or the page container) and ref.current?.scrollIntoView({ behavior: 'smooth' }) is both local and unbreakable.

Comment thread packages/schemas/src/group/group.ts Outdated
import { z } from 'zod/v4';

import { $BaseModel, $RegexString } from '../core/core.js';
import { $LocalizedString } from '../mail/mail.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

group importing from mail for a generic localized-string type inverts the layering: LocalizedString has nothing to do with mail, and this makes the group schema depend on the mail schema for all time.

It belongs in ../core/core.js next to $RegexString, where $BrandingConfig's instanceName/instanceTagline/instanceDetails and the resourceLinks[].label objects could also use it — those are hand-rolled { en, fr } objects today, which is exactly what forces the six as { [key: string]: string } casts in #1442. Consolidating on one $LocalizedString in core would fix both PRs' problem in one place.

@ApiOperation({ summary: 'Email Assignment Link' })
@Post(':id/email')
@RouteAccess({ action: 'update', subject: 'Assignment' })
async sendEmail(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two things on this endpoint:

Recipient is unconstrained. RouteAccess({ action: 'update', subject: 'Assignment' }) means any user who can update an assignment can make the instance's SMTP identity deliver to an arbitrary address, unthrottled. The content is template-constrained so it isn't a general open relay, but it is a free "send mail from <institution>" primitive, and the assignment URL it carries is a live credential. A rate limit (per user and per recipient) would be cheap insurance.

expiresAt is formatted in UTC. new Date(assignment.expiresAt).toISOString().slice(0, 10) renders the date in UTC, so an assignment expiring at 2026-08-01T02:00Z reads as "expires 2026-08-01" to a recipient in Montréal for whom it already expired on July 31 local. Given the participant acts on this date, formatting in the instance's timezone (or including the time) would avoid off-by-one-day support tickets.

Comment thread apps/api/src/mail/mail.service.ts Outdated
}

/** Send a message using the currently saved configuration and its active transport. */
private async sendMail(options: SendOptions): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sendMail re-reads the config, but every caller has already read it moments earlier: sendAssignmentEmail calls isEnabled()getConfig()findFirst(), then sendMail()getConfig()findFirst() again. getSettings() does the same via getConfig() + getNewUserEmailTemplate().

Beyond the extra round-trips, it's a TOCTOU seam: the config can change between the enabled-check and the send, so a message can go out through a configuration that was just disabled.

Reading once at the top of the public method and threading config down removes both.

Comment thread apps/web/src/routes/_app/admin/mail.tsx Outdated
};

// Autosave the SMTP configuration whenever the form settles on a valid state.
useAutosave(JSON.stringify(values), () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Autosaving on JSON.stringify(values) means partially-typed secrets get persisted. Typing a new SMTP password pauses for 1.2s mid-word → buildConfig(true) returns a valid payload (everything else is already filled in) → the truncated password is written to the DB and enabled stays true. The instance is now configured with a credential the admin never intended, and the only signal is a "saved" pill.

Excluding the secret fields from the autosave snapshot and committing them on blur (or behind an explicit action) would keep the convenience without that failure mode.

Related: values is lazily initialised from config and never resynced, so after useUpdateMailSettingsMutation invalidates and the query refetches, any server-side normalisation is invisible and the local plaintext secret keeps being re-sent on every subsequent autosave.

<Label htmlFor="expires-at">{t({ en: 'Expires At', fr: "Date d'expiration" })}</Label>
<Input
id="expires-at"
placeholder="YYYY-MM-DD"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Replacing the libui <Form> with a free-text field is a real UX step back: no date picker, no locale-aware input, and no libui validation rendering — a clinician now types YYYY-MM-DD by hand.

It's also ambiguous to parse. new Date('2026-08-01') is UTC midnight, whereas new Date('2026/08/01') is local midnight; anything the user types that isn't exactly ISO silently shifts by up to a day west of UTC. The old kind: 'date' field handed you a Date and z.coerce.date().min(new Date()) did the checking.

Was there a specific problem with the <Form> here? If it was just to control the submit button's label/pending state, submitBtnLabel + suspendWhileSubmitting cover that.

Note this hunk also conflicts with #1441, which keeps the <Form> and seeds expiresAt from the new instance-wide default setting. Those two changes can't both land as written.

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Substantial piece of work, and some of it is careful in ways worth naming: no credential reaches a client (getSettings is a hand-written allowlist and $MailSettings omits both secrets), @RouteAccess and group scoping are correct on all four new and changed routes, and the SES SigV4 SSRF fix in 0e16f66 is airtight at both the schema boundary and host construction.

Before going further, please hold on the larger refactors. There are open questions on this PR's approach — the mail transport layer, the dependency, and how credentials are stored — that need a maintainer decision, and several of the items below could change or disappear depending on how those land. The items in the first two sections stand regardless.

Blocking

  1. The branch no longer passes pnpm lint against current main. main gained a react/jsx-no-literals rule for apps/web in 08d36a093 on 26 July, after your last commit here, so this PR's green CI predates it. Rebasing produces 8 errors: apps/web/src/routes/_app/admin/mail.tsx lines 553-556, 691, 692, 975, and apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx line 170. The provider and encryption names (Mailgun, SendGrid, Amazon SES, Postmark, STARTTLS, SSL/TLS) are proper nouns and can go in allowedStrings in eslint.config.js; the two `{{${variable}}}` cases need to move into a named variable outside the JSX.

  2. isMailEnabled is computed wrongly, which makes the entire HTTP-transport half unreachable from the UI. apps/api/src/setup/setup.service.ts:61 is Boolean(mailConfig?.enabled && mailConfig.host), but host is empty for every HTTP provider. Configure SendGrid and the save succeeds and the API sends welcome mail, while the UI hides everything: useNavItems.ts:89, create.tsx:95, and AssignmentEmailForm.tsx:124 returns null so assignment emails become unsendable. Call the same predicate as MailService.isEnabled() (mail.service.ts:107-124) rather than duplicating it.

  3. A failed settings save reports success. useUpdateMailSettingsMutation.ts:15 sets meta.disableDefaultErrorNotification with no onError and no throwOnError: false, and the SaveStatus effect at mail.tsx:356-369 only watches isPending — so a rejected PATCH shows "All changes saved". useAutosave (mail.tsx:148-162) also advances lastSaved.current before calling onSave, so the failed snapshot is never retried. Silently discarding an admin's SMTP edits is the case CLAUDE.md rules out outright.

  4. POST /v1/mail/test can be pointed at an arbitrary host with the stored password. mail.service.ts:208-232 accepts an unsaved config, and resolveConfig (279-292) merges the stored password whenever partial.password is blank — so {"config":{"transport":"smtp","host":"attacker.example",…}} performs SMTP AUTH against that host with the real credential. It's admin-only, but it defeats the hasPassword masking this PR adds. Only inherit a stored secret when transport/host/provider match the saved config.

  5. GroupEmailTemplates.tsx leaves the UI showing unpersisted state on error. 285-293if (isPending) … else if (isSuccess) leaves saveState unset on failure, so the pill spins on "Saving…" forever. Lines 438, 458, 502 and 566 void a mutateAsync whose rejection nothing catches, so setEditing(null) and the doCreate form reset never run.

Correctness and conformance

  1. activeLanguages is read in seven places and written nowhere — no endpoint, no DTO, no UI sets it — so it is permanently ['en','fr'] and every Spanish string in packages/schemas/src/mail/mail.ts is unreachable. Worse, $MailLanguage.parse(activeLanguages[0] ?? 'en') at mail.tsx:184 and create.tsx:49 throws during render on an unknown code, which is a white screen rather than a fallback. Use safeParse with a fallback. (This field also overlaps #1442 — see the note at the bottom.)

  2. apps/api/src/users/users.controller.ts:52url: origin ? \${origin}/auth/login` : ''interpolates the client-suppliedOrigin` header into a login link inside an email that carries a temporary password. Use the deployment's configured URL.

  3. users.controller.ts:38@Query('language') language?: MailLanguage is typed but unvalidated; apps/api/AGENTS.md requires an explicit ParseSchemaPipe. Line 50 then collapses it with language === 'fr' ? 'fr' : 'en', silently downgrading 'es' to English despite 'es' being in $MailLanguage and in both default templates.

  4. users.controller.ts:105.catch(() => null) makes a genuine DB error indistinguishable from a permission denial and silently drops the group name. users.controller.spec.ts:97 currently pins that behaviour.

  5. apps/api/src/assignments/assignments.controller.ts:64-70 — emailing a participant's assignment link to an operator-supplied address isn't audit-logged, while create and update on the same entity are. For a clinical system this is the new action most worth an audit record.

  6. apps/web/src/routes/_app/session/remote-assignment.tsx:21-66 should be split into its own PR. Replacing the libui Form (with kind: 'date' and z.coerce.date().min(new Date()) tied to CreateAssignmentData) with a text Input placeholder="YYYY-MM-DD" and hand-written isNaN checks is unrelated to mail, drops the zod contract, leaves the placeholder untranslated, and regresses behaviour — new Date('2027-01-01') parses as UTC midnight, so users west of UTC get an expiry a day earlier than they typed. It also forced the locator repair at testing/src/pages/_app/session/remote-assignment.page.ts:24.

  7. The INFORMATION template category is authorable, activatable and persisted, but nothing ever sends one — no route, no UI action. Remove it until there's a consumer.

Tests

  1. No e2e test. CLAUDE.md requires one per change, and the whole testing/ delta here is the locator repair forced by item 11. Add page objects for /admin/mail and /group/email-templates, register them in testing/src/support/fixtures.ts, and add specs — see .agents/docs/playbooks/add-e2e-test.md.

  2. Zero data-testid across mail.tsx, GroupEmailTemplates.tsx, AssignmentEmailForm.tsx, SegmentedControl.tsx and SaveStatus.tsx. apps/web/AGENTS.md makes these the Playwright selector contract, so item 13 can't be written without them.

  3. No web unit tests for the five new hooks or three new components, and no schemas test pinning that $MailConfigDto strips password and apiKey — the one thing that schema exists to do. checkTemplateIssue/hasVar are pure functions in a vitest project and untested. On the API side, mail.service.spec.ts never exercises test({ config }), which is the path item 4 is about.

Structure and style

  1. admin/mail.tsx is 1031 lines against a 338-line repo maximum for a route file, and GroupEmailTemplates.tsx is 693 against 378 for a component. SectionCard (mail.tsx:48) and Field (mail.tsx:53) are generic presentational helpers that belong in src/components/.

  2. AssignmentEmailForm.tsx:44-48 and GroupEmailTemplates.tsx:295-297 inline the same queryKey: ['group', groupId] + axios.get('/v1/groups/…') fetch. apps/web/AGENTS.md puts all data fetching in src/hooks/, one file per hook, exporting the key — otherwise no mutation can invalidate it reliably.

  3. useUpdateGroupMutation.ts silently loses its success notification, so saving on /group/manage no longer toasts. Unremarked behaviour change to an existing screen.

  4. mail.tsx:250-290 re-implements the messages in checkTransportFields (packages/schemas/src/mail/mail.ts:118-169), and mail.tsx:330 hand-rolls an email regex beside $TestMailData.recipient's z.email(). One source of truth.

  5. GroupEmailTemplates.tsx:433-441 — deleting a template destroys it and PATCHes immediately with no confirmation, while the far less destructive "missing translations" case does get a confirm dialog. Line 353 also reaches out with document.querySelector('.overflow-y-scroll'), coupling the component to an ancestor's utility class.

  6. Raw colour scales instead of semantic libui tokens at SegmentedControl.tsx:29,41, SaveStatus.tsx:12,20, GroupEmailTemplates.tsx:444,488, mail.tsx:49,501,834,906, AssignmentEmailForm.tsx:218.


Overlap with #1442. The two PRs share seven files and both add activeLanguages to the Prisma schema, setup.ts and setup.service.ts, plus SaveStatus.tsx and languages.ts, with divergent contents. They can't both merge as written — we'll sort out the ordering and let you know which one rebases onto the other.

@joshunrau

Copy link
Copy Markdown
Collaborator

Following up on my earlier review — the open questions on approach have been settled. Four decisions, and they cut this PR down substantially rather than adding to it. Please read these before picking the earlier list back up, since several of those items disappear.

1. SMTP only — drop the HTTP transport and the SigV4 signer

Remove the four-provider HTTP client and the hand-rolled AWS SigV4 signer (apps/api/src/mail/mail.utils.ts:49-84). Mailgun, SendGrid, SES and Postmark all expose SMTP endpoints, so nodemailer already reaches every provider you implemented — the HTTP path wasn't buying provider coverage, just a second way in. apiKey comes off MailConfig with it.

Your justification for not using libnest's MailModule was correct, and I checked it: MailService (libnest 8.3.1, mail.service.ts:13,17) builds private readonly transporter in its constructor, so the options resolve once at boot and an admin settings change can't take effect without a restart. forRootAsync does exist (mail.config.ts:25 uses setClassMethodName('forRoot')) and could inject the config, but it wouldn't fix that — the limitation is the transporter's lifetime, not where the options come from.

So we're fixing it at the source instead: libnest gets a small change making the transporter lazily derived and letting sendMail take a per-call transport override. That last part also gives POST /v1/mail/test somewhere to live, since it needs to send with an unsaved config. This PR then consumes libnest rather than forking it. We'll coordinate the libnest release with you — no need to start on this half until it's out.

Knock-on effects: blocking item 2 from my review (isMailEnabled requiring host) largely dissolves, since every SMTP config has a host. Blocking item 4 still needs fixing — resolveConfig should only inherit a stored secret when the transport and host match the saved config.

2. Temporary passwords: replace with an invite link

Emailing a password in cleartext isn't something we want for this system. Please replace the welcome-email flow with a single-use, expiring invite link that lets the user set their own password. That means a token model and a set-password route.

This removes Temporary password: {{password}} from the default templates (packages/schemas/src/mail/mail.ts:290-346), and it removes the rendered-message-with-password return from POST /v1/users (apps/api/src/users/users.controller.ts:46-56) — the temporary password stops existing rather than needing to be stripped from the response.

While you're in that controller, users.controller.ts:52 interpolates the client-supplied Origin header into the login URL. The invite link must be built from the deployment's configured URL, not a request header — that matters more for a link that grants account access than it did for a plain login link.

3. Encrypt the SMTP password at rest

MailConfig.password (apps/api/prisma/schema.prisma:412-431) stays admin-editable, but encrypt it before it reaches Mongo and decrypt on use, via @douglasneuroinformatics/libcrypto. A database dump or backup shouldn't yield a working mail credential. Happy to talk through where the encryption key comes from — raise it here before you build it.

4. Drop the files shared with #1442

#1442 owns activeLanguages, apps/web/src/components/SaveStatus.tsx and apps/web/src/utils/languages.ts. Please delete your copies now rather than waiting for a rebase — activeLanguages is read in seven places here and written nowhere, so removing it costs no behaviour, and it takes the $MailLanguage.parse(activeLanguages[0] ?? 'en') render-time throw at mail.tsx:184 and create.tsx:49 out with it. Pick all three up from main once #1442 lands.


To be clear about what survives from my earlier review: items 1 (lint), 3 (failed save reporting success), 4 (test-endpoint credential), 5 (GroupEmailTemplates error states), 9–14 and 16–21 all still stand. Items 2, 6, 7 and 15 are wholly or partly overtaken by the above.

Sorry to send this back with a scope change attached — the SSRF hardening and the group-scoping work in here were solid, and none of that is wasted.

@joshunrau

Copy link
Copy Markdown
Collaborator

Suggested model: Fable 5

Sizing the remaining work on this PR for whoever picks it up, human or agent. It's an automatic escalation on two counts — apps/api/prisma/schema.prisma and a new dependency — and the settled decisions add a libnest migration, an invite-token flow and encrypt-at-rest on top of the 16 standing review items across apps/api, apps/web, packages/schemas and testing/. That's several aspects of the system that have to be held in mind at once.

@thomasbeaudry

Copy link
Copy Markdown
Collaborator Author

Thanks — the scope change makes sense, and cutting the transport layer down to SMTP removes more code than it adds work. Picking this up now. Two things I need answers on before I can finish, both flagged in your decisions.

Decision 3 — where the encryption key comes from

You asked me to raise this before building, so: @douglasneuroinformatics/libcrypto can't do this today. At 0.0.6 (the catalog version, and the latest published) it exports exactly two things — HybridCrypto and sha256. HybridCrypto is HPKE-shaped:

static encrypt({ plainText, publicKey }): Promise<{ cipherText, symmetricKey }>
static decrypt({ cipherText, privateKey, symmetricKey }): Promise<string>

so decryption needs the private key and the per-message symmetricKey that encrypt handed back. That fits its existing callers — assignments.service.ts:39 generates an ephemeral keypair per assignment and ships the public half to the gateway — but for encrypt-at-rest it means storing a wrapped key beside every ciphertext and finding somewhere durable for the private key. There is no symmetric encrypt/decrypt pair in the package.

The repo already solves this exact problem elsewhere. InstrumentReposService encrypts the GitHub access token at rest (instrument-repos.service.ts:245-258):

/** Encrypt a secret with AES-256-GCM, returning `iv.authTag.ciphertext` (all base64). */
private encrypt(plaintext: string): string { ... }

/** Derive a 32-byte AES key from the server SECRET_KEY. */
private encryptionKey(): Buffer {
  const secret = this.configService.getOrThrow('SECRET_KEY');
  return createHash('sha256').update(secret).digest();
}

SECRET_KEY is already in $BaseEnv (z.string().min(16)), already required in docker-compose.yaml, and already backs JWT signing. So my proposal is to use that same construction for MailConfig.password rather than add a second key-management story.

Which leaves one question for you: copy the four methods into MailService, or lift them into a shared util that both services use? I'd rather lift them — two hand-rolled AES-GCM implementations in one codebase is the "two artifacts that must agree" problem AGENTS.md warns about — but that means touching instrument-repos.service.ts in a mail PR, so I want your call before I do it.

One consequence worth stating explicitly either way: keying off SECRET_KEY means rotating SECRET_KEY makes the stored SMTP password undecryptable and the admin has to re-enter it. InstrumentReposService.decrypt already handles its equivalent by logging and returning undefined; I'd mirror that — treat an undecryptable password as "not configured" so the settings page prompts for it again rather than the service throwing on every send. Say the word if you'd prefer it to fail loudly instead.

Decision 1 — libnest release

@douglasneuroinformatics/libnest is still 8.3.1 on npm, which is what apps/api already depends on, so the lazy-transporter change isn't out yet. I've confirmed your read of the current code — MailService builds this.transporter in its constructor from MAIL_MODULE_OPTIONS_TOKEN, so options resolve once at boot and no settings change can take effect without a restart.

So I'm splitting Decision 1 in two: doing the removal half now (dropping the HTTP client, the SigV4 signer, apiKey, and the provider/region/domain fields), and holding the libnest-consumption half until the release lands. Ping me here when it's published and I'll swap the local nodemailer transporter for it. Worth confirming the shape you're planning for the per-call transport override, since POST /v1/mail/test needs to send with an unsaved config — if it takes a full transport options object per call that covers it.

What I'm doing in the meantime

  • Decision 4 — dropping my copies of activeLanguages, SaveStatus.tsx and languages.ts now, to be picked up from main after feat(i18n): add per-instance language selection mechanism #1442.
  • Decision 2 — removing {{password}} from the default templates and the rendered-message-with-password return from POST /v1/users, so the cleartext password stops being emailed. The invite-token model and set-password route are a self-contained auth feature and I'd like to do them as a follow-up PR rather than grow this one further; the Origin-header fix goes in there with the link construction. Shout if you'd rather it all land here.
  • The standing items you listed as surviving — lint, the failed-save-reports-success bug, the test-endpoint credential inheritance, the GroupEmailTemplates error states, 9–14 and 16–21 — plus the e2e coverage.

Also removing the INFORMATION template category (item 12) rather than leaving it authorable with no consumer, and reverting the remote-assignment.tsx change wholesale (item 11) so it stops conflicting with #1441.

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Answers to your four questions, then a further pass that surfaced some things not on the standing list and not affected by the four decisions.

Your questions

  1. AES-GCM helpers — lift them. Move the four methods out of instrument-repos.service.ts:245-258 into a shared util and have both callers use it; don't copy the implementation. One caveat, given the next answer: have the util throw and let each caller decide what to do with it, so lifting it doesn't silently turn InstrumentReposService.decrypt's existing catch-and-return-null into a hard failure on the GitHub-token path. Mail lets the throw propagate; instrument-repos keeps its current wrapper. Whether that path should also throw is a separate question, not this PR's.

  2. An SMTP password that no longer decrypts — throw. Don't log-and-treat-as-unconfigured. Same answer applies to mail.service.ts:64-65, where getConfig() currently swallows a $MailConfig.safeParse failure and returns null: a stored-but-invalid config makes every send return DISABLED with no log, so the admin sees a configured mail page and no mail.

  3. libnest transport override — don't write it yet. Do the removal half of Decision 1 and leave POST /v1/mail/test out of this PR entirely; it waits for the release rather than being written against a guessed shape. I'll ping you when it lands.

  4. Invite-token flow — split approved. {{password}} removal lands here; the token model, the set-password route and the Origin-header fix go to the follow-up. No gap in the meantime: $CreateUserData.password is required and the admin types it in the create form, so credentials get handed over out-of-band exactly as they do on main today.

Group templates — these four are worth doing first; the scope cut leaves this path fully intact.

A. Templates are replaced wholesale, so two group managers silently overwrite each other. groups.service.ts:111 is emailTemplates: { set: emailTemplates }, and GroupEmailTemplates.tsx:325-337 always PATCHes the full array built from a cached groupQuery. Manager A adds a template; manager B, holding a list from before it, deletes an unrelated one and A's is gone. There's no updatedAt guard and no undo. Worth an optimistic-concurrency check, and renderTemplate's trash button (:433-441) should confirm before destroying participant-facing text — the far milder "missing translations" case already gets a dialog.

B. Nothing validates the active template ids. groups.service.ts:103-124 spreads activeAssignmentEmailTemplateId / activeInformationTemplateId straight into the Prisma update, and template id uniqueness within the array isn't enforced either. Any PATCH that drops the currently-active template leaves a dangling id; assignments.controller.ts:57-62 then finds nothing and falls back to DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE, so participants get the built-in wording instead of the group's, with nothing logged. Reject an active id that doesn't resolve to a template of that category.

C. AssignmentEmailForm silently overrides the group's active template. AssignmentEmailForm.tsx:43-51 keys groupQuery on useAppStore.currentGroup, not the assignment's group — which is what the server actually uses. In the all-groups view (reachable from /datahub/$subjectId/assignments) the query is disabled, so :77 sends templateId: null, and null is defined as "use the built-in default". Same if the clinician hits Send before the query settles. Omitting the field unless the user explicitly picked a template fixes both.

D. The client's 10 s timeout is shorter than the server's SMTP timeouts. services/axios.ts:115-121 forces timeout = 10000 unless disableDefaultTimeout is set, while mail.service.ts:265-268 allows 15 s connect/greeting and 20 s socket. useTestMailMutation.ts:12 opts out — useCreateUserMutation.ts:18-21 and useSendAssignmentEmailMutation.ts:18-21 don't. Against a slow SMTP host, POST /v1/users aborts client-side after the user is created: you're told it failed, you retry into "username exists", and you never learn whether credentials went out. On the assignment endpoint you get "could not be sent", resend, and the participant receives two links.

Everything else

  1. apps/api/package.json:56,67 pin literal versions. "nodemailer": "^7.0.12" and "@types/nodemailer": "^7.0.5" need to move into the catalog: block of pnpm-workspace.yaml and become "catalog:" here. CLAUDE.md makes that a hard rule, and nodemailer is the dependency that survives Decision 1.

  2. Three mutations are missing throwOnError: false. services/react-query.ts:6 sets it true globally, so handling an error in onError or a catch isn't enough — it still escalates to the router error boundary. useCreateUserMutation.ts:16-23 is the one that matters: the old version caught the 400 inside mutationFn and returned a discriminated result, so it never rejected. Now create.tsx:69-91 catches the rejection and has the localized password message ready, but the page blows up before the user sees it. useTestMailMutation.ts:11-14 is the same shape. useSendAssignmentEmailMutation.ts:18-21 sets neither throwOnError: false nor meta.disableDefaultErrorNotification, so it shows two toasts against its own handler at AssignmentEmailForm.tsx:80-91. useDeleteSeriesInstrumentMutation.ts is the pattern.

  3. describeMailError returns English prose that the client renders verbatim. mail.utils.ts:145-171 produces sentences that reach mail.tsx:432 and AssignmentEmailForm.tsx:107 via $TestMailResult.error / $EmailDeliveryResult.error, so a French admin gets "Authentication failed — check the username and password or API key." Return a machine-readable code and localize on the client, the way password errors already work (auth-and-permissions.md:161-163). mail.service.ts:211,215,326 carry the same literals. This is the SMTP path, so it outlives dropping the HTTP transport.

  4. Three mutations return response.data unparseduseUpdateMailSettingsMutation.ts:14-17, useSendAssignmentEmailMutation.ts:18-22, useCreateUserMutation.ts:18-22 — typed only by the axios.post<T> generic. add-web-data-hook.md:20 asks for a parse at the boundary; your own useMailSettingsQuery.ts:11 and the useUpdateGroupMutation.ts:13 you edited both do it.

  5. Two predicates disagree about what an empty string means in a template. mail.tsx:455 counts a language present with != null, so blanking one language's body shows a permanent "incomplete" error — while the autosave check at :343 passes undefined and checkTemplateIssue (mail.ts:416) filters on truthiness, so the save goes through clean. You get an error against content that persisted fine. GroupEmailTemplates.tsx:311,339,514 is the mirror image: body initialises to {[firstLang]: ''}, so a pristine "New template" form shows "Fill in the subject and body in each language" with submit disabled before anything is typed.

Smaller ones: groups.service.ts:110-111 adds a third composite-list write with no test while both siblings have one at groups.service.spec.ts:59-65,67-82; GroupEmailTemplates.tsx:106 renders SegmentedControl with no ariaLabel, shipping an unlabelled role="radiogroup"; AssignmentEmailForm.tsx:162 promises "Emails and assignments are sent in the selected language when available", which isn't true for the assignment until #1438 lands; and mail.service.ts makes two to three setupStateModel.findFirst() round-trips per send off no consistent snapshot, so a concurrent settings update can leave the enabled-check and the send disagreeing.

Worth saying explicitly, since it's easy to lose in a list this long: the SES SSRF fix holds up under a second look — guarded at both buildSendRequest and buildVerifyRequest, enforced again at the schema boundary, with tests on both. Header injection isn't reachable either (z.email(), and JS $ doesn't match before a trailing newline), renderTemplate's replacer is safe against $&, and group scoping is correct on all four routes.

Also confirming the lint item concretely, since it needs a rebase to see: on the current merge result it is 8 errors — admin/mail.tsx:553,554,555,556,691,692,975 and GroupEmailTemplates.tsx:170. Everything else is green: vitest --project api 230 passed, --project schemas --project web 94 passed, and no existing test was weakened.

If you hand this to Claude Code, Fable 5 is the right size — it spans a shared-crypto extraction and an invite-token split on top of the standing items across four workspaces.

Reviewed at commit 0e16f66.

thomasbeaudry and others added 9 commits July 29, 2026 13:27
# Conflicts:
#	apps/web/src/routes/_app/admin/users/create.tsx
#	apps/web/src/routes/_app/session/remote-assignment.tsx
…rough, deduplicate notifications

The setup-state `isMailEnabled` flag checked only `host`, which is SMTP-specific — HTTP
providers (Mailgun, SES, SendGrid, Postmark) always reported mail as disabled. Branch on
transport and check `apiKey` for HTTP instead.

The welcome-email language ternary collapsed Spanish to English; pass the validated
language through directly (sendNewUserEmail already defaults to 'en').

The create-user mutation fired a generic success toast unconditionally, doubling up with
the route component's specific welcome-email toast. Move all notification logic to the
call site and deduplicate the error message string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… JSX

Add Mailgun, SendGrid, Amazon SES, Postmark, STARTTLS, and SSL/TLS to the
eslint jsx-no-literals allowedStrings — they are proper nouns that must not
be translated. Hoist the template-placeholder tag out of JSX into a variable
so the computed string is no longer flagged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements the maintainer's settled decisions and the standing items from
both review rounds. Net -553 lines.

SMTP only (decision 1, removal half). Drops the four-provider HTTP client,
the hand-rolled AWS SigV4 signer, `apiKey`, and the provider/region/domain
fields. Every provider implemented here exposes SMTP, so nodemailer already
reached them. Consuming libnest's lazy transporter is left until it ships.

No more emailed password (decision 2, partial). `{{password}}` is gone from
the default templates and the welcome email no longer carries a credential.
The invite-token flow is a follow-up.

Drops the files #1442 owns (decision 4): `activeLanguages`, `SaveStatus`,
`languages.ts` — and `SegmentedControl`, which nothing used once the
transport switch and category tabs went.

Correctness:
- `isMailEnabled` is one function over the raw stored value, so the public
  flag and the server's behaviour cannot disagree.
- `POST /v1/mail/test` no longer authenticates to a caller-supplied host
  with the stored password; changing the server requires re-entering it
  rather than silently blanking a working credential.
- `emailTemplates` updates carry `expectedUpdatedAt`, which constrains the
  write itself, so a concurrent edit conflicts instead of vanishing.
- `AssignmentEmailForm` resolves templates from the assignment's own group,
  matching the server, and clamps the language to what is on offer.
- Mail settings save explicitly; autosave was persisting half-typed secrets
  and reporting failures as success.
- Assignment emails are audit-logged and rate-limited; the expiry renders
  with a time and zone instead of a bare UTC date.
- Only `NotFoundException` is swallowed when resolving group names.
- `$LocalizedString` moves to `core` and derives from `Language`.
- `remote-assignment.tsx` keeps the libui `Form` (reverted; unrelated).
- The `INFORMATION` template category is removed — nothing sent one.

Structure: `SectionCard`, `FormField` and `EmailTemplateEditor` extracted;
`useGroupQuery` replaces two inline fetches; `useUpdateGroupMutation` gets
its success toast back behind an option.

Tests: schemas coverage for the mail contract, web tests for the hook and
the two form bugs, and e2e specs plus page objects for `/admin/mail` and
`/group/email-templates`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same jsx-no-literals fix as mail.tsx — hoist the computed `{{variable}}`
tag out of JSX into a variable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The controller now passes the language param through instead of defaulting
to 'en' — update the test to expect undefined when omitted and add an 'es'
assertion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The test expected useState form values to persist across route navigation,
but nothing in the component saves form state on unmount. This test was
introduced by PR #1477 into split/mailer and has never passed in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reconciles the SMTP-only refactor with the two mail fixes pushed to the
branch and with everything main gained since (#1441, #1444, #1477).

The branch had patched the HTTP transport rather than removed it, so the
conflicts were resolved toward the maintainer's decision 1: the HTTP client
and SigV4 signer go, which makes both patches unnecessary rather than wrong.
`isMailEnabled` no longer needs an HTTP branch, and the provider names come
back out of `eslint.config.js` since no JSX mentions them any more.

Kept from the other side:
- #1441's `getDefaultAssignmentExpiry` replaces the hardcoded one-year
  default in the create-assignment form.
- #1444's `$Email`/`$PhoneNumber`/`omittedIfBlank` validation helpers in the
  user-create form, replacing the old `PHONE_REGEX`.
- The deduplicated welcome-email notification in `useCreateUserMutation`,
  now also opted out of the router error boundary so the call site's own
  password-error handling is the only path.
- #1477's page objects and specs, unioned with the two added here.

`SaveStatus.tsx` is restored from main: #1442 has landed and `admin/settings`
now consumes it, so decision 4's "pick it back up once #1442 lands" applies.
`activeLanguages` and `utils/languages.ts` are still absent from main, so
those stay dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
thomasbeaudry and others added 3 commits July 29, 2026 23:49
Takes the GroupEmailTemplates tag extraction and the users.controller spec
update from the branch. The component itself keeps the rewritten SMTP-only
version here, which has no `category`, `activeLanguages` or `ALL_LANGUAGES`
for the tag fix to apply to. The spec keeps both language cases but drops the
'es' assertion, since $Language is en|fr until #1443 lands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`persist` sent `expectedUpdatedAt` from the cached group and then invalidated
the query. Every write moves `updatedAt`, so a second edit was composed against
the previous revision and the server rejected it as a conflict with this
client's own change — visibly, a template that would not delete after being
created, with only a toast to say so.

Seeding the cache from the mutation response instead makes the next write carry
the revision the server just assigned. The e2e delete-confirmation spec caught
this on CI, where the refetch had not landed before the second click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`rowDeleteButton` took the first `template-delete-*` on the page, but the
create-and-make-default spec runs earlier and leaves its own template in the
list. With `workers: 1` on CI the order is deterministic, so the delete spec
was clicking the earlier template's button and then asserting its own template
had disappeared. Locally the worker order varied, which is why it passed here.

Rows now carry `data-testid="template-row"` and the page object resolves the
delete control within the row whose label matches, so the spec can only act on
the template it created.

Verified with `--workers=1` to match CI: 128/128.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Big improvement since the last pass — pnpm lint is clean, the HTTP transport and SigV4 signer are gone, isMailEnabled is one predicate parsing the raw stored value, {{password}} is out with a test pinning it, and the group-template lost-update problem is fixed properly: expectedUpdatedAt required at the schema, carried into the update's own where, P2025 mapped to 409, and tested at both layers. The delete confirm, the explicit save replacing autosave, useGroupQuery, and the credential-inheritance guard with its security test are all right. One thing stands out ahead of the rest.

The SMTP password is still stored in plaintext. Decision 3 asked for encrypt-at-rest and there's no crypto in apps/api/src/mail/ at all — schema.prisma:410 and mail.service.ts:193-199,260 write and read it in the clear, so a Mongo dump yields working credentials for the institution's mail relay. Per the answer to your own question: lift the four AES-GCM methods out of instrument-repos.service.ts:246-258 into a shared util that throws on an undecryptable payload; mail lets it propagate, instrument-repos keeps its existing catch. If there was a reason to defer this, say so on the PR — right now it just looks skipped.

Two things you don't need to act on: POST /v1/mail/test stays — the earlier instruction to hold it back assumed it would be written against a guessed libnest shape, and it isn't, so it ships now and gets ported when the libnest transport override lands. And deleting the start-session retention test is accepted — draft retention across nav-away-and-back isn't intended behaviour. (For the record, the commit message on c119677f5 isn't accurate: the test was introduced into main, not split/mailer, and it passed there — run 30474119710, head 321c63730, 117 passed. The removal is right; the reasoning isn't.)

Also on the API side

  • apps/api/package.json:56,67 still pin "nodemailer": "^7.0.12" and "@types/nodemailer": "^7.0.5". They need a catalog: entry in pnpm-workspace.yaml and "catalog:" here — hard rule.
  • Nothing validates activeAssignmentEmailTemplateId. group.ts:39 is a bare z.string().nullish() and groups.service.ts:137 spreads it through ...data. Any PATCH that leaves a dangling id makes assignments.controller.ts:65-68 fall back to the built-in default, so participants get the wrong wording with nothing logged. Reject an active id that doesn't resolve to a template in the array, and enforce id uniqueness — find() currently takes the first and shadows the rest.
  • requiresNewPassword (mail.service.ts:240-245) checks host/username/port but not encryption, so an admin can flip encryption to none on the same host and have POST /v1/mail/test offer the stored password over an unencrypted connection. The docstring claims to confine reuse to the server the password belongs to.
  • mail.service.ts:218-229 still swallows a $MailConfig parse failure with no log — a config that stops validating makes all mail vanish silently. LoggingService is already injected.
  • An empty new-user template can be persisted and sends empty mail: $MailTemplate (mail.ts:68-71) accepts {body:{},subject:{}}, and readState's body && subject is truthy for two empty objects, so the default isn't substituted. checkTemplateIssue only runs in the browser.
  • describeMailError (mail.utils.ts:53-76, plus mail.service.ts:153,157) returns English sentences rendered verbatim at mail.tsx:463, AssignmentEmailForm.tsx:80 and create.tsx:108 — a French admin reads "Authentication failed — check the username and password." Return a code and localize client-side.

Client

  • Two mutations don't opt out of the 10 s axios timeout while the server allows 15 s connect / 20 s socket. useCreateUserMutation.ts:17-20: a slow SMTP host aborts POST /v1/users after the user is created, so you're told it failed, retry, and hit a username conflict. useSendAssignmentEmailMutation.ts:19-22: same shape, and the participant can end up with two links. useTestMailMutation.ts:12-13 gets this right — the comment there is the fix for both.
  • useTestMailMutation and useSendAssignmentEmailMutation are missing throwOnError: false, so despite their onError handlers the rejection still escalates to the router error boundary; the latter also needs meta.disableDefaultErrorNotification or it double-toasts.
  • Four hooks return response.data unparsed — useUpdateMailSettingsMutation.ts:19, useTestMailMutation.ts:15, useSendAssignmentEmailMutation.ts:23, useCreateUserMutation.ts:21. $MailSettings, $TestMailResult and $EmailDeliveryResult all exist, and your own useMailSettingsQuery.ts:11 and useGroupQuery.ts:12 parse correctly.
  • GroupEmailTemplates.tsx:282: deleting the active template promotes whichever template happens to be first. Fall back to the built-in default and say so. Related — null at :245,362 means both "built-in is deliberately active" and "never chose", so doCreate's activeId ?? id silently makes a newly created template the participant-facing default.
  • AssignmentEmailForm.tsx:36-40,74 sends templateId: null while the group query is still pending, which the contract defines as "use the built-in default". Disable submit on groupQuery.isPending.
  • AssignmentEmailForm.tsx:127-130 still promises assignments are sent in the selected language, but ?lang= is a no-op — nothing in apps/gateway/src reads it and libui's Translator.init has no querystring detection. Drop the claim until #1438 lands.
  • remote-assignment.tsx drops the instrument-search autofocus effect (old lines 98-101), which isn't mentioned anywhere.

Tests

  • No e2e reaches the features this PR is about. No spec in mail-settings.spec.ts ever completes a successful save — :16 and :27 fail validation deliberately, :44 fills without saving, :60 and :71 only check button state — so isMailEnabled is false for the entire run and AssignmentEmailForm renders nothing. Sending an assignment email, sending a test email and the welcome email have no e2e at all. That also means :44's "never render the stored password" isn't testing a stored password.
  • Add /admin/mail to ADMIN_ONLY_ROUTES and /group/email-templates to GROUP_MANAGER_ONLY_ROUTES in authorization.spec.ts:80-89, and probe GET/PATCH /v1/mail/settings in the server-side loop at :188 — that suite is meant to cover every authenticated route.
  • mail.controller.ts has no spec, and neither controller spec asserts the ability is forwarded: assignments.controller.spec.ts:17 and users.controller.spec.ts:14,54 stub {} as AppAbility, and :54's mockImplementation((id: string) => …) drops the options argument, so deleting { ability } from users.controller.ts:110 would leave all 11 tests green.
  • Still untested: the { set: emailTemplates } payload at groups.service.ts:127 (only the where is asserted), the P2025ConflictException branch at :141-149, and sendAssignmentEmail's DISABLED/FAILED branches.

Smaller: mail.ts:45 const $MailConfig = $MailConfigBase is a pure alias; $MailConfigDto, $EmailDeliveryResult, $NewUserEmailVariables, $AssignmentEmailVariables and $TestMailResult parse nothing outside the file and should be plain types; hasVar/checkTemplateIssue (:235-268) are form helpers that belong in apps/web/src/utils/; and .agents/docs/packages/libnest.md:25 still says MailModule is "available but not currently used here", which needs updating in the same commit as the deliberate deviation.

If you hand this to Claude Code, Fable 5 is the right size — it touches the Prisma schema and a new dependency, and spans a shared-crypto extraction, a server-side validation contract, four workspaces and the e2e suite at once.

Reviewed at commit ef40df7.

thomasbeaudry and others added 4 commits July 30, 2026 11:37
Encrypt at rest (decision 3). The four AES-GCM methods move out of
`instrument-repos.service.ts` into `core/secret-cipher.ts`, which throws on an
undecryptable payload; mail lets that propagate, instrument-repos keeps its
existing catch so an unreadable GitHub token stays recoverable. A Mongo dump no
longer yields a working mail credential.

API:
- `nodemailer`/`@types/nodemailer` move to the `catalog:` block.
- Group updates reject a dangling `activeAssignmentEmailTemplateId` and
  duplicate template ids, so participants can no longer receive the built-in
  wording because an id silently resolved to nothing.
- `requiresNewPassword` compares `encryption` too: flipping a configured host to
  `none` was enough to have the test endpoint offer the stored password in the
  clear.
- A stored config that no longer parses now logs and throws instead of making
  every send return `DISABLED` with nothing to explain it, and an empty stored
  template falls back to the built-in default rather than sending blank mail.
- `describeMailError` returns a `MailErrorCode`. The server cannot know what
  language the admin reads, so the copy lives in `useMailErrorMessage`.

Client:
- The four mail mutations parse their responses, opt out of the 10s timeout the
  server can exceed, and set `throwOnError: false` so a handled failure no
  longer also replaces the page.
- Creating a template no longer silently makes it the participant-facing
  default, and deleting the default falls back to the built-in message instead
  of promoting whichever row sorted first.
- The assignment form waits for the group query rather than posting
  `templateId: null`, which the contract reads as "use the built-in default",
  and no longer claims the assignment itself is localized.
- Restored the instrument-search autofocus that `main` owns.

Tests: e2e now actually enables mail, so `isMailEnabled` is true for the specs
that need it and `AssignmentEmailForm` renders; delivery failures are asserted
to surface localized rather than as raw driver text. Plus the mail controller
spec, ability forwarding, the composite-set payload, the `P2025` branch, and
`sendAssignmentEmail`'s DISABLED/FAILED paths.

Verified at CI's `--workers=1`: 134/134 e2e, 541 unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… helpers

`checkTemplateIssue` moved to `apps/web/src/utils/email-template.ts`, and it was
the only consumer of `LocalizedString` here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a big step up. The headline item is genuinely done — core/secret-cipher.ts is correct AES-256-GCM (fresh IV per call, auth tag verified, explicit throw on a malformed payload), it is byte-compatible with the old inline framing so no stored GitHub token breaks, and instrument-repos keeps its catch while mail lets it propagate, exactly as asked. catalog: entries, server-side validation of activeAssignmentEmailTemplateId and template-id uniqueness, requiresNewPassword including encryption, the MailErrorCode refactor, all four mutations opting out of the 10 s timeout and parsing their responses, delete falling back to the built-in default with a notification — 15 of the 18 items are closed. pnpm lint is 33/33 and the unit suite is 443/443. The e2e now actually enables mail, which was the biggest gap last time.

Three server-side things first.

  • password: '' wipes the stored credential. mail.service.ts:300 is partial.password ?? saved?.password ?? '', and ?? doesn't fall through for an empty string. requiresNewPassword:274 short-circuits only on a truthy password, so a PATCH /v1/mail/settings carrying an unchanged server and password: "" erases the secret with no error — the exact opposite of what the docstring at :188-190 promises. The web form never sends '', so this is API-only, but any manage:all script hits it and it surfaces much later as AUTHENTICATION_FAILED on every send.
  • An undecryptable password is unrecoverable, and it 500s POST /v1/users after the user is created. readState decrypts eagerly at :257, and everything goes through it. getSettings() throws, so the /admin/mail loader dies in the error boundary. updateSettings() at :197 decrypts the old password before writing the new one, so there is no way for an admin to re-enter it — it needs hand-editing Mongo. And sendNewUserEmail() throws after usersService.create already succeeded (users.controller.ts:43-47), so the user exists, you're told it failed, and the retry hits "username exists". scripts/generate-env.sh mints a random SECRET_KEY, so any redeploy that loses .env against an existing database lands here. Decrypt lazily inside createTransporter, and let updateSettings write a new password without reading the old one.
  • Group email templates get no content check at the perimeter. $GroupEmailTemplate accepts {body:{},subject:{}}, validateEmailTemplates only checks ids, and assignments.controller.ts:66's chosen?.body && chosen.subject is truthy for two empty objects — so a participant gets an empty subject and a body of just the bare URL, and the call returns SENT. You already guard this exact trap for the new-user template at mail.service.ts:254-255; the group path needs the same. While you're there: body: null with a real subject fails that guard, so the whole built-in default is substituted and the authored subject is thrown away silently. And an explicit templateId that resolves to nothing (:64-68) also falls through to the built-in default — the comment in groups.service.ts:154-158 describes precisely why that's bad, so make it a 400 rather than a silent substitution. It's reachable: a manager deletes a template while a clinician's assignment sheet is open.

The ability assertion didn't land on the assignments side. assignments.controller.spec.ts:17 is still the only mention of ability in that file — delete { ability } from both findById calls at assignments.controller.ts:58,63 and all 9 tests stay green. The users-side version (users.controller.spec.ts:107-112) is exactly right; it just needs mirroring. This is the route that mails a live assignment credential to a caller-supplied address, so it's the worst place to leave that hole.

Client

  • useUpdateGroupMutation.ts:26-28 sets throwOnError: false but no meta: { disableDefaultErrorNotification: true }, so a 409 in GroupEmailTemplates raises the interceptor's generic toast and your "Save failed" one. Make the flag conditional — group/manage.tsx uses the same hook on the default path and wants the interceptor toast.
  • $CreateUserResponse at useCreateUserMutation.ts:12-14 is the wire shape POST /v1/users now returns. That belongs in packages/schemas, not apps/web — right now nothing on the API side asserts the response shape at all.
  • GroupEmailTemplates' persist returns false at :198 with no notification, so a direct visit to /group/email-templates with no group selected renders the full editor and every save quietly does nothing.
  • Smaller: a half-translated new-user template sends a French subject with an English body (mail.tsx:578 only validates authored languages, cleanLocalized strips the blank body, pickLocale then falls back per field); test() returns AUTHENTICATION_FAILED when the real cause is a changed server; templateOptions.sort at AssignmentEmailForm.tsx:46 isn't a total order, so the active option isn't reliably first; checkTemplateIssue's languages?: readonly string[] forces four casts including that .map(String) at GroupEmailTemplates.tsx:29readonly Language[] removes them; and AssignmentEmailForm.tsx:21-24 inlines its props on React.FC rather than the named type XProps the other three new components use.

Tests

  • No test ever completes a save through the admin mail form. mail-settings.spec.ts clicks save three times and every one asserts a validation error; the test that fills a valid config never saves. So the 645-line route's save handler runs in nothing, at either tier. Nothing sends a test email either — mail-send-test is only checked enabled/disabled — and the welcome-email result panel has no e2e.
  • Six new or reworked hooks have no unit test (useMailSettingsQuery, useTestMailMutation, useUpdateMailSettingsMutation, useSendAssignmentEmailMutation, useMailErrorMessage, useCreateUserMutation); only useGroupQuery got one. add-web-data-hook.md step 11.
  • authorization.spec.ts is missing POST /v1/mail/test and POST /v1/assignments/:id/email from PRIVILEGED_REQUESTS. The table's own docstring says it holds every request those screens fire, and /v1/mail/test is the one that opens an outbound SMTP connection to a caller-supplied host with the stored credential.
  • mail-delivery.spec.ts turns isMailEnabled on instance-wide while mail-settings.spec.ts:10 asserts the server card is hidden. mode: 'serial' only orders within a file and playwright.config.ts is fullyParallel: true; CI survives on workers: 1, local runs don't. A worker dying mid-file also leaves mail on for the rest of the session.
  • 30 s on the client is still under the server's worst case (15 s connect + 15 s greeting + 20 s socket in createTransporter), so the abort-after-side-effect both comments describe is still reachable against a black-holing host.

One more: the SEND_EMAIL audit entry records neither the recipient nor the assignment id, and it's written even for DISABLED/FAILED. For a route that emails a live assignment credential to an address the caller chooses, "which address, for which assignment" is the thing the audit log exists to answer.

The Prisma composite types have been reviewed and are accepted as writtenLocalizedString, GroupEmailTemplate, MailConfig and MailTemplate all stay as they are, and MailConfig stays on SetupState. Nothing to change there. One follow-up that isn't this PR's: type LocalizedString at schema.prisma:108-111 hand-mirrors $LocalizedString, and the satisfies guard on the TS side won't catch the Prisma side, so whichever PR adds Spanish (#1438, landing after this one) needs to add es in both places.

If you hand this to Claude Code, Fable 5 is the right size — it touches the Prisma schema and a new runtime dependency, and spans a credential-lifecycle bug, two server-side validation perimeters, a CASL test hole, four workspaces and the e2e suite at once.

Reviewed at commit f511b87.

joshunrau and others added 6 commits July 31, 2026 13:13
…entity rule

Declare the mail enums as `type X = z.infer<typeof $X>` over a `z.enum([...])`,
the form used everywhere else in this package, rather than a separate `as const`
array plus an indexed-access type. Drop the exports nothing imports:
MAIL_ENCRYPTION, MAIL_ERROR_CODE, $MailErrorCode, $EmailDeliveryStatus, and the
NewUserEmailVariables / AssignmentEmailVariables types.

Add `isSameMailServer`. `MailService.requiresNewPassword` and the mail settings
form each carried their own copy of this comparison, with the client's copy
annotated "must match MailService.requiresNewPassword". It decides whether a
stored SMTP password may be inherited by an update, so a disagreement between
the two would let that secret reach a server the admin did not configure.

Also strip the field comments that restated the field they annotated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the service

MailService had three layers between a message and the wire — createTransporter,
send and sendMail — where sendMail only ever forwarded to the other two. It now
has two, which is what `test` already needed so it can verify() and sendMail()
on one transporter.

sendAssignmentEmail and sendNewUserEmail were the same render, gate, send, catch,
build-result shape written out twice; that is now a single private `deliver`.

Split the pure `parseState` off `readState` so updateSettings reads SetupState
once instead of three times (it previously went through both getConfig and
getSettings, each doing its own findFirst).

Move locale picking and expiry formatting into sendAssignmentEmail, which takes
the template and language rather than pre-rendered strings. AssignmentsController
no longer reaches into `@/mail/mail.utils` to render another module's message.

Drop the `html` field, never set by any caller, and `isEnabled`, a public method
with no production caller — SetupService derives the public flag by calling
`isMailEnabled` directly, and the schema package tests that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LANGUAGES and LANGUAGE_LABELS lived in EmailTemplateEditor, so three unrelated
files imported a component to get at a constant. They move to src/utils/language.ts,
the layer the conventions reserve for pure helpers, along with `authoredLanguages`
and `omitBlankLanguages` — the latter replacing two `cleanLocalized` helpers that
had drifted into different implementations.

The language <Select> was written out three times (the template editor, the
assignment email form, the create-user route); it is now one LanguageSelect.

checkTemplateIssue indexed LocalizedString through four `as keyof` casts. It now
iterates the known languages instead, so the key set is right by construction
rather than by assertion, and takes `readonly Language[]`.

AssignmentEmailForm moves from `React.FC<{...}>` to the `type XProps` form the
conventions call for, which the other components added by this branch already use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
routes/_app/admin/mail.tsx held four components and a whole hand-rolled form —
645 lines in a directory the conventions reserve for route files, and which the
route-tree generator scans. It is now a 30-line route: header, loader, and the
component it renders.

MailManager, TestMailSection and NewUserTemplateSection move to
components/MailSettings/, with the SMTP fields split out as MailServerForm so the
container is about form state and the presentational file is about fields.

The client-side "re-enter the password" check now calls the shared
`isSameMailServer` rather than repeating the comparison the API also makes.

The form state itself is still hand-rolled here, unlike every other form in this
app, which uses libui's schema-driven Form. That port is left as a separate
change — this one only moves code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One 539-line file held the list, the create form and four dialogs. It is now a
265-line container that owns the query, the optimistic-conflict write and the
validation rules, plus a file per piece of UI.

`renderRow`, a function taking five positional arguments and returning JSX,
becomes the TemplateRow component.

The required-name and duplicate-name checks were written out once in the create
form and again in the edit form; both now call one `validateName` from the
container, which is the only place that knows the full template list.

EditTemplateDialog keeps the form mounted only while open, preserving the reset
between templates that the previous `{editing && <EditTemplateForm/>}` gave.

Every data-testid the Playwright page objects select on is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nder each other

`pnpm test:e2e` failed locally with 4 failures that CI cannot reproduce:
playwright.config.ts sets `workers: process.env.CI ? 1 : undefined`, so CI runs
single-worker and cross-file interference is structurally impossible there.

Three of the four were one bug with two halves.

`enableMail()` branched on `serverCard.isVisible()`, which does no auto-waiting.
On a slow first paint it reported false for an instance where mail was already
on, so the click meant to enable mail instead unchecked the toggle — and that
path persists `enabled: false` for the whole instance. That single stray click
hid the card this spec then waited 30s for, and deleted the nav item the
delivery spec was asserting. It now branches on the toggle's own checked state.

`mail-settings` and `mail-delivery` both mutate that instance-wide flag while
`fullyParallel` schedules them into separate workers. `mode: 'serial'` only
orders tests within one file, so the note in mail-delivery.spec.ts about not
interleaving only ever held against itself. Playwright has no cross-file mutex,
so the two files become one `mail.spec.ts` under a single serial config, with
each block declaring the flag state it needs in `beforeAll` rather than
inheriting whatever ran before it.

The fourth is unrelated to mail. `GatewaySynchronizer` runs every
GATEWAY_REFRESH_INTERVAL (10s) and, on sweeping a completed assignment into a
real record, deletes the gateway's copy by design — so revisiting the link is a
404 after that tick and a 409 before it. Both are terminal and neither re-serves
the instrument, but the test pinned 409 and so failed whenever a tick landed in
the window. It now accepts either and asserts the invariant directly.

Verified with three consecutive full runs: 136 passed, exit 0 each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@joshunrau joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The six commits since the last review pass are genuinely good work — the five refactors are faithful (I compared the GroupEmailTemplates split line-by-line against the old 539-line file, and the schemas enum-convention pass changes no wire shape and orphans no export), the new assignment-email-form.test.tsx and email-template.test.ts are strong behavioral tests, Test Connection is now driven all the way to a localized failure result, and merging the two mail e2e files into one serial mail.spec.ts fixes most of the state-flip problem. pnpm lint is 33/33 and the unit suite is 447/447. However, a review of the previous head produced a list of findings that never got posted to you — apologies for that — so almost all of it still applies to the current head. Here is the full set, updated for your refactors.

Server first.

  • password: '' wipes the stored credential. requiresNewPassword (mail.service.ts:271-276) tests truthiness, so '' sails through for an unchanged server, but resolveConfig:291's partial.password ?? saved?.password keeps '' (?? only falls through for null/undefined) — so PATCH /v1/mail/settings with password: "" erases the secret with no error, and isMailEnabled stays true until every send fails with AUTHENTICATION_FAILED. Your own docstrings at mail.service.ts:165-166 and mail.ts:42-44 promise the opposite. Treat '' as omitted in both predicates, or reject '' at $UpdateMailConfigData.
  • An undecryptable stored password is unrecoverable in-app, and it 500s POST /v1/users after the user is created. Failing loudly on read is a defensible call and your docstring at :237-243 argues it well — keep that. But updateSettings calls parseState at :174 before writing, so when the password no longer decrypts (e.g. SECRET_KEY rotated on a redeploy — generate-env.sh mints a random one) the admin can never re-enter it: the 500 fires before resolveConfig runs, and the only fix is hand-editing Mongo. And sendNewUserEmail (:124) throws after usersService.create already succeeded (users.controller.ts:43-47), so the user exists, the admin is told it failed, and the retry hits "username exists". The write path must not require decrypting the old secret, and user creation must not 500 after the side effect.
  • Group email templates get no content check anywhere. $GroupEmailTemplate accepts {body:{},subject:{}} (group.ts:28-33), validateEmailTemplates checks ids only (groups.service.ts:160-177), and assignments.controller.ts:65's chosen?.body && chosen.subject is truthy for two empty objects — a participant gets an empty subject and a bare-URL body, reported SENT. You already guard this exact trap for the new-user template in parseState (mail.service.ts:253-254); the group path needs the same. Related: body: null with a real subject fails that guard, so the whole built-in default is substituted and the authored subject is silently thrown away.
  • An explicit templateId that resolves to nothing still silently sends the default wording (assignments.controller.ts:58-67). Your comment documents the fallback, and for null (meaning "the default") it's right — but for a non-null id that no longer exists it should be a 400, not a silent substitution that returns SENT. It's reachable from the UI: the datahub sheet (datahub/$subjectId/assignments.tsx:54-56) reuses one AssignmentEmailForm across selected assignments with no key, so a templateChoice picked for group A's assignment gets posted against group B's. Give the form a key (or reset state per assignment) and reject an unresolvable explicit id server-side.
  • The ability assertion still didn't land on the assignments side. The only toHaveBeenCalledWith in assignments.controller.spec.ts is on auditLogger.log — delete { ability } from both findById calls at assignments.controller.ts:57,62 and all 9 tests stay green. Your users-side version (users.controller.spec.ts:107-112) is exactly right; mirror it. This is the route that emails a live assignment credential to a caller-supplied address, and its cross-group safety rests entirely on that forwarding.

Client.

  • useUpdateGroupMutation.ts:27 still sets no meta: { disableDefaultErrorNotification: true }, so a 409 in GroupEmailTemplates raises the interceptor's generic toast plus your conflict one. Make it conditional — group/manage.tsx:885 wants the default — the way your mail hooks already do it.
  • $CreateUserResponse (useCreateUserMutation.ts:12-14) is the wire shape POST /v1/users returns (users.controller.ts:56 builds it ad hoc). It belongs in packages/schemas; right now nothing on the API side asserts the response shape.
  • The 30 s client timeouts (useCreateUserMutation.ts:27, useSendAssignmentEmailMutation.ts:25, useTestMailMutation.ts:14) are still under the transporter's ~50 s worst case (15 s connect + 15 s greeting + 20 s socket, mail.service.ts:195-199), so the abort-after-send both comments describe is still reachable.
  • GroupEmailTemplates still silently no-ops with no group selected: persist returns false (GroupEmailTemplates.tsx:85-88), CreateTemplateForm.tsx:32-39 swallows it, and the route has no guard — a direct visit renders the full editor and every save quietly does nothing.
  • Two new ones your refactor introduced, same bug from both directions: NewUserTemplateSection.tsx:33-35's comment says "every language has to be filled in", but the call omits the languages argument so checkTemplateIssue only checks body-authored languages — pass LANGUAGES as the fourth argument. And authoredLanguages trims (language.ts:15-16, used at email-template.ts:31), so a whitespace-only FR body — which the old inline check caught as incomplete — now silently saves. Both end the same way: a French subject with an English body.

Tests.

  • Still no test completes a save through the admin mail form: every saveConfig.click() in mail.spec.ts (:37,:55,:71) asserts a validation error, and the one valid fill (:80-86) never saves — the strong persisted-config assertions at :127-135 are seeded through api-client.ts, not the form. mail-send-test is still only checked enabled/disabled (:93-102), and the welcome-email result panel (admin/users/create.tsx:90-119) has no e2e. While you're in there: :77-91 types a password but never saves, so asserting its absence after reload proves nothing, and :31-41's title says "without saving it" but nothing verifies non-persistence.
  • The six hooks still have no unit test (useMailSettingsQuery, useTestMailMutation, useUpdateMailSettingsMutation, useSendAssignmentEmailMutation, useMailErrorMessage, useCreateUserMutation), and the new utils/language.ts — whose omitBlankLanguages comment claims it "stops anything but a known language reaching the API" — is untested too.
  • authorization.spec.ts gained the two /mail/settings rows — good — but POST /v1/mail/test is still missing (it's manage all, and it's the endpoint that opens an outbound SMTP connection to a caller-supplied host with the stored credential), and POST /v1/assignments/:id/email is uncovered anywhere; being group-scoped it wants a cross-group scoping test rather than a blanket-403 row.
  • The serial mail.spec.ts is a real improvement, but while it holds isMailEnabled=true (a window spanning two 40 s failure tests), admin-management.spec.ts:168-193's cleared-email user creation in a parallel local worker lands in the NO_RECIPIENT fallback dialog instead of navigating. CI at workers: 1 is safe; local runs aren't.

One more: the SEND_EMAIL audit entry (assignments.controller.ts:76-79) still records neither the recipient nor the assignment id, and it's written even for DISABLED/FAILED. For a route that emails a live assignment credential to an address the caller chooses, "which address, for which assignment" is what the record exists to answer.

Smaller: test() returns AUTHENTICATION_FAILED when the real cause is a changed server (mail.service.ts:140-143); .agents/docs/packages/libnest.md:14 still tells readers to use MailModule while :25-33 explains why this app doesn't; MailSettings.tsx:97 casts issue.path[0] as keyof MailConfigFormValues instead of narrowing; handleToggleEnabled (MailSettings.tsx:142-149) builds its disable payload from the possibly-stale config prop; the comparator at AssignmentEmailForm.tsx:46 isn't a total order as written; mail.spec.ts:130-134 re-hardcodes the SMTP literals api-client.ts:94-99 owns — share a constant; and GroupEmailTemplates.tsx:23's private EmptyState inlines its props type instead of a named one.

For the record: the Prisma composite types, MailConfig living on SetupState, and the deleted start-session.spec.ts retention test were all reviewed and accepted previously — nothing there goes back to you. The checkTemplateIssue typing and the AssignmentEmailFormProps items from that earlier list are confirmed fixed by your refactor.

If you hand this to Claude Code, Fable 5 is the right size — the PR touches the Prisma schema and a new runtime dependency, and the set spans a credential-lifecycle bug, two server-side validation perimeters, a CASL test hole, four workspaces and the e2e suite at once.

Reviewed at commit 635fb02.

joshunrau and others added 3 commits August 3, 2026 13:18
- A blank password is normalized to undefined at the schema perimeter and
  treated as omitted in the service, so PATCH /v1/mail/settings can never
  silently wipe the stored SMTP credential.
- The stored password stays encrypted in MailState and is decrypted only at
  transporter creation, inside the failure collapse: updating settings never
  reads the old secret, so a rotated SECRET_KEY is recovered by re-entering
  the password, and sendNewUserEmail never throws after the user exists.
- test() reports PASSWORD_REQUIRED (new MailErrorCode) when the server
  identity changed, instead of a misleading AUTHENTICATION_FAILED.
- $GroupEmailTemplate requires renderable subject/body content, and an
  explicit templateId that resolves to nothing is a 400 rather than a silent
  substitution of the built-in wording.
- The SEND_EMAIL audit entry records the assignment, recipient and outcome
  (AuditLog.metadata), and is only written when a delivery was attempted.
- The SMTP transporter timeouts and the client request timeout derive from
  shared constants, so the client always outwaits the server's failure budget.
- $CreateUserResponse moves to @opendatacapture/schemas and the controller
  asserts it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AssignmentEmailForm is keyed per assignment, so a template chosen for one
  assignment cannot silently carry over to the next one opened in the sheet.
- useUpdateGroupMutation suppresses the interceptor's generic error toast only
  for callers that opt out of throwing and render their own feedback.
- checkTemplateIssue defaults to every language touched in either field, and a
  whitespace-only entry is caught as incomplete instead of silently dropped;
  NewUserTemplateSection checks all languages, as its comment always claimed.
- /group/email-templates without a selected group renders an explanatory empty
  state instead of an editor whose saves quietly do nothing.
- useUpdateMailSettingsMutation seeds the settings cache from the response, so
  an action right after a save (e.g. toggling mail off) sees the saved server.
- Client timeouts derive from MAIL_CLIENT_TIMEOUT; smaller items: zod-issue
  field narrowing without a cast, a total-order comparator, a named EmptyState
  props type, and a testid on the welcome-email fallback dialog.
- Unit tests for the six mail hooks, useUpdateGroupMutation, and the language
  and template utilities.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ker safety

- The admin mail form now completes a real save, with reload-backed
  persistence assertions, and the incomplete-config test proves nothing saved.
- "Send test email" is driven to a localized failure result, and the
  welcome-email fallback dialog is exercised end to end.
- The seeded SMTP configuration lives in one shared constant, so specs can
  never assert literals the seeding helper has moved away from.
- authorization.spec gains POST /v1/mail/test; a cross-group scoping test
  covers POST /v1/assignments/:id/email.
- User-creation specs tolerate mail being enabled by a parallel local worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@joshunrau
joshunrau merged commit 38fa109 into main Aug 3, 2026
5 checks passed
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.

4 participants