Skip to content

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

Open
thomasbeaudry wants to merge 18 commits into
mainfrom
split/mailer
Open

feat(mail): add outgoing email, group email templates, and assignment emails#1439
thomasbeaudry wants to merge 18 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.

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>
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