@@ -91,6 +91,63 @@ async function rearmThreshold(
9191 }
9292}
9393
94+ interface LimitEmailRecipient {
95+ email : string
96+ name ?: string
97+ }
98+
99+ /** Whether a recipient has unsubscribed from all or from notification emails. */
100+ async function isUnsubscribed ( email : string ) : Promise < boolean > {
101+ const prefs = await getEmailPreferences ( email )
102+ return Boolean ( prefs ?. unsubscribeAll || prefs ?. unsubscribeNotifications )
103+ }
104+
105+ /**
106+ * Resolve the recipients that should receive a limit email, with all opt-outs
107+ * already applied (the per-user notifications toggle and unsubscribe prefs).
108+ * Returning an empty list means "nobody to notify" — the caller then skips the
109+ * claim so the dedup state isn't burned without an email going out.
110+ */
111+ async function resolveRecipients (
112+ scope : 'user' | 'organization' ,
113+ params : { userId ?: string ; userEmail ?: string ; userName ?: string ; organizationId ?: string }
114+ ) : Promise < LimitEmailRecipient [ ] > {
115+ if ( scope === 'user' ) {
116+ if ( ! params . userId || ! params . userEmail ) return [ ]
117+ const rows = await db
118+ . select ( { enabled : settings . billingUsageNotificationsEnabled } )
119+ . from ( settings )
120+ . where ( eq ( settings . userId , params . userId ) )
121+ . limit ( 1 )
122+ if ( rows . length > 0 && rows [ 0 ] . enabled === false ) return [ ]
123+ if ( await isUnsubscribed ( params . userEmail ) ) return [ ]
124+ return [ { email : params . userEmail , name : params . userName } ]
125+ }
126+
127+ if ( ! params . organizationId ) return [ ]
128+ const admins = await db
129+ . select ( {
130+ email : user . email ,
131+ name : user . name ,
132+ enabled : settings . billingUsageNotificationsEnabled ,
133+ role : member . role ,
134+ } )
135+ . from ( member )
136+ . innerJoin ( user , eq ( member . userId , user . id ) )
137+ . leftJoin ( settings , eq ( settings . userId , member . userId ) )
138+ . where ( eq ( member . organizationId , params . organizationId ) )
139+
140+ const recipients : LimitEmailRecipient [ ] = [ ]
141+ for ( const a of admins ) {
142+ if ( ! isOrgAdminRole ( a . role ) ) continue
143+ if ( a . enabled === false ) continue
144+ if ( ! a . email ) continue
145+ if ( await isUnsubscribed ( a . email ) ) continue
146+ recipients . push ( { email : a . email , name : a . name || undefined } )
147+ }
148+ return recipients
149+ }
150+
94151/**
95152 * Send a usage-limit threshold email (80% warning / 100% reached) for a
96153 * non-credit category, edge-triggered on the mutation that changed usage.
@@ -158,69 +215,44 @@ export async function maybeSendLimitThresholdEmail(params: {
158215 // Usage-decrease callers re-arm only — a drop is never a fresh crossing to email.
159216 if ( params . rearmOnly || desired === 0 ) return
160217
218+ // Resolve eligible recipients (opt-outs filtered) BEFORE claiming, so the
219+ // dedup state only advances when an email will actually be sent — otherwise
220+ // an opted-out recipient would silently burn the threshold and suppress a
221+ // later email after notifications are re-enabled.
222+ const recipients = await resolveRecipients ( scope , params )
223+ if ( recipients . length === 0 ) return
224+
161225 if ( ! ( await claimThreshold ( scope , stateId , category , desired ) ) ) return
162226
163227 const kind = desired === REACH_THRESHOLD ? 'reached' : 'warning'
164228 const percentUsed = Math . min ( 100 , Math . round ( percent ) )
165229 const upgradeLink = `${ getBaseUrl ( ) } ${ buildUpgradeHref ( params . workspaceId , category ) } `
166230
167- const sendTo = async ( email : string , name ?: string ) => {
168- const prefs = await getEmailPreferences ( email )
169- if ( prefs ?. unsubscribeAll || prefs ?. unsubscribeNotifications ) return
170-
171- const html = await renderLimitThresholdEmail ( {
172- kind,
173- reason : category ,
174- userName : name ,
175- usageLabel : params . usageLabel ,
176- limitLabel : params . limitLabel ,
177- percentUsed,
178- upgradeLink,
179- } )
180-
181- await sendEmail ( {
182- to : email ,
183- subject : getLimitEmailSubject ( category , kind ) ,
184- html,
185- emailType : 'notifications' ,
186- } )
187- }
188-
189- if ( scope === 'user' && params . userId && params . userEmail ) {
190- const rows = await db
191- . select ( { enabled : settings . billingUsageNotificationsEnabled } )
192- . from ( settings )
193- . where ( eq ( settings . userId , params . userId ) )
194- . limit ( 1 )
195- if ( rows . length > 0 && rows [ 0 ] . enabled === false ) return
196- await sendTo ( params . userEmail , params . userName )
197- } else if ( scope === 'organization' && params . organizationId ) {
198- const admins = await db
199- . select ( {
200- email : user . email ,
201- name : user . name ,
202- enabled : settings . billingUsageNotificationsEnabled ,
203- role : member . role ,
231+ for ( const r of recipients ) {
232+ // Isolate per-recipient failures so one bad send doesn't skip the rest.
233+ try {
234+ const html = await renderLimitThresholdEmail ( {
235+ kind,
236+ reason : category ,
237+ userName : r . name ,
238+ usageLabel : params . usageLabel ,
239+ limitLabel : params . limitLabel ,
240+ percentUsed,
241+ upgradeLink,
204242 } )
205- . from ( member )
206- . innerJoin ( user , eq ( member . userId , user . id ) )
207- . leftJoin ( settings , eq ( settings . userId , member . userId ) )
208- . where ( eq ( member . organizationId , params . organizationId ) )
209243
210- for ( const a of admins ) {
211- if ( ! isOrgAdminRole ( a . role ) ) continue
212- if ( a . enabled === false ) continue
213- if ( ! a . email ) continue
214- // Isolate per-admin failures so one bad recipient doesn't skip the rest.
215- try {
216- await sendTo ( a . email , a . name || undefined )
217- } catch ( sendError ) {
218- logger . error ( 'Failed to send limit email to org admin' , {
219- category,
220- email : a . email ,
221- error : sendError ,
222- } )
223- }
244+ await sendEmail ( {
245+ to : r . email ,
246+ subject : getLimitEmailSubject ( category , kind ) ,
247+ html,
248+ emailType : 'notifications' ,
249+ } )
250+ } catch ( sendError ) {
251+ logger . error ( 'Failed to send limit email' , {
252+ category,
253+ email : r . email ,
254+ error : sendError ,
255+ } )
224256 }
225257 }
226258
0 commit comments