You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
WPB-25932: Send meeting emails on create/update/link-refresh/delete - #5606
blackheaven
changed the title
[WPB-25932] Send meeting emails on create/update/link-refresh/delete
WPB-25932: Send meeting emails on create/update/link-refresh/delete
Oct 19, 2026
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Active critical and moderate issues remain, including synchronous email-related failures that can alter meeting operation outcomes, incorrect branding configuration indentation, duplicate recipient emails, an unused reply-to setting, and a test compilation error.
The reason will be displayed to describe this comment to others. Learn more.
Already addressed by 6bbe94e: prepareMeetingEmails is fully best-effort returning Maybe — brig lookup failures are isolated in an Async child and collapse to Nothing (warn-logged), so email preparation can never change the outcome of the meeting operation.
The reason will be displayed to describe this comment to others. Learn more.
Addressed by 2ddc4d1 (and previously documented in the 2026-11-06 comment by @blackheaven): unsafeEmailAddress resolves transitively Wire.API.User → Wire.API.User.Identity → Wire.API.User.EmailAddress → Text.Email.Parser; an explicit import is rejected by -Werror -Wunused-imports as redundant (GHC-66111). False positive.
The reason will be displayed to describe this comment to others. Learn more.
Already addressed by 6bbe94e: meReplyTo flows through Galley/App.hs into EmailSubsystem/Interpreter.hs and is set as the Reply-To header (mailHeaders).
Addressed all 8 inline findings (commit to follow shortly; currently staged in gdifolco/WPB-25932-meetings-send-emails):
r4490318702 / r4490318805 (email prep can fail the operation):sendMeetingEmails is split into best-effort prepareMeetingEmails (returns Maybe; link-lookup failure is warn-logged and swallowed, Error MeetingError constraint dropped) and dispatchMeetingEmails. Preparation can no longer change the outcome of create/update/refresh/delete.
r4490318748 (no guard before Brig RPC / code lookup): new MeetingSystemConfig.emailsEnabled (isJust (settings.meetings.email)) short-circuits before the brig RPC, license read, and code-store lookup. Cleanup worker passes False.
r4490318926 (cancel email before deletion): cancellations are rendered in the preflight (join_url still live) but dispatched only after deleteLocalConversation succeeds.
r4490318955 (duplicates): dedup moved into dispatchMeetingEmails (now order-preserving via nubOrd), enforced on every lifecycle path; covered by a new test.
r4490318996 (replyTo unused):settings.meetings.email.replyTo now flows through MeetingEmailEnv.meReplyTo -> meetingEmailSubsystemInterpreter -> renderMeetingEmail as a Reply-To header; brig passes Nothing. TemplateSpec asserts the header absent/present.
r4490318895 (configmap branding):nindent 10 -> nindent 12; brand/legal now nest under branding:.
r4490318845 (spec does not compile): false positive as stated — the name resolves through the legitimate re-export chain Wire.API.User -> module Wire.API.User.Identity -> module Wire.API.User.EmailAddress -> module Text.Email.Parser, not via Wire.CodeStore (an exportless header does not re-export its imports). Restoring the explicit import is rejected by -Werror -Wunused-imports (GHC marks it redundant because the imported Name is identical), so it stays out; the chain is documented in the commit message.
Verified: make c gates (wire-subsystems, galley, background-worker) clean under -Werror + hlint + treefmt; wire-subsystems-tests full suite passes (620 examples, 0 failures) including 3 new tests (duplicate recipients, emails unconfigured, best-effort delete).
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Critical and moderate issues remain, including plaintext SMTP password exposure, unlicensed migration tests, unhandled Brig and SMTP failures, and a removed test import that breaks existing references.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Critical correctness and compilation issues remain: verified creator emails are not used, retries can send duplicate invitations, and the unit-test helper import is missing.
This reads the pending/unverified address instead of the creator's current email. User.userEmailUnvalidated is only populated while an email change is awaiting verification; activated users' usable address is exposed by userEmail from userIdentity (see libs/wire-api/src/Wire/API/User.hs:661-662,740-741). As a result, normal creators with a verified email and no pending change are treated as having no email and all meeting mail is skipped. Use the verified userEmail value and update the mock to populate that field.
Send invitations only to newly added email addresses
addInvitedEmails is state-idempotent (the Postgres store unions and deduplicates invited_emails), but this path sends to the entire request list. Retrying an add request or adding an already-invited address therefore sends a duplicate invitation, unlike the replace path below, which filters to newly added addresses. Compute emails \ storedMeeting.invitedEmails before dispatching.
The reason will be displayed to describe this comment to others. Learn more.
No resend intended — fixed in the earlier rework commit 56dbc42: only newlyAdded = emails \\ storedMeeting.invitedEmails get an invitation (same for replace). Covered by the tests "does not re-invite an address that is already invited (add)" and "sends invitations only for newly added addresses (add/replace/remove)`.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8dd9f16: MeetingEmailsInvitation gained an optional locale (maybe_ (optField "locale" schema), backward compatible). It is threaded through the AddInvitedEmails/ReplaceInvitedEmails effects and sendMeetingEmailsFor, which dispatches with fromMaybe creatorLoc callerLocale; create/update/refresh/delete keep the creator locale. Tests: caller-provided locale is used, and absence falls back to the creator's locale.
The reason will be displayed to describe this comment to others. Learn more.
Using async here does not seem right. there is no concurrency benefit. can't we catch the error with runError or something similar? and then we might also have to try/catch for untyped IO/HTTP errors.
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 8dd9f16: the fork+await is gone. meetingHostProfileBestEffort runs the lookup synchronously and collapses failures to Nothing via the same errorToIOFinal @SomeException (fromExceptionSem @SomeException …) composition as pushBestEffort in Wire.NotificationSubsystem.Interpreter — this also covers transport failures, which "Wire.Rpc" throws as IO exceptions that runError cannot catch (and which the async child let escape through await). prepareMeetingEmails now needs Member (Final IO) r instead of Member Async r; a regression test pins that create still succeeds and sends no RPC when brig fails.
The reason will be displayed to describe this comment to others. Learn more.
I think this is a breaking change. Existing overrides would now be ignored and leave sesQueue unset. Needs to be added to release notes, but I think it would be better to keep the old name.
The reason will be displayed to describe this comment to others. Learn more.
Compat added in 8dd9f16: brig's meeting.replyTo now falls back to the legacy galley.config.settings.meetings.email.replyTo (new key wins), and the galley configmap enables meeting emails when the legacy meetings.email block is present (documented as deprecated in values.yaml, changelog entry changelog.d/1-api-changes/WPB-25932-meeting-email-config.md). Verified with helm template: legacy replyTo renders into brig's meeting block; legacy block + emailsEnabled: false keeps emails on (remove the block to disable); transport/sender/branding keys under the legacy block are ignored.
The reason will be displayed to describe this comment to others. Learn more.
the simple fact that we need this is a design smell, IMO. Not a problem introduced by this PR, this PR only exposes the symptoms. Can we separate the dependencies so that we do not need the whole meetings interpreter? Or if this change is not feasible, can we move this local to where it is called and not expose it, to not encourage anyone else to use this?
The reason will be displayed to describe this comment to others. Learn more.
Removed in 8dd9f16: the worker now calls cleanupOldMeetingsImpl directly on its minimal constraint set (MeetingsStore + ConversationSubsystem + Input (Local ()) + CodeStore), so the interpreter, licensing, brig access and email config drop out of the cleanup path entirely; errorBrigAPIAccess is deleted.
The reason will be displayed to describe this comment to others. Learn more.
The production noop interpreter is already gone: commit 56dbc42 dropped the galley email subsystem entirely (rendering/transport moved to brig), so Wire.EmailSubsystem no longer needs a no-op stand-in in production stacks.
The reason will be displayed to describe this comment to others. Learn more.
Suggested change
JoinMeetingPaywall
JoinMeetingLicenseFailure
This is more neutral, IMO. Paywall describes more the product POV rather than a domain failure. (this is almost a nit-pick, but applies to all occurrences of "Paywall")
The reason will be displayed to describe this comment to others. Learn more.
can we have a test that covers that existing members do not get another email even if they are part of the add request? like this: create a meeting with member A, then add [A,B], check that only B receives the email
The reason will be displayed to describe this comment to others. Learn more.
Already covered: "does not re-invite an address that is already invited (add)" creates with A, then adds [A,B] and asserts only B gets an RPC (plus the replace/remove variant asserting recipients [A,B,C,D] across lifecycle events).
The reason will be displayed to describe this comment to others. Learn more.
I am stil not 100% this catches all IO exceptions.
The reason this is done is to log delivery failures, right? Can we move the logging into the EmailSubsystem interpreter or even into the RPC interpreter to make sure no transport failures bubble up as unhandled IO exceptions.
The reason will be displayed to describe this comment to others. Learn more.
Delivery-failure logging now lives in the EmailSubsystem interpreter (per-recipient warn with kind/recipient/exception), and the galley dispatch child additionally catches SomeException: transport IO exceptions (brig entirely down) are warn-logged with the failure reason instead of dying silently with the discarded child. Nothing from the RPC escapes unhandled anymore. One honest caveat on test coverage: the meeting-operation-success unit test passes both before and after this change (the detached child's result was already discarded pre-fix), so the behavioral delta here is the warn log itself, not the outcome.
Meeting invitation/update/cancel email rendering and transport moved from galley to brig: configure brig's `emailSMS` instead of the legacy `galley.config.settings.meetings.email` block. For backward compatibility the legacy galley block is still honored: its presence enables meeting emails and its `replyTo` feeds brig's `emailSMS.general.meeting.replyTo`; transport/sender/branding keys under it (`from`, `useSES`, `aws.*`, `smtp.*`, `templateDir`, `branding`) are ignored. The new keys are `galley.config.settings.meetings.emailsEnabled` and `brig.config.emailSMS.general.meeting.replyTo`.
The reason will be displayed to describe this comment to others. Learn more.
this should be a release note, maybe.
Also can we remove the galley email configuration entirely and rely solely on brig? AFAICT it has never been adopted and IMO just adds more unnecessary complexity.
The reason will be displayed to describe this comment to others. Learn more.
Maybe I am misunderstanding this. Then you may object of course.
May hunch is that the legacy fallbacks are not necessary because this has not been adopted, yet?
The reason will be displayed to describe this comment to others. Learn more.
Keeping emailsEnabled and the legacy block for now. The flag is a per-operation short-circuit: without it, every meeting lifecycle op on a licensed deployment pays the brig profile RPC, code lookup and internal POST even when the operator doesn't want emails, with no off-switch short of changing the license payload. The legacy block keeps pre-restructure helm values working, and the compat story is already documented in changelog.d/1-api-changes/ (towncrier, compiled into the release notes). Happy to revisit a brig-only configuration before the release train if you'd still prefer it.
The reason will be displayed to describe this comment to others. Learn more.
As above: keeping emailsEnabled + the legacy block for now — the flag is the only operator off-switch for meeting emails on licensed deployments, and the legacy block keeps old helm values working (compat documented in changelog.d/1-api-changes/). Open to revisiting brig-only config before the release train if you still prefer that.
Galley now sends meeting invitation, update and cancellation emails to
invited email addresses via Wire.EmailSubsystem when
settings.meetings.email is configured (scheduled meetings only; creator
locale; delivery failures logged, never surfaced):
- create/addInvitedEmails/replaceInvitedEmails (newly-added only) ->
invitation emails
- updateMeeting -> update email per actually-changed stored field
- refreshMeetingLink -> 'Join link changed' update email
- deleteMeeting -> cancellation emails after the deletion preflight
Infrastructure: reusable meetingEmailSubsystemInterpreter and
errorBrigAPIAccess; MeetingsEmailConfig gains templateDir + branding;
galley MeetingEmailEnv wiring with fire-and-forget best-effort transport;
meetings cleanup worker satisfies the new constraints with error
interpreters. Sends are async best-effort (pushAsyncImpl idiom);
cancellation emails go out only after the fallible deletion preflight;
empty recipient lists skip brig/code-store lookups entirely.
- License gate: sends happen only when the deployment license's
meetings.emails_sending flag is on, checked first in
sendMeetingEmails; unlicensed deployments get no meeting emails
even with settings.meetings.email configured.
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
ok-to-testApproved for running tests in CI, overrides not-ok-to-test if both labels exist
4 participants
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.
https://wearezeta.atlassian.net/browse/WPB-25932
Checklist
changelog.d