Skip to content

OUT-3929 | Gate IU emails via platform notification settings - #1377

Closed
arpandhakal wants to merge 2 commits into
OUT-3927-send-comment-emails-to-iusfrom
OUT-3929-gate-iu-emails-platform-notification-settings
Closed

OUT-3929 | Gate IU emails via platform notification settings#1377
arpandhakal wants to merge 2 commits into
OUT-3927-send-comment-emails-to-iusfrom
OUT-3929-gate-iu-emails-platform-notification-settings

Conversation

@arpandhakal

Copy link
Copy Markdown
Collaborator

Summary

Implements OUT-3929. The Assembly platform now ships a real IU notification-preference mechanism (per the handoff doc), which is quite different from the anticipated approach: instead of the app deciding IU email delivery via a local flag, the app passes a platform-declared notificationSettingId on each IU send and the platform enforces the IU's per-surface preference. Suppressed surfaces are dropped silently; omitting the id preserves pre-OUT-3929 behavior.

Stacked on #1376 (OUT-3927) — base this PR on OUT-3927-send-comment-emails-to-ius.

What's here

  • Payload: notificationSettingId added to NotificationRequestBodySchema.
  • CopilotAPI.getNotificationSettings(): resolves our install via listAppInstalls() (installs aren't in the token, so match by appId), then fetches GET /v1/installs/{installId}/notification-settings.
  • resolveTasksNotificationSettingId(): single-setting model for now — returns the sole declared setting's id, or undefined when none is declared (→ safe fallback, no suppression). Caches only the stable setting id per workspace with a short TTL; the mutable IU preference is never cached (it's evaluated by the platform on every send).
  • Threading: the id rides IU email sends — create(), createBulkNotification(), the grouped-flush summary, and the reply job. Stored on the buffered email so both the single-event replay and the grouped summary carry it.
  • Kill switch: isIuEmailEnabled() stays as the app-side rollout gate (product decision); when on, IU emails now ride the platform preference. Comment in config updated to reflect its new role.

Scoping decisions (please review)

  1. Email surface only. For assignment/comment/completion, the id is attached to the buffered email, not the immediate in-product dispatch — those in-product notifications are tracked in InternalUserNotification and drive the notification center, so letting the platform silently drop them risks phantom/orphaned rows. Consequence: the Product toggle on the IU settings page does not yet affect Tasks in-product notifications. If we want that too, it's a follow-up (or declare the setting email-only in the dashboard).
  2. Replies gate both surfaces. The reply job sends one combined in-product+email payload (replies aren't DB-tracked), so the setting governs both there — a deliberate, documented asymmetry with Refac: Readme and package.json contents #1.
  3. Single setting (confirmed with PM direction): all IU notifications share one Tasks setting; the resolver is centralized so splitting into per-category settings later is a localized change.

Test plan

  • yarn tsc clean
  • notification + jobs suites green (156 tests): resolver (single-setting pick, empty fallback, per-workspace caching), id stamped on IU emails, CU emails never get it, empty-settings fallback still buffers/sends, sendGroupedEmail passthrough
  • Staging (needs platform flag + declared setting): toggle the Tasks setting off as an IU in a flagged workspace and confirm the email stops with no app-side error; confirm install resolution + setting id line up end to end

🤖 Generated with Claude Code

Implements OUT-3929. Instead of the app deciding IU email delivery, we now pass
the Assembly-declared notificationSettingId on IU email sends and let the
platform enforce each IU's per-surface preference (suppressed surfaces are
dropped silently; omitting the id preserves old behavior).

