feat(mail): add outgoing email, group email templates, and assignment emails - #1439
Conversation
… 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>
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>
|
Updated with latest What was red: the Cause: this PR replaces the libui Fix: the page object now uses Also merged |
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>
|
Also fixed the CodeQL failure (separate from the e2e one above). What it flagged: 2 critical Fix: constrain the region to the AWS region character set ( CodeQL now passes with 0 open critical alerts on the PR; full unit suite 405 passed / 1 skipped. |
gdevenyi
left a comment
There was a problem hiding this comment.
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
-
isMailEnabledis false for every HTTP-transport provider.setup.service.tsderives it frommailConfig.enabled && mailConfig.host, buthostis''on thehttptransport. So an instance configured with Mailgun/SendGrid/SES/Postmark hasMailService.isEnabled() === true(mail sends fine) while the public flag says false — which hides the Mail nav item, makesAssignmentEmailFormreturnnull, and hides the welcome-email language picker. The whole HTTP path is unreachable through the UI. These two predicates need to be one function. -
AssignmentEmailFormresolves templates from the wrong group. The component queries/v1/groups/{currentGroup.id}from the app store, but the server resolves the template fromassignment.groupId. On/datahub/$subjectId/assignmentsthose differ whenever the assignment belongs to another group: the dropdown lists the wrong group's templates, and thetemplateIdposted 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. -
SMTP password and provider secrets are stored in plaintext.
MailConfig.password/apiKey(the latter being the AWS secret access key for SES) sit unencrypted inSetupStateModel. 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
-
The language set is now defined in five unrelated places —
MAIL_LANGUAGE,$LocalizedString's keys, Prisma'sLocalizedStringtype,ALL_LANGUAGES(typed{ [key: string]: string }), and$SetupState.activeLanguages: z.array(z.string()). Nothing makes them agree, andactiveLanguageswill 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. -
useUpdateGroupMutationlost its success toast for every caller. TheonSuccessnotification was deleted from the hook and re-added at one call site ingroup/manage.tsx. Any other caller now saves silently.useUpdateSetupStateMutationalready models the right pattern — an optionalsuccessNotificationon the hook. -
Lost-update race on
emailTemplates.persist()sends the entire array from the client's cached copy and the service replaces it withset. Two group managers editing concurrently means one set of edits vanishes with no error. -
remote-assignment.tsxreplaces 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 withnew Date(string)— which treats2026-08-01as UTC midnight but2026/08/01as local. This is also the file that conflicts with #1441. -
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.
| 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), |
There was a problem hiding this comment.
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.
| enabled Boolean | ||
| encryption String | ||
| host String | ||
| password String |
There was a problem hiding this comment.
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] |
There was a problem hiding this comment.
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
templateIdposted isn't found ingroup.emailTemplates, sochosenisundefinedand the controller silently falls back toDEFAULT_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>( |
There was a problem hiding this comment.
language is initialised once, but the option list it has to belong to (languageDropdownOptions, derived from the selected template's populated languages ∩ instrumentLanguages ∩ activeLanguages) 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 }); |
There was a problem hiding this comment.
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.
| import { z } from 'zod/v4'; | ||
|
|
||
| import { $BaseModel, $RegexString } from '../core/core.js'; | ||
| import { $LocalizedString } from '../mail/mail.js'; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| /** Send a message using the currently saved configuration and its active transport. */ | ||
| private async sendMail(options: SendOptions): Promise<void> { |
There was a problem hiding this comment.
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.
| }; | ||
|
|
||
| // Autosave the SMTP configuration whenever the form settles on a valid state. | ||
| useAutosave(JSON.stringify(values), () => { |
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
-
The branch no longer passes
pnpm lintagainst currentmain.maingained areact/jsx-no-literalsrule forapps/webin08d36a093on 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.tsxlines 553-556, 691, 692, 975, andapps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsxline 170. The provider and encryption names (Mailgun, SendGrid, Amazon SES, Postmark, STARTTLS, SSL/TLS) are proper nouns and can go inallowedStringsineslint.config.js; the two`{{${variable}}}`cases need to move into a named variable outside the JSX. -
isMailEnabledis computed wrongly, which makes the entire HTTP-transport half unreachable from the UI.apps/api/src/setup/setup.service.ts:61isBoolean(mailConfig?.enabled && mailConfig.host), buthostis 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, andAssignmentEmailForm.tsx:124returnsnullso assignment emails become unsendable. Call the same predicate asMailService.isEnabled()(mail.service.ts:107-124) rather than duplicating it. -
A failed settings save reports success.
useUpdateMailSettingsMutation.ts:15setsmeta.disableDefaultErrorNotificationwith noonErrorand nothrowOnError: false, and theSaveStatuseffect atmail.tsx:356-369only watchesisPending— so a rejected PATCH shows "All changes saved".useAutosave(mail.tsx:148-162) also advanceslastSaved.currentbefore callingonSave, so the failed snapshot is never retried. Silently discarding an admin's SMTP edits is the caseCLAUDE.mdrules out outright. -
POST /v1/mail/testcan be pointed at an arbitrary host with the stored password.mail.service.ts:208-232accepts an unsavedconfig, andresolveConfig(279-292) merges the stored password wheneverpartial.passwordis 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 thehasPasswordmasking this PR adds. Only inherit a stored secret whentransport/host/providermatch the saved config. -
GroupEmailTemplates.tsxleaves the UI showing unpersisted state on error.285-293—if (isPending) … else if (isSuccess)leavessaveStateunset on failure, so the pill spins on "Saving…" forever. Lines438,458,502and566voidamutateAsyncwhose rejection nothing catches, sosetEditing(null)and thedoCreateform reset never run.
Correctness and conformance
-
activeLanguagesis 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 inpackages/schemas/src/mail/mail.tsis unreachable. Worse,$MailLanguage.parse(activeLanguages[0] ?? 'en')atmail.tsx:184andcreate.tsx:49throws during render on an unknown code, which is a white screen rather than a fallback. UsesafeParsewith a fallback. (This field also overlaps #1442 — see the note at the bottom.) -
apps/api/src/users/users.controller.ts:52—url: 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. -
users.controller.ts:38—@Query('language') language?: MailLanguageis typed but unvalidated;apps/api/AGENTS.mdrequires an explicitParseSchemaPipe. Line 50 then collapses it withlanguage === 'fr' ? 'fr' : 'en', silently downgrading'es'to English despite'es'being in$MailLanguageand in both default templates. -
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:97currently pins that behaviour. -
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. -
apps/web/src/routes/_app/session/remote-assignment.tsx:21-66should be split into its own PR. Replacing the libuiForm(withkind: 'date'andz.coerce.date().min(new Date())tied toCreateAssignmentData) with a textInput placeholder="YYYY-MM-DD"and hand-writtenisNaNchecks 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 attesting/src/pages/_app/session/remote-assignment.page.ts:24. -
The
INFORMATIONtemplate category is authorable, activatable and persisted, but nothing ever sends one — no route, no UI action. Remove it until there's a consumer.
Tests
-
No e2e test.
CLAUDE.mdrequires one per change, and the wholetesting/delta here is the locator repair forced by item 11. Add page objects for/admin/mailand/group/email-templates, register them intesting/src/support/fixtures.ts, and add specs — see.agents/docs/playbooks/add-e2e-test.md. -
Zero
data-testidacrossmail.tsx,GroupEmailTemplates.tsx,AssignmentEmailForm.tsx,SegmentedControl.tsxandSaveStatus.tsx.apps/web/AGENTS.mdmakes these the Playwright selector contract, so item 13 can't be written without them. -
No web unit tests for the five new hooks or three new components, and no
schemastest pinning that$MailConfigDtostripspasswordandapiKey— the one thing that schema exists to do.checkTemplateIssue/hasVarare pure functions in a vitest project and untested. On the API side,mail.service.spec.tsnever exercisestest({ config }), which is the path item 4 is about.
Structure and style
-
admin/mail.tsxis 1031 lines against a 338-line repo maximum for a route file, andGroupEmailTemplates.tsxis 693 against 378 for a component.SectionCard(mail.tsx:48) andField(mail.tsx:53) are generic presentational helpers that belong insrc/components/. -
AssignmentEmailForm.tsx:44-48andGroupEmailTemplates.tsx:295-297inline the samequeryKey: ['group', groupId]+axios.get('/v1/groups/…')fetch.apps/web/AGENTS.mdputs all data fetching insrc/hooks/, one file per hook, exporting the key — otherwise no mutation can invalidate it reliably. -
useUpdateGroupMutation.tssilently loses its success notification, so saving on/group/manageno longer toasts. Unremarked behaviour change to an existing screen. -
mail.tsx:250-290re-implements the messages incheckTransportFields(packages/schemas/src/mail/mail.ts:118-169), andmail.tsx:330hand-rolls an email regex beside$TestMailData.recipient'sz.email(). One source of truth. -
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. Line353also reaches out withdocument.querySelector('.overflow-y-scroll'), coupling the component to an ancestor's utility class. -
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.
|
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 signerRemove the four-provider HTTP client and the hand-rolled AWS SigV4 signer ( Your justification for not using libnest's So we're fixing it at the source instead: libnest gets a small change making the transporter lazily derived and letting Knock-on effects: blocking item 2 from my review ( 2. Temporary passwords: replace with an invite linkEmailing 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 While you're in that controller, 3. Encrypt the SMTP password at rest
4. Drop the files shared with #1442#1442 owns To be clear about what survives from my earlier review: items 1 (lint), 3 (failed save reporting success), 4 (test-endpoint credential), 5 ( 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. |
|
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 — |
|
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 fromYou asked me to raise this before building, so: static encrypt({ plainText, publicKey }): Promise<{ cipherText, symmetricKey }>
static decrypt({ cipherText, privateKey, symmetricKey }): Promise<string>so decryption needs the private key and the per-message The repo already solves this exact problem elsewhere. /** 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();
}
Which leaves one question for you: copy the four methods into One consequence worth stating explicitly either way: keying off Decision 1 — libnest release
So I'm splitting Decision 1 in two: doing the removal half now (dropping the HTTP client, the SigV4 signer, What I'm doing in the meantime
Also removing the |
joshunrau
left a comment
There was a problem hiding this comment.
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
-
AES-GCM helpers — lift them. Move the four methods out of
instrument-repos.service.ts:245-258into 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 turnInstrumentReposService.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. -
An SMTP password that no longer decrypts — throw. Don't log-and-treat-as-unconfigured. Same answer applies to
mail.service.ts:64-65, wheregetConfig()currently swallows a$MailConfig.safeParsefailure and returnsnull: a stored-but-invalid config makes every send returnDISABLEDwith no log, so the admin sees a configured mail page and no mail. -
libnest transport override — don't write it yet. Do the removal half of Decision 1 and leave
POST /v1/mail/testout of this PR entirely; it waits for the release rather than being written against a guessed shape. I'll ping you when it lands. -
Invite-token flow — split approved.
{{password}}removal lands here; the token model, the set-password route and theOrigin-header fix go to the follow-up. No gap in the meantime:$CreateUserData.passwordis required and the admin types it in the create form, so credentials get handed over out-of-band exactly as they do onmaintoday.
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
-
apps/api/package.json:56,67pin literal versions."nodemailer": "^7.0.12"and"@types/nodemailer": "^7.0.5"need to move into thecatalog:block ofpnpm-workspace.yamland become"catalog:"here.CLAUDE.mdmakes that a hard rule, and nodemailer is the dependency that survives Decision 1. -
Three mutations are missing
throwOnError: false.services/react-query.ts:6sets ittrueglobally, so handling an error inonErroror acatchisn't enough — it still escalates to the router error boundary.useCreateUserMutation.ts:16-23is the one that matters: the old version caught the 400 insidemutationFnand returned a discriminated result, so it never rejected. Nowcreate.tsx:69-91catches the rejection and has the localized password message ready, but the page blows up before the user sees it.useTestMailMutation.ts:11-14is the same shape.useSendAssignmentEmailMutation.ts:18-21sets neitherthrowOnError: falsenormeta.disableDefaultErrorNotification, so it shows two toasts against its own handler atAssignmentEmailForm.tsx:80-91.useDeleteSeriesInstrumentMutation.tsis the pattern. -
describeMailErrorreturns English prose that the client renders verbatim.mail.utils.ts:145-171produces sentences that reachmail.tsx:432andAssignmentEmailForm.tsx:107via$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,326carry the same literals. This is the SMTP path, so it outlives dropping the HTTP transport. -
Three mutations return
response.dataunparsed —useUpdateMailSettingsMutation.ts:14-17,useSendAssignmentEmailMutation.ts:18-22,useCreateUserMutation.ts:18-22— typed only by theaxios.post<T>generic.add-web-data-hook.md:20asks for a parse at the boundary; your ownuseMailSettingsQuery.ts:11and theuseUpdateGroupMutation.ts:13you edited both do it. -
Two predicates disagree about what an empty string means in a template.
mail.tsx:455counts a language present with!= null, so blanking one language's body shows a permanent "incomplete" error — while the autosave check at:343passesundefinedandcheckTemplateIssue(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,514is the mirror image:bodyinitialises 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.
# 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>
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
left a comment
There was a problem hiding this comment.
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,67still pin"nodemailer": "^7.0.12"and"@types/nodemailer": "^7.0.5". They need acatalog:entry inpnpm-workspace.yamland"catalog:"here — hard rule.- Nothing validates
activeAssignmentEmailTemplateId.group.ts:39is a barez.string().nullish()andgroups.service.ts:137spreads it through...data. Any PATCH that leaves a dangling id makesassignments.controller.ts:65-68fall 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 notencryption, so an admin can flip encryption tononeon the same host and havePOST /v1/mail/testoffer the stored password over an unencrypted connection. The docstring claims to confine reuse to the server the password belongs to.mail.service.ts:218-229still swallows a$MailConfigparse failure with no log — a config that stops validating makes all mail vanish silently.LoggingServiceis already injected.- An empty new-user template can be persisted and sends empty mail:
$MailTemplate(mail.ts:68-71) accepts{body:{},subject:{}}, andreadState'sbody && subjectis truthy for two empty objects, so the default isn't substituted.checkTemplateIssueonly runs in the browser. describeMailError(mail.utils.ts:53-76, plusmail.service.ts:153,157) returns English sentences rendered verbatim atmail.tsx:463,AssignmentEmailForm.tsx:80andcreate.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 abortsPOST /v1/usersafter 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-13gets this right — the comment there is the fix for both. useTestMailMutationanduseSendAssignmentEmailMutationare missingthrowOnError: false, so despite theironErrorhandlers the rejection still escalates to the router error boundary; the latter also needsmeta.disableDefaultErrorNotificationor it double-toasts.- Four hooks return
response.dataunparsed —useUpdateMailSettingsMutation.ts:19,useTestMailMutation.ts:15,useSendAssignmentEmailMutation.ts:23,useCreateUserMutation.ts:21.$MailSettings,$TestMailResultand$EmailDeliveryResultall exist, and your ownuseMailSettingsQuery.ts:11anduseGroupQuery.ts:12parse 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 —nullat:245,362means both "built-in is deliberately active" and "never chose", sodoCreate'sactiveId ?? idsilently makes a newly created template the participant-facing default.AssignmentEmailForm.tsx:36-40,74sendstemplateId: nullwhile the group query is still pending, which the contract defines as "use the built-in default". Disable submit ongroupQuery.isPending.AssignmentEmailForm.tsx:127-130still promises assignments are sent in the selected language, but?lang=is a no-op — nothing inapps/gateway/srcreads it and libui'sTranslator.inithas no querystring detection. Drop the claim until #1438 lands.remote-assignment.tsxdrops 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.tsever completes a successful save —:16and:27fail validation deliberately,:44fills without saving,:60and:71only check button state — soisMailEnabledis false for the entire run andAssignmentEmailFormrenders 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/mailtoADMIN_ONLY_ROUTESand/group/email-templatestoGROUP_MANAGER_ONLY_ROUTESinauthorization.spec.ts:80-89, and probeGET/PATCH /v1/mail/settingsin the server-side loop at:188— that suite is meant to cover every authenticated route. mail.controller.tshas no spec, and neither controller spec asserts the ability is forwarded:assignments.controller.spec.ts:17andusers.controller.spec.ts:14,54stub{} as AppAbility, and:54'smockImplementation((id: string) => …)drops the options argument, so deleting{ ability }fromusers.controller.ts:110would leave all 11 tests green.- Still untested: the
{ set: emailTemplates }payload atgroups.service.ts:127(only thewhereis asserted), theP2025→ConflictExceptionbranch at:141-149, andsendAssignmentEmail'sDISABLED/FAILEDbranches.
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.
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
left a comment
There was a problem hiding this comment.
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:300ispartial.password ?? saved?.password ?? '', and??doesn't fall through for an empty string.requiresNewPassword:274short-circuits only on a truthy password, so aPATCH /v1/mail/settingscarrying an unchanged server andpassword: ""erases the secret with no error — the exact opposite of what the docstring at:188-190promises. The web form never sends'', so this is API-only, but anymanage:allscript hits it and it surfaces much later asAUTHENTICATION_FAILEDon every send.- An undecryptable password is unrecoverable, and it 500s
POST /v1/usersafter the user is created.readStatedecrypts eagerly at:257, and everything goes through it.getSettings()throws, so the/admin/mailloader dies in the error boundary.updateSettings()at:197decrypts 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. AndsendNewUserEmail()throws afterusersService.createalready succeeded (users.controller.ts:43-47), so the user exists, you're told it failed, and the retry hits "username exists".scripts/generate-env.shmints a randomSECRET_KEY, so any redeploy that loses.envagainst an existing database lands here. Decrypt lazily insidecreateTransporter, and letupdateSettingswrite a new password without reading the old one. - Group email templates get no content check at the perimeter.
$GroupEmailTemplateaccepts{body:{},subject:{}},validateEmailTemplatesonly checks ids, andassignments.controller.ts:66'schosen?.body && chosen.subjectis truthy for two empty objects — so a participant gets an empty subject and a body of just the bare URL, and the call returnsSENT. You already guard this exact trap for the new-user template atmail.service.ts:254-255; the group path needs the same. While you're there:body: nullwith a real subject fails that guard, so the whole built-in default is substituted and the authored subject is thrown away silently. And an explicittemplateIdthat resolves to nothing (:64-68) also falls through to the built-in default — the comment ingroups.service.ts:154-158describes 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-28setsthrowOnError: falsebut nometa: { disableDefaultErrorNotification: true }, so a 409 inGroupEmailTemplatesraises the interceptor's generic toast and your "Save failed" one. Make the flag conditional —group/manage.tsxuses the same hook on the default path and wants the interceptor toast.$CreateUserResponseatuseCreateUserMutation.ts:12-14is the wire shapePOST /v1/usersnow returns. That belongs inpackages/schemas, notapps/web— right now nothing on the API side asserts the response shape at all.GroupEmailTemplates'persistreturnsfalseat:198with no notification, so a direct visit to/group/email-templateswith 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:578only validates authored languages,cleanLocalizedstrips the blank body,pickLocalethen falls back per field);test()returnsAUTHENTICATION_FAILEDwhen the real cause is a changed server;templateOptions.sortatAssignmentEmailForm.tsx:46isn't a total order, so the active option isn't reliably first;checkTemplateIssue'slanguages?: readonly string[]forces four casts including that.map(String)atGroupEmailTemplates.tsx:29—readonly Language[]removes them; andAssignmentEmailForm.tsx:21-24inlines its props onReact.FCrather than the namedtype XPropsthe other three new components use.
Tests
- No test ever completes a save through the admin mail form.
mail-settings.spec.tsclicks 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-testis 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); onlyuseGroupQuerygot one.add-web-data-hook.mdstep 11. authorization.spec.tsis missingPOST /v1/mail/testandPOST /v1/assignments/:id/emailfromPRIVILEGED_REQUESTS. The table's own docstring says it holds every request those screens fire, and/v1/mail/testis the one that opens an outbound SMTP connection to a caller-supplied host with the stored credential.mail-delivery.spec.tsturnsisMailEnabledon instance-wide whilemail-settings.spec.ts:10asserts the server card is hidden.mode: 'serial'only orders within a file andplaywright.config.tsisfullyParallel: true; CI survives onworkers: 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 written — LocalizedString, 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.
…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
left a comment
There was a problem hiding this comment.
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, butresolveConfig:291'spartial.password ?? saved?.passwordkeeps''(??only falls through for null/undefined) — soPATCH /v1/mail/settingswithpassword: ""erases the secret with no error, andisMailEnabledstays true until every send fails withAUTHENTICATION_FAILED. Your own docstrings atmail.service.ts:165-166andmail.ts:42-44promise 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/usersafter the user is created. Failing loudly on read is a defensible call and your docstring at:237-243argues it well — keep that. ButupdateSettingscallsparseStateat:174before writing, so when the password no longer decrypts (e.g.SECRET_KEYrotated on a redeploy —generate-env.shmints a random one) the admin can never re-enter it: the 500 fires beforeresolveConfigruns, and the only fix is hand-editing Mongo. AndsendNewUserEmail(:124) throws afterusersService.createalready 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.
$GroupEmailTemplateaccepts{body:{},subject:{}}(group.ts:28-33),validateEmailTemplateschecks ids only (groups.service.ts:160-177), andassignments.controller.ts:65'schosen?.body && chosen.subjectis truthy for two empty objects — a participant gets an empty subject and a bare-URL body, reportedSENT. You already guard this exact trap for the new-user template inparseState(mail.service.ts:253-254); the group path needs the same. Related:body: nullwith a real subject fails that guard, so the whole built-in default is substituted and the authored subject is silently thrown away. - An explicit
templateIdthat resolves to nothing still silently sends the default wording (assignments.controller.ts:58-67). Your comment documents the fallback, and fornull(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 returnsSENT. It's reachable from the UI: the datahub sheet (datahub/$subjectId/assignments.tsx:54-56) reuses oneAssignmentEmailFormacross selected assignments with nokey, so atemplateChoicepicked for group A's assignment gets posted against group B's. Give the form akey(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
toHaveBeenCalledWithinassignments.controller.spec.tsis onauditLogger.log— delete{ ability }from bothfindByIdcalls atassignments.controller.ts:57,62and 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:27still sets nometa: { disableDefaultErrorNotification: true }, so a 409 inGroupEmailTemplatesraises the interceptor's generic toast plus your conflict one. Make it conditional —group/manage.tsx:885wants the default — the way your mail hooks already do it.$CreateUserResponse(useCreateUserMutation.ts:12-14) is the wire shapePOST /v1/usersreturns (users.controller.ts:56builds it ad hoc). It belongs inpackages/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. GroupEmailTemplatesstill silently no-ops with no group selected:persistreturnsfalse(GroupEmailTemplates.tsx:85-88),CreateTemplateForm.tsx:32-39swallows 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 thelanguagesargument socheckTemplateIssueonly checks body-authored languages — passLANGUAGESas the fourth argument. AndauthoredLanguagestrims (language.ts:15-16, used atemail-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()inmail.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-135are seeded throughapi-client.ts, not the form.mail-send-testis 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-91types 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 newutils/language.ts— whoseomitBlankLanguagescomment claims it "stops anything but a known language reaching the API" — is untested too. authorization.spec.tsgained the two/mail/settingsrows — good — butPOST /v1/mail/testis still missing (it'smanage all, and it's the endpoint that opens an outbound SMTP connection to a caller-supplied host with the stored credential), andPOST /v1/assignments/:id/emailis uncovered anywhere; being group-scoped it wants a cross-group scoping test rather than a blanket-403 row.- The serial
mail.spec.tsis a real improvement, but while it holdsisMailEnabled=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 theNO_RECIPIENTfallback dialog instead of navigating. CI atworkers: 1is 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.
- 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>
Splits #1434 into three PRs. This is PR 2 of 3 — merges after #1440.
Merge order
split/uxmainsplit/mailermainsplit/languagesplit/mailerThis 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/frDeclaring
esinLanguageOptionsmakesesa required key in everyt({ ... })call inapps/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
nodemailer, with admin-configurable SMTP settings stored on the setup state and never returned to clients. The public setup route exposes only a derivedisMailEnabledflag so the client hides all email UI when mail is off.One note on
?langAssignment 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:nodemailerand@types/nodemailer.Verification
tscandeslintclean forschemas,web,api;pnpm test382 passed / 1 skipped. Four redundant type assertions inGroupEmailTemplateswere removed to satisfyno-unnecessary-type-assertion.🤖 Generated with Claude Code