Repository navigation
feat: email bulk remote assignment links - #1838
Merged
Merged
Conversation
A subject assigned several instruments was asked once per assignment, so a battery of three meant three emails carrying one link each. `$SendBulkAssignmentEmailData` extends the single-assignment body with a unique, non-empty, bounded `assignmentIds`, so one request can carry every link belonging to one recipient. The built-in template says "link(s)" and pluralises its expiry line in all three languages, since a message may now list more than one. Co-Authored-By: Claude Opus 5 <[email protected]>
`GET /v1/setup` derives this flag on every request and so always sends one, unlike the stored flags beside it. Reading it as nullable made every consumer write `?? false` to decide whether to offer email at all, which is a decision about a value that cannot be absent. Defaulting it keeps the wire tolerant of a server that omits it while giving clients a boolean. Co-Authored-By: Claude Opus 5 <[email protected]>
New `POST /v1/assignments/bulk/email` reads every named assignment through the
caller's ability, refuses a set spanning more than one group, and renders one
message listing all of their links — `{{url}}` joined by newlines, with
`{{expiresAt}}` the soonest of them. One audit entry is still written per link,
so the log keeps naming every link that went out.
Template resolution and audit logging moved into private helpers shared with
the single-assignment route, which is unchanged.
Co-Authored-By: Claude Opus 5 <[email protected]>
`sendableLanguages` states once what a template may be sent in: the languages it was written in, scoped to the ones the instance still has enabled, falling back to its own when the two do not overlap, because that copy exists nowhere else. The assignment email form read this rule inline, and the bulk send was about to need the same one. `postAssignmentEmail` likewise states the two concessions both send routes make — a timeout outlasting the server's whole SMTP failure budget, so a client cannot abort after the mail has gone out, and no default error toast, because each caller renders the failure where the operator is looking. Co-Authored-By: Claude Opus 5 <[email protected]>
Turning the toggle on only revealed the form, so an administrator who enabled email and navigated away left the instance with mail still off and no sign of it. Enabling now persists. Co-Authored-By: Claude Opus 5 <[email protected]>
The wizard could create a hundred links and then left the operator to distribute them by hand. Addresses now come in with the batch and the results step sends them: - an optional email list panel on the subject step — download a template pre-filled with the selected subjects, fill in addresses, upload it back — which reports what matched, what the group does not have, and which selected subjects it left without an address; - an email column on the uploaded or pasted file, when mail is configured; - an Email All dialog on the results step, choosing the template and the language, sending one message per subject carrying every link assigned to them. A send writes only to recipients who have not received theirs, so a failure leaves a retry that cannot mail anyone a second copy, and a rate limit is waited out rather than counted as a failure — the server sent nothing, which is what makes retrying the identical request safe. Rows the group cannot place are named by row number at the mapping step, with an offer to leave them out: identifying them there rather than at submission keeps a stray row from costing the batch, and in personal-information mode a message naming the person would carry their name out of the module that derives identifiers in the browser. The source tab and the pasted text are held by the wizard like the rest of the batch, so Back returns to the data, and a refusal renders directly above the button that produced it. Co-Authored-By: Claude Opus 5 <[email protected]>
Six paths the unit tier cannot reach, because they span the wizard, the API and a real SMTP failure: an uploaded address arriving in the created batch, the language picker offering exactly the instance's active languages, a failed send staying retryable, a refusal rendering above the button that produced it, Back returning to the tab and text the data came from, and a row the group cannot place being named and then skipped. The bulk email case lives in `mail.spec.ts` with the rest of the mail suite, which runs serially because `isMailEnabled` is instance-wide. Co-Authored-By: Claude Opus 5 <[email protected]>
The trigger read "retry" only when some messages had gone out, so a batch whose every message failed - the common case when the server is misconfigured - was offered as a fresh send instead. It now asks whether this batch has been sent at all, which the end-to-end run caught on a batch of one. The e2e spec drives the timepoints step through its own controls rather than a Continue button that does not exist until an instrument is added. Co-Authored-By: Claude Opus 5 <[email protected]>
`redactEmails` let a local part begin at any position, so text holding no `@` was rescanned from every character in it — quadratic work on a string an SMTP server or, now that the bulk route redacts its request's recipient, an API caller supplies. A local part may only begin where whitespace or an RFC 5322 special ends, which is what CodeQL flagged as reachable (js/polynomial-redos). Co-Authored-By: Claude Opus 5 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Bulk remote assignment links can now be emailed from the wizard, one message per subject carrying every link assigned to them.
emailcolumn on an uploaded or pasted file works too.POST /v1/assignments/bulk/emailper recipient.POST /v1/assignments/bulk/emailreads every named assignment through the caller's ability, refuses a set spanning more than one group, renders{{url}}as the recipient's links joined by newlines with{{expiresAt}}the soonest of them, and writes one audit entry per link.link(s)and pluralises its expiry line in all three languages.Behaviour worth reviewing
Retry Failed (n)that cannot mail anyone a second copy; a batch where every send failed is a retry too, not a fresh send.Retry-After, else 5s, three attempts) rather than counted as a failure: the server sent nothing, which is what makes retrying the identical request safe. Any other error is reported without retrying, because the client cannot know whether the mail left.isMailEnabledonGET /v1/setupis now a defaulted boolean rather than nullable — it is derived per request and never absent, and reading it as nullable made every consumer write?? false.Still deferred
Pooled/bounded delivery, a recipient-weighted quota and durability across a reload remain absent, as
.ai-local/DECISIONS.md(2026-08-06) called for: the client still loops recipients one at a time, now with rate-limit waiting and retry of failures only. Bounce handling does not exist —SENTmeans the relay accepted the handoff; asynchronous DSNs are not ingested.Testing
pnpm lintclean forschemas,api,webandtesting.web,schemasandapi. Coverage of every file this branch touches is 100% (statements, branches, functions, lines).mail.spec.ts22/22 passed, including the newbulk assignment emailcase;remote-assignments-wizard.spec.tsgreen. Six new e2e paths: an uploaded address reaching the created batch, the language picker offering exactly the instance's active languages, a failed send staying retryable, a refusal rendering above the button that produced it, Back returning to the tab and text the data came from, and an unplaceable row being named then skipped.record-file-deletion.spec.ts(already failing onmain) andsubject-detail.spec.ts's@smokegraph test, which this branch does not touch — the latter I have not re-verified against a clean checkout ofmain.🤖 Generated with Claude Code