- add notificationSettingId to the notification payload schema
- CopilotAPI.getNotificationSettings(): resolve our install via listAppInstalls
  (installs aren't in the token), fetch installs/{id}/notification-settings
- resolveTasksNotificationSettingId(): single-setting model for now, caches the
  stable setting id per workspace (never the mutable IU preference)
- thread the id onto IU email sends: create(), createBulkNotification(), the
  grouped flush summary, and the reply job. Stored on the buffered email so both
  the single-event replay and the grouped summary carry it. The immediate
  in-product dispatch is intentionally left ungated to protect the notification
  center + InternalUserNotification tracking.
- keep isIuEmailEnabled() as the app-side rollout kill switch; when on, IU emails
  now ride the platform preference. If no setting is declared, the id is omitted
  and delivery falls back to today's behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

OUT-3929

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tasks-app Ready Ready Preview, Comment Jul 7, 2026 11:34am

Request Review

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown

Greptile Summary

Implements OUT-3929: IU email notifications now carry a notificationSettingId so the Assembly platform can enforce each IU's per-surface preference instead of the app deciding delivery locally. A new resolveTasksNotificationSettingId helper fetches the single declared Tasks setting per workspace (5-minute cache) and threads its id through all four IU email dispatch paths — create(), createBulkNotification(), the grouped-flush summary, and the reply job.

  • Payload & schema: notificationSettingId added as optional to NotificationRequestBodySchema; new NotificationSettingSchema / NotificationSettingsResponseSchema types added to common.ts.
  • Platform integration: CopilotAPI.getNotificationSettings() resolves the app's install via listAppInstalls() (matching by appId), then fetches GET /v1/installs/{installId}/notification-settings; returns an empty list when no matching install is found so callers fall back to no suppression.
  • Scoping: the setting id is attached only to buffered email payloads, not the immediate in-product createNotification dispatch, preserving notification-centre tracking for IU recipients.

Confidence Score: 3/5

The settings-fetch is now on the critical path for IU email buffering — a new external dependency that didn't exist before this PR. Adding a resilience fallback before merge is recommended.

The core concern is that resolveTasksNotificationSettingId is awaited inline before bufferGroupedEmailEvent in notification.service.ts. Any error from getNotificationSettings() that survives retries (persistent 5xx, unexpected 404 from the settings endpoint, Zod parse failure on a new response shape) will cause the IU email to be silently skipped — a regression from pre-PR behaviour where buffering was unconditional. The rest of the implementation is well-structured: the single-setting resolver, per-workspace cache, and threading through all four dispatch paths are all clean and the test coverage is solid.

src/app/api/notification/notification.service.ts — both create() and createBulkNotification() await the settings resolver before buffering, making the email path contingent on a new external API call.

Important Files Changed

Filename Overview
src/app/api/notification/notification.service.ts Adds resolveTasksNotificationSettingId to the email-buffering critical path for IU recipients; if the settings fetch fails after retries, IU emails are silently dropped with no retry opportunity.
src/app/api/notification/resolveNotificationSettingId.ts New module: resolves the single Tasks notification setting ID with a 5-minute per-workspace cache; clean logic but the fetch is now on the critical IU email path.
src/utils/CopilotAPI.ts Adds _getNotificationSettings / getNotificationSettings wrapping install lookup + settings fetch; missing the standard console.info trace log present on every other _* method.
src/jobs/notifications/flush-grouped-email.ts Reads notificationSettingId from the first matching buffered event to pass to sendGroupedEmail; has a redundant ?? undefined after .find(Boolean) but otherwise correct.
src/jobs/notifications/send-reply-create-notifications.ts Threads notificationSettingId through IU reply notifications; also refactors getInitiatorNotificationPromises from positional to named args, improving readability with no semantic change.
src/types/common.ts Adds notificationSettingId to NotificationRequestBodySchema and introduces NotificationSettingSchema / NotificationSettingsResponseSchema with sensible defaults.
src/jobs/notifications/send-grouped-email.ts Adds optional notificationSettingId to SendGroupedEmailArgs and passes it through to createNotification; straightforward and safe.
src/config/index.ts Comment-only update clarifying the new role of iuEmailAlwaysEnabled as a kill switch; no logic change.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant NS as NotificationService
    participant RSN as resolveTasksNotificationSettingId
    participant Cache as Module Cache
    participant CAPI as CopilotAPI
    participant Platform as Assembly Platform

    NS->>RSN: "resolve({copilot, workspaceId})"
    RSN->>Cache: get(workspaceId)
    alt cache hit (within TTL)
        Cache-->>RSN: "{id, expiresAt}"
        RSN-->>NS: settingId (or undefined)
    else cache miss
        RSN->>CAPI: getNotificationSettings()
        CAPI->>Platform: listAppInstalls()
        Platform-->>CAPI: installs[]
        CAPI->>Platform: "GET /v1/installs/{id}/notification-settings"
        Platform-->>CAPI: "{notifications:[{id,label,surfaces}]}"
        CAPI-->>RSN: NotificationSettingsResponse
        RSN->>Cache: "set(workspaceId, {id, expiresAt})"
        RSN-->>NS: settingId (or undefined)
    end
    NS->>NS: bufferGroupedEmailEvent(...notificationSettingId)
    Note over NS: In-product dispatch omits notificationSettingId
    NS->>Platform: sendGroupedEmail / createNotification
    Platform-->>NS: suppress or deliver per IU preference
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant NS as NotificationService
    participant RSN as resolveTasksNotificationSettingId
    participant Cache as Module Cache
    participant CAPI as CopilotAPI
    participant Platform as Assembly Platform

    NS->>RSN: "resolve({copilot, workspaceId})"
    RSN->>Cache: get(workspaceId)
    alt cache hit (within TTL)
        Cache-->>RSN: "{id, expiresAt}"
        RSN-->>NS: settingId (or undefined)
    else cache miss
        RSN->>CAPI: getNotificationSettings()
        CAPI->>Platform: listAppInstalls()
        Platform-->>CAPI: installs[]
        CAPI->>Platform: "GET /v1/installs/{id}/notification-settings"
        Platform-->>CAPI: "{notifications:[{id,label,surfaces}]}"
        CAPI-->>RSN: NotificationSettingsResponse
        RSN->>Cache: "set(workspaceId, {id, expiresAt})"
        RSN-->>NS: settingId (or undefined)
    end
    NS->>NS: bufferGroupedEmailEvent(...notificationSettingId)
    Note over NS: In-product dispatch omits notificationSettingId
    NS->>Platform: sendGroupedEmail / createNotification
    Platform-->>NS: suppress or deliver per IU preference
Loading

Reviews (1): Last reviewed commit: "feat(notifications): gate IU emails via ..." | Re-trigger Greptile

Comment on lines +79 to +81
// IU emails carry the Tasks notification setting so the platform enforces the IU's
// per-surface preference. Stored on the buffered email, so both the grouped summary and
// the single-event replay dispatch it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Settings fetch is now on the critical path for IU email buffering

resolveTasksNotificationSettingId is awaited before bufferGroupedEmailEvent. If getNotificationSettings() fails after all retries — e.g., a transient 5xx that lasts longer than the retry window, an unexpected 404 from the settings endpoint, or a Zod parse error on a new platform response shape — the entire buffering step is skipped and the IU loses the email silently with no retry opportunity. Pre-PR this path was unconditional.

Wrapping with a try/catch and falling back to undefined on failure restores the pre-OUT-3929 behaviour (email delivered, just without platform-side suppression) and keeps the guard non-blocking on API instability.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e8ee73. resolveTasksNotificationSettingId now catches getNotificationSettings failures and returns undefined instead of throwing, so a failure no longer skips buffering + the in-product dispatch. Undefined id = platform delivers all (fail-open); the failure isn't cached, so the next send retries and it self-heals. Note: a separate requirement update means the setting id now also rides the in-product surface — which makes fail-open the only coherent failure mode (fail-closed would mean dropping the whole notification during a settings outage).

senderCompanyId: sender?.senderCompanyId,
recipientInternalUserId: group.recipientIuId,
// All IU rows in a window share the single Tasks setting; read it off any buffered email.
notificationSettingId: liveEvents.map((e) => e.individualEmail?.notificationSettingId).find(Boolean) ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ?? undefined is redundant — .find(Boolean) already returns undefined when no element matches, so the nullish-coalescing branch is never reached.

Suggested change
notificationSettingId: liveEvents.map((e) => e.individualEmail?.notificationSettingId).find(Boolean) ?? undefined,
notificationSettingId: liveEvents.map((e) => e.individualEmail?.notificationSettingId).find(Boolean),

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — dropped the redundant ?? undefined in 1e8ee73.

Comment thread src/utils/CopilotAPI.ts
Comment on lines +354 to +355
async _getNotificationSettings(): Promise<NotificationSettingsResponse> {
const appId = z.string({ message: 'Missing AppID in environment' }).parse(APP_ID)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Every other _* method in this class opens with a console.info log, but _getNotificationSettings skips it. This makes the method invisible in production traces when debugging a mis-delivered or unexpectedly-suppressed IU email.

Suggested change
async _getNotificationSettings(): Promise<NotificationSettingsResponse> {
const appId = z.string({ message: 'Missing AppID in environment' }).parse(APP_ID)
async _getNotificationSettings(): Promise<NotificationSettingsResponse> {
console.info('CopilotAPI#_getNotificationSettings', this.token)
const appId = z.string({ message: 'Missing AppID in environment' }).parse(APP_ID)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added the console.info trace to _getNotificationSettings in 1e8ee73.

Two follow-ups on OUT-3929:

1. Gate the in-product surface as well (per updated requirement). The
   notificationSettingId is now attached to the immediate in-product IU dispatch
   in create()/createBulkNotification, not just the buffered email, so the
   platform gates both surfaces against the IU's preference. We don't gate
   ourselves; we just pass the id.

2. Address Greptile review:
   - P1: resolveTasksNotificationSettingId catches getNotificationSettings
     failures and returns undefined instead of throwing. A failure no longer
     skips buffering + the in-product dispatch (which silently dropped the whole
     notification). Undefined id = platform delivers all (fail-open); the failure
     is not cached so the next send retries. With in-product now gated too,
     fail-open is the coherent choice - failing closed would mean dropping the
     entire notification during a settings-API outage.
   - P2: drop redundant `?? undefined` after `.find(Boolean)` in the flush.
   - P2: add the standard console.info trace to _getNotificationSettings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Deployment failed with the following error:

Deploying Serverless Functions to multiple regions is restricted to the Pro and Enterprise plans.

Learn More: https://vercel.link/multiple-function-regions

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