Skip to content

feat: an email is on record the moment its job is queued, and a failed or bounced one can be resent - #2425

Merged
ExtraToast merged 2 commits into
integration/managementfrom
feat/2385-email-queued
Oct 13, 2026
Merged

ExtraToast merged 2 commits into
integration/managementfrom
feat/2385-email-queued

Conversation

@ExtraToast

@ExtraToast ExtraToast commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Part of #2385 (step 5, first of seven). Brings back #1969 from the archive tag.

What this is

  • An email is in the log as Queued the moment its job is queued. Until now a row appeared only when the email was sent, so the board could not tell that one would go.
  • A failed or bounced email can be resent: made again for the address the person has now, as a new email that names the one it was made from. Retry still sends the same email again.

The old page (Sent, at /management/emails) shows the Queued state and a Resend button. The new pages come with the next slice.

How it works

  • A job that sends one email extends EmailJob and says what it sends in compose. Nine jobs do: recovery, role change, security notice, contribution reminder, incasso notice, joining contribution, and the three event sign-up emails.
  • The dispatcher publishes JobQueued. QueuedEmails hears it and writes the Queued record in the queuing transaction. The send fills that record in. A job that breaks before it has anything to send marks the record failed.
  • New column emails.resent_from_id and an index on emails.job_execution_id.

What differs from the archive, and why

The archive's first cut is taken as it was (old #2043, applied onto the branch). Resend is reworked, because a review and the commit security check both found it unsafe:

  • The api decides which emails can be resent. The archive checked the status only in the page, so the api would make any email again. Each email now carries resendable, and the page shows the button from that.
  • Only a failed or bounced email, so nobody gets a second copy of one that went. The archive's own integration test resent a Sent email.
  • Only where the email's job says it may be. A recovery email's payload holds the one-time link, so making it again sent the same link to whatever address the account had by then. The archive's test did exactly this: change the account's address, resend the recovery email, get the link. A security notice holds the link that locks an account and is meant for the address the account had, and a sign-up confirmation holds a guest's own link. So a job has to say yes, and five do, those whose payload holds no link or token: role change, contribution reminder, incasso notice, joining contribution and sign-up removed.
  • Not while the email's own job will still try again, so the two cannot both send.
  • Only an email a job sends by itself. A job that sends many would send them all again.
  • One transaction. The archive queued the job first and could then answer "no longer exists" with the job already on its way.
  • A Queued row no longer stays Queued for ever when its job breaks before sending.
  • Old PENDING rows become Failed, not Queued. A row still PENDING is a send that was cut off; nothing will ever send it.
  • EventSignUpAddedEmailJob is newer than the archive and now extends EmailJob too.
  • Two methods of EmailService that nothing called are gone, as in the archive's later state.

Known, not changed

  • A bounced sign-up confirmation cannot be resent, because its payload holds the guest's link. Sending a guest a fresh link is a feature of its own.
  • Resend has no confirmation step. The page it sits on is replaced in the next slice, which is the place for one.
  • A job's retry from the Jobs page marks other jobs with the same payload dead. A resent job has its original's payload, so retrying one there closes the other. Both are finished jobs by then.
  • The email is composed in the queuing transaction, one read per job. A bulk queue of many emails pays that once per email.

How it was checked

  • Api: compiles with warnings as errors, ktlint and detekt pass, 939 unit tests pass in the touched modules. 319 integration tests (email, jobs, event) pass against a throwaway MariaDB, among them: a recovery email is refused after the account's address changed, and a bounced email is made again once and only by the board.
  • Every added line under services/api/src/main and services/frontend/src is hit by a unit test, measured against jacoco and lcov.
  • Frontend: the email tests pass, typecheck (src, tests, e2e) and lint are clean.

…d or bounced one is made again for the person's current address

Queuing an email job writes its Queued record in the queuing transaction, so
the board sees an email that will go and not only one that went. The send then
fills that record in, and a job that breaks before it has anything to send
marks the record failed. A job that sends one email says so through
`EmailJob`, which is what lets the record be written before the job runs.

A failed or bounced email can be made again for the address the person has
now. The new email names the one it was made from. The api decides which
emails that holds for and says so on each one:

- only one that failed or bounced, so nobody gets a second copy of an email
  that went;
- not while its own job will still try again, so the two cannot both send;
- not one that holds a one-time link, such as a recovery email: its payload
  carries the link, which would go out again to whatever address the account
  has by then;
- only one a job sends by itself, since a job that sends many would send them
  all again.

Making an email again is one transaction, so a refusal after the job was
queued takes the job back with it.
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Changes

Bucket Files Added Removed
api 23 ███████ 450 ░░░ 172
frontend 4 █ 45 ░ 8
tests 25 ██████████ 636 ░ 18
build & config 4 ███ 183 ░ 8
total 56 +1314 −206

1.28 test lines per prod line.

Coverage

Statements Branches Functions Lines
api 78.14% 71.41% 68.40% 80.89%
main 08fdcf6 — — — —
frontend 94.48% 89.93% 92.88% 95.23%
main 08fdcf6 92.74% 88.01% 90.20% 93.37%
Δ +1.74 +1.92 +2.68 +1.86

Patch coverage: 100.0%, 204 of 204 changed lines covered.

The rule was a list of what may not be resent, and it named recovery alone.
A security notice carries the link that locks an account and is meant for the
address the account had, and a sign-up confirmation carries a guest's own
link. Neither was on the list.

A job now has to say that its email may be made again, and only the five
whose payload holds no link or token do: role change, contribution reminder,
incasso notice, joining contribution and sign-up removed.
@github-actions

Copy link
Copy Markdown
Contributor

The SQL 2026-10-01-email-queued.yaml will run

Rendered against a database built from the baseline plus everything on the base branch, so this is what the changeset adds and nothing else. Generated, not applied.

--  Changeset db/changelog/changes/2026-10-01-email-queued.yaml::email-queued::blueshell
UPDATE blueshell.emails SET delivery_status = 'FAILED', error_reason = 'The send was cut off before it finished.', error_type = 'Interrupted' WHERE delivery_status = 'PENDING';

ALTER TABLE blueshell.emails ADD resent_from_id BIGINT NULL;

CREATE INDEX idx_emails_job_execution ON blueshell.emails(job_execution_id);

@ExtraToast
ExtraToast merged commit 7499250 into integration/management Oct 13, 2026
39 of 40 checks passed
@ExtraToast
ExtraToast deleted the feat/2385-email-queued branch October 13, 2026 14:06
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.

1 participant