feat: send email reminders to registered participants (#671) - #771
feat: send email reminders to registered participants (#671)#771saurabhhhcodes wants to merge 3 commits into
Conversation
…ar0036singh#326) When the email provider fails, events were simply not notified with no retry, no alerting and no trace of the lost messages. - utils/emailResilience.ts: EmailCircuitBreaker (opens after 5 consecutive failures, half-open after cooldown), retryWithExponentialBackoff (1s, 2s, 4s, 8s, 16s), enqueueDeadLetter (Firestore email_dead_letter_queue) and alertAdmins (admin_alerts collection + error log) - utils/emailSender.ts: sendEmailWithRetry wraps sendEmail with backoff + breaker; final failures are logged with recipient/eventId/reason context and queued to the DLQ; breaker trips alert admins - sendBulkEmails.ts: per-recipient EmailJS sends now go through the same retry/breaker/DLQ path (timeout is scoped per attempt so backoff cannot be starved by a single AbortController) - retryDeadLetterEmail.ts: new onCall (admin/club only) that re-sends a queued DLQ entry via Resend and tracks retryCount/status - 10 new tests (backoff, breaker states, DLQ writes, open-breaker short-circuit, onCall auth/roles/re-send); tsc clean; 21 tests pass in the affected suites
…6singh#671) The reminder scheduler only fired in-app + push notifications; event registrants never received an email ahead of the event. - reminders.ts: processDueReminders() now also fetches event participants and emails everyone (max 100 emails/run, 100 participants/event) through sendEmailWithRetry (backoff + circuit breaker + DLQ from roshankumar0036singh#326) using the universal email template with event title, time, location and deep link - reminders are marked emailed/emailCount so retries don't resend; deleted events degrade gracefully; malformed emails filtered - scheduler stays thin: schedule().onRun calls processDueReminders - 4 new tests (two participants, already-emailed skip, missing event, malformed filter/count) — mocked firebase-admin pattern
|
Warning Review limit reached
Next review available in: 58 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThe PR adds shared email resilience utilities, integrates retries and dead-letter handling into email delivery, adds email notifications to due reminders, and introduces an authorized callable for retrying queued failures. ChangesEmail delivery resilience
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant ReminderProcessor
participant EmailSender
participant EmailCircuitBreaker
participant EmailProvider
participant Firestore
ReminderProcessor->>EmailSender: send reminder email
EmailSender->>EmailCircuitBreaker: check circuit state
EmailSender->>EmailProvider: retry delivery
EmailProvider-->>EmailSender: delivery result
EmailSender->>EmailCircuitBreaker: record result
EmailSender->>Firestore: record failed delivery
ReminderProcessor->>Firestore: update reminder email metadata
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 18
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cloud-functions/src/reminders.test.ts`:
- Around line 1-182: Format the test file using the repository’s configured
Prettier settings by running Prettier on reminders.test.ts, and apply only the
resulting formatting changes without altering test behavior.
In `@cloud-functions/src/reminders.ts`:
- Around line 66-93: Update the participant selection and delivery flow around
getParticipantContacts and sendEmailWithRetry to require each participant’s
explicit reminder-email consent before sending. Add a one-click unsubscribe URL
to the email template data, and ensure that following it persists the
participant’s opt-out preference for future reminder checks.
- Around line 66-99: Update sendReminderEmails to use a durable delivery record
keyed by eventId and reminder interval, atomically claiming it before any sends
so concurrent invocations cannot duplicate delivery. Store per-recipient
delivery status in that record, mark each successful email independently, and
retry only recipients not already marked successful before batch.commit().
Preserve the existing participant filtering and email budget behavior.
- Around line 69-74: Update the participant recipient-building flow to validate
and deduplicate addresses before applying the recipient limit, so later valid
unique addresses are included. Apply one calculated limit using
Math.min(MAX_PARTICIPANTS_PER_EVENT, emailBudget.remaining) after filtering and
deduplication, and preserve the existing email budget cap.
- Around line 46-47: Update sendReminderEmails and its result-persistence flow
to distinguish pending, delivered, and failed recipient states instead of
treating every processed reminder as sent. When the budget is exhausted or
sendEmailWithRetry returns success: false (including an open circuit breaker),
keep the reminder eligible or persist a durable retry record rather than setting
emailed: true. Finalize emailed only after every recipient succeeds or has a
durable retry record, and ensure attempted counts reflect actual delivery
attempts.
In `@cloud-functions/src/retryDeadLetterEmail.test.ts`:
- Around line 91-94: Extend the assertions in the retryDeadLetterEmail test
around sendEmailWithRetry to verify the queue entry update call includes status
"delivered", retryCount 2, and the expected delivery timestamps. Use the
existing update mock or spy, while preserving the current success-result and
email-send assertions.
In `@cloud-functions/src/retryDeadLetterEmail.ts`:
- Around line 12-94: Run the repository’s Prettier configuration on
cloud-functions/src/retryDeadLetterEmail.ts (lines 12-94) and
cloud-functions/src/retryDeadLetterEmail.test.ts (lines 1-96), preserving
behavior and committing the resulting formatting changes.
- Around line 58-63: Update sendEmailWithRetry and retryDeadLetterEmail so
retries of an existing dead-letter entry preserve the original templateData and
do not create a second queue entry. Extend the sender options to accept the
retry context, persist templateData in newly created dead-letter records, and
update the existing entry with the retry result when invoked from
retryDeadLetterEmail; keep the original entry’s retry flow and message data
intact.
- Around line 17-22: Update the retryDeadLetterEmail authorization flow so club
callers can retry only entries matching their immutable club or event ownership
identifier. Store that ownership identifier with each dead-letter entry,
validate it against the caller before sending, and preserve unrestricted retry
access only for administrators.
- Around line 39-42: The retryDeadLetterEmail flow must claim a queued entry
before sending to prevent concurrent duplicate emails. Use a Firestore
transaction to atomically set status to retrying with a lease or attempt ID,
return the existing delivered result, and proceed with the external send only
when the claim succeeds; update the entry afterward only if that same lease owns
it.
In `@cloud-functions/src/sendBulkEmails.ts`:
- Around line 218-226: Update the dead-letter handling around the EmailJS
`deadLetter` object and `enqueueDeadLetter` call so EmailJS failures are not
queued as manually retryable while `retryDeadLetterEmail` only supports
`resend`; either add EmailJS delivery support to `retryDeadLetterEmail` or route
these failures through an existing recovery path that can process them.
In `@cloud-functions/src/utils/emailResilience.test.ts`:
- Around line 53-89: Add suite-local fake timer setup and teardown around
describe('retryWithExponentialBackoff'), enabling fake timers before its tests
and restoring real timers afterward so runOnlyPendingTimersAsync and
runAllTimersAsync control the retry delays without waiting in real time.
In `@cloud-functions/src/utils/emailResilience.ts`:
- Line 172: Separate the shared emailCircuitBreaker in
cloud-functions/src/utils/emailResilience.ts (lines 172-172) into
provider-specific breaker instances. Update
cloud-functions/src/utils/emailSender.ts (lines 120-148) to use only the Resend
breaker for admission checks and result recording, and update
cloud-functions/src/sendBulkEmails.ts (lines 161-194) to check the EmailJS
breaker before each send and record outcomes only on that breaker.
- Around line 43-55: The circuit breaker currently never transitions out of OPEN
during request admission. Update isOpen() (and its callers as needed) to invoke
tryReset() when the cooldown has elapsed, while ensuring only one request is
admitted during HALF_OPEN; preserve OPEN rejection before cooldown and allow the
single trial request to proceed.
- Around line 123-129: Remove raw recipient addresses from observability data:
in cloud-functions/src/utils/emailResilience.ts lines 123-129, mask or hash
entry.to in the dead-letter log; in cloud-functions/src/utils/emailSender.ts
lines 121-125, mask or hash options.to in the open-breaker log; in
cloud-functions/src/utils/emailSender.ts lines 151-155, remove the raw address
from persisted alert context; and in cloud-functions/src/sendBulkEmails.ts lines
201-206, mask or hash p.email in the failure log. Keep full addresses only in
the restricted delivery record that requires them, reusing an existing masking
or stable-hash utility where available.
- Around line 98-106: Extend DeadLetterEntry with a templateData field matching
the template payload shape, populate it when creating dead-letter records from
SendEmailOptions, and ensure retryDeadLetterEmail continues passing the stored
data to the provider. Add a replay test that verifies event-specific template
values are persisted and supplied during retry.
In `@cloud-functions/src/utils/emailSender.ts`:
- Around line 158-167: Update the dead-letter handling in the email-send flow
around the deadLetter object and enqueueDeadLetter call to distinguish retries
from initial sends. Pass the existing entry ID or an enqueueOnFailure=false
option from retryDeadLetterEmail so failed retries update only the original
dead-letter record, while initial failures continue creating new records.
- Around line 129-141: Make retried email delivery idempotent: in
cloud-functions/src/utils/emailSender.ts lines 129-141, generate one stable
delivery Idempotency-Key per invocation and reuse it for every Resend retry; in
cloud-functions/src/sendBulkEmails.ts lines 161-190, add durable delivery-record
persistence and application-level deduplication before retrying EmailJS sends,
using the existing email-send and bulk-send symbols without changing
successful-delivery behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: be2957e1-d006-439d-8f1a-de2c65ebde7e
📒 Files selected for processing (10)
cloud-functions/src/index.tscloud-functions/src/reminders.test.tscloud-functions/src/reminders.tscloud-functions/src/retryDeadLetterEmail.test.tscloud-functions/src/retryDeadLetterEmail.tscloud-functions/src/sendBulkEmails.tscloud-functions/src/utils/emailResilience.test.tscloud-functions/src/utils/emailResilience.tscloud-functions/src/utils/emailSender.tscloud-functions/src/utils/sendEmailWithRetry.test.ts
| if (data.emailed === true || emailBudget.remaining <= 0) { | ||
| return { sent: 0, attempted: false }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Do not mark deferred or failed emails as completed.
When the email budget is exhausted, sendReminderEmails returns attempted: false, but Line 164 still sets sent: true. The next query excludes that reminder, so its email is permanently dropped. Also, sendEmailWithRetry returns success: false when its circuit breaker is open, but this code ignores that result and records emailed: true with an attempted count.
Persist pending, delivered, and failed email states separately. Only finalize the reminder email state after every recipient has either succeeded or has a durable retry record.
Also applies to: 76-99, 121-124, 162-172
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/reminders.ts` around lines 46 - 47, Update
sendReminderEmails and its result-persistence flow to distinguish pending,
delivered, and failed recipient states instead of treating every processed
reminder as sent. When the budget is exhausted or sendEmailWithRetry returns
success: false (including an open circuit breaker), keep the reminder eligible
or persist a durable retry record rather than setting emailed: true. Finalize
emailed only after every recipient succeeds or has a durable retry record, and
ensure attempted counts reflect actual delivery attempts.
| const participants = await getParticipantContacts(db, eventId); | ||
| const emails = [ | ||
| ...new Set( | ||
| participants | ||
| .slice(0, MAX_PARTICIPANTS_PER_EVENT) | ||
| .map(p => (typeof p.email === 'string' ? p.email.trim() : '')) | ||
| .filter(email => email.length > 0 && email.includes('@')), | ||
| ), | ||
| ].slice(0, emailBudget.remaining); | ||
|
|
||
| for (const email of emails) { | ||
| emailBudget.remaining -= 1; | ||
| await sendEmailWithRetry( | ||
| { | ||
| to: email, | ||
| subject: `⏰ Reminder: ${title} starts ${startAt}`, | ||
| templateName: 'universal_email_template', | ||
| templateData: { | ||
| subject: `${title} starts ${startAt}`, | ||
| to_name: '', | ||
| message: `Don't forget — <strong>${title}</strong> is starting ${startAt}.\n\nWhere: ${location}\n\nYou're registered, so we'll see you there!`, | ||
| event_title: title, | ||
| date: startAt, | ||
| event_link: `${WEBSITE_BASE_URL}/event/${eventId}`, | ||
| cert_display: 'none', | ||
| download_btn_display: 'none', | ||
| browse_btn_display: 'block', | ||
| }, |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
Require email consent before delivery.
getParticipantContacts provides addresses without an opt-in or unsubscribe check. The email template also has no unsubscribe link. Do not call sendEmailWithRetry until the participant has explicit reminder-email consent. Include a one-click unsubscribe link and persist its preference.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/reminders.ts` around lines 66 - 93, Update the
participant selection and delivery flow around getParticipantContacts and
sendEmailWithRetry to require each participant’s explicit reminder-email consent
before sending. Add a one-click unsubscribe URL to the email template data, and
ensure that following it persists the participant’s opt-out preference for
future reminder checks.
| const participants = await getParticipantContacts(db, eventId); | ||
| const emails = [ | ||
| ...new Set( | ||
| participants | ||
| .slice(0, MAX_PARTICIPANTS_PER_EVENT) | ||
| .map(p => (typeof p.email === 'string' ? p.email.trim() : '')) | ||
| .filter(email => email.length > 0 && email.includes('@')), | ||
| ), | ||
| ].slice(0, emailBudget.remaining); | ||
|
|
||
| for (const email of emails) { | ||
| emailBudget.remaining -= 1; | ||
| await sendEmailWithRetry( | ||
| { | ||
| to: email, | ||
| subject: `⏰ Reminder: ${title} starts ${startAt}`, | ||
| templateName: 'universal_email_template', | ||
| templateData: { | ||
| subject: `${title} starts ${startAt}`, | ||
| to_name: '', | ||
| message: `Don't forget — <strong>${title}</strong> is starting ${startAt}.\n\nWhere: ${location}\n\nYou're registered, so we'll see you there!`, | ||
| event_title: title, | ||
| date: startAt, | ||
| event_link: `${WEBSITE_BASE_URL}/event/${eventId}`, | ||
| cert_display: 'none', | ||
| download_btn_display: 'none', | ||
| browse_btn_display: 'block', | ||
| }, | ||
| }, | ||
| { eventId, attempts: 3 }, | ||
| ); | ||
| } | ||
|
|
||
| return { sent: emails.length, attempted: true }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Make event email delivery idempotent.
Each due reminder creates a notification for one userId, but sendReminderEmails sends to every participant of the event. If two due reminders reference the same event, each participant receives the same email once per reminder document. A concurrent scheduled invocation can also repeat sends before batch.commit() records sent.
Create a durable delivery record keyed by event and reminder interval. Atomically claim that record before sending. Track delivery per recipient so retries do not resend successful deliveries.
Also applies to: 159-180, 189-191
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/reminders.ts` around lines 66 - 99, Update
sendReminderEmails to use a durable delivery record keyed by eventId and
reminder interval, atomically claiming it before any sends so concurrent
invocations cannot duplicate delivery. Store per-recipient delivery status in
that record, mark each successful email independently, and retry only recipients
not already marked successful before batch.commit(). Preserve the existing
participant filtering and email budget behavior.
| participants | ||
| .slice(0, MAX_PARTICIPANTS_PER_EVENT) | ||
| .map(p => (typeof p.email === 'string' ? p.email.trim() : '')) | ||
| .filter(email => email.length > 0 && email.includes('@')), | ||
| ), | ||
| ].slice(0, emailBudget.remaining); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Apply the recipient limit after validation and deduplication.
Line 70 limits source documents before malformed and duplicate addresses are removed. If the first 100 records are invalid or duplicate, valid addresses later in the collection receive no reminder.
Proposed fix
const emails = [
...new Set(
participants
- .slice(0, MAX_PARTICIPANTS_PER_EVENT)
.map(p => (typeof p.email === 'string' ? p.email.trim() : ''))
.filter(email => email.length > 0 && email.includes('@')),
),
- ].slice(0, emailBudget.remaining);
+ ].slice(0, MAX_PARTICIPANTS_PER_EVENT, emailBudget.remaining);Use a single calculated limit such as Math.min(MAX_PARTICIPANTS_PER_EVENT, emailBudget.remaining), because slice accepts only two indexes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/reminders.ts` around lines 69 - 74, Update the
participant recipient-building flow to validate and deduplicate addresses before
applying the recipient limit, so later valid unique addresses are included.
Apply one calculated limit using Math.min(MAX_PARTICIPANTS_PER_EVENT,
emailBudget.remaining) after filtering and deduplication, and preserve the
existing email budget cap.
| export interface DeadLetterEntry { | ||
| to: string; | ||
| provider: 'resend' | 'emailjs'; | ||
| subject?: string; | ||
| templateId?: string; | ||
| eventId?: string; | ||
| reason: string; | ||
| attempts: number; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Persist the email template data for dead-letter replay.
DeadLetterEntry does not contain templateData. cloud-functions/src/retryDeadLetterEmail.ts retries with entry.templateData ?? {}. A failed reminder therefore loses its event-specific template values before manual retry. Add the template data to the dead-letter schema and persist it from SendEmailOptions. Add a replay test that verifies the stored payload.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/utils/emailResilience.ts` around lines 98 - 106, Extend
DeadLetterEntry with a templateData field matching the template payload shape,
populate it when creating dead-letter records from SendEmailOptions, and ensure
retryDeadLetterEmail continues passing the stored data to the provider. Add a
replay test that verifies event-specific template values are persisted and
supplied during retry.
| logger.error({ | ||
| message: 'email queued to dead-letter queue', | ||
| to: entry.to, | ||
| provider: entry.provider, | ||
| eventId: entry.eventId ?? null, | ||
| reason: entry.reason, | ||
| }); |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
Remove raw recipient addresses from observability data. Email addresses are personal data. These logs and admin-alert records do not require the full address for diagnosis. Store the recipient only in the restricted delivery record that requires it, and log a masked address or a stable hash instead.
cloud-functions/src/utils/emailResilience.ts#L123-L129: mask or hashentry.toin the dead-letter log.cloud-functions/src/utils/emailSender.ts#L121-L125: mask or hashoptions.toin the open-breaker log.cloud-functions/src/utils/emailSender.ts#L151-L155: remove the raw address from persisted alert context.cloud-functions/src/sendBulkEmails.ts#L201-L206: mask or hashp.emailin the failure log.
📍 Affects 3 files
cloud-functions/src/utils/emailResilience.ts#L123-L129(this comment)cloud-functions/src/utils/emailSender.ts#L121-L125cloud-functions/src/utils/emailSender.ts#L151-L155cloud-functions/src/sendBulkEmails.ts#L201-L206
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/utils/emailResilience.ts` around lines 123 - 129, Remove
raw recipient addresses from observability data: in
cloud-functions/src/utils/emailResilience.ts lines 123-129, mask or hash
entry.to in the dead-letter log; in cloud-functions/src/utils/emailSender.ts
lines 121-125, mask or hash options.to in the open-breaker log; in
cloud-functions/src/utils/emailSender.ts lines 151-155, remove the raw address
from persisted alert context; and in cloud-functions/src/sendBulkEmails.ts lines
201-206, mask or hash p.email in the failure log. Keep full addresses only in
the restricted delivery record that requires them, reusing an existing masking
or stable-hash utility where available.
Source: Linters/SAST tools
| } | ||
| } | ||
|
|
||
| export const emailCircuitBreaker = new EmailCircuitBreaker(); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Use isolated circuit breakers and enforce admission for each provider. Resend and EmailJS share one mutable breaker. An EmailJS success can close a breaker opened by Resend failures. The EmailJS path also sends requests while that shared breaker is open.
cloud-functions/src/utils/emailResilience.ts#L172-L172: expose separate breaker instances keyed by provider.cloud-functions/src/utils/emailSender.ts#L120-L148: use only the Resend breaker for Resend admission and result recording.cloud-functions/src/sendBulkEmails.ts#L161-L194: check the EmailJS breaker before each send and record results only on the EmailJS breaker.
📍 Affects 3 files
cloud-functions/src/utils/emailResilience.ts#L172-L172(this comment)cloud-functions/src/utils/emailSender.ts#L120-L148cloud-functions/src/sendBulkEmails.ts#L161-L194
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/utils/emailResilience.ts` at line 172, Separate the
shared emailCircuitBreaker in cloud-functions/src/utils/emailResilience.ts
(lines 172-172) into provider-specific breaker instances. Update
cloud-functions/src/utils/emailSender.ts (lines 120-148) to use only the Resend
breaker for admission checks and result recording, and update
cloud-functions/src/sendBulkEmails.ts (lines 161-194) to check the EmailJS
breaker before each send and record outcomes only on that breaker.
| const finalResult = await retryWithExponentialBackoff( | ||
| async () => { | ||
| const result = await sendEmail(options); | ||
| if (!result.success) { | ||
| throw new Error(result.error ?? 'Unknown email send failure'); | ||
| } | ||
| return result; | ||
| }, | ||
| { label: `email to ${options.to}` }, | ||
| ).catch((error: unknown) => { | ||
| const reason = error instanceof Error ? error.message : String(error); | ||
| return { success: false, error: reason }; | ||
| }); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🌐 Web query:
According to the official Resend and EmailJS REST API documentation for the versions used by this repository, what idempotency or duplicate-prevention mechanism should be used when retrying an email request after a timeout or transport failure?
💡 Result:
For the Resend API, the official mechanism for duplicate prevention during retries is the use of Idempotency Keys [1]. When retrying an email request after a timeout or other transport failure, you should include a unique Idempotency-Key in the HTTP header of your request [1][2]. This key must be unique per API request (e.g., a UUID) and can be up to 256 characters in length [1][3]. Resend supports this on POST /emails and POST /emails/batch endpoints [1][2]. If you are using SMTP, you can alternatively use the Resend-Idempotency-Key email header [1]. In contrast, the official documentation for the EmailJS REST API does not specify a native idempotency key mechanism for duplicate prevention during retries [4][5][6]. Retrying requests in EmailJS should be managed by the application layer, ensuring the logic does not inadvertently trigger the same request multiple times due to unhandled timeout states [7][8]. Summary of mechanisms: - Resend: Use the Idempotency-Key HTTP header (or Resend-Idempotency-Key for SMTP) [1]. - EmailJS: No native REST API idempotency mechanism is defined in the documentation; application-side deduplication logic is required [4][6].
Citations:
- 1: https://resend.com/docs/dashboard/emails/idempotency-keys
- 2: https://resend.mintlify.dev/docs/dashboard/emails/idempotency-keys
- 3: https://resend.com/docs/api-reference/emails/send-batch-emails
- 4: https://www.emailjs.com/docs/rest-api/send/
- 5: https://www.emailjs.com/docs/rest-api/history/
- 6: https://www.emailjs.com/docs/sdk/send/
- 7: Duplicate emails sent and timeout eleith/emailjs#32
- 8: https://github.com/emailjs-com/emailjs-nodejs/blob/main/README.md
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- candidate files ---'
git ls-files 'cloud-functions/src/utils/emailSender.ts' 'cloud-functions/src/sendBulkEmails.ts' 'cloud-functions/src/utils/emailResilience.ts'
printf '%s\n' '--- retry and send call sites ---'
rg -n -C 5 'retryWithExponentialBackoff|sendEmail|emailjs|resend|Idempotency|idempot' cloud-functions/src/utils/emailSender.ts cloud-functions/src/sendBulkEmails.ts cloud-functions/src/utils/emailResilience.ts
printf '%s\n' '--- outlines ---'
ast-grep outline cloud-functions/src/utils/emailSender.ts
ast-grep outline cloud-functions/src/sendBulkEmails.ts
ast-grep outline cloud-functions/src/utils/emailResilience.tsRepository: roshankumar0036singh/Uni-Event
Length of output: 13375
🏁 Script executed:
#!/bin/bash
set -e
printf '%s\n' '--- emailSender implementation ---'
sed -n '45,170p' cloud-functions/src/utils/emailSender.ts
printf '%s\n' '--- retry implementation ---'
sed -n '1,115p' cloud-functions/src/utils/emailResilience.ts
printf '%s\n' '--- bulk send and failure handling ---'
sed -n '120,235p' cloud-functions/src/sendBulkEmails.ts
printf '%s\n' '--- dependency declarations ---'
rg -n -C 3 '"resend"|"firebase-functions"|"node-fetch"|"emailjs"' package.json cloud-functions/package.json package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null || trueRepository: roshankumar0036singh/Uni-Event
Length of output: 17353
🏁 Script executed:
#!/bin/bash
set -e
python3 - <<'PY'
from pathlib import Path
sender = Path("cloud-functions/src/utils/emailSender.ts").read_text()
bulk = Path("cloud-functions/src/sendBulkEmails.ts").read_text()
resilience = Path("cloud-functions/src/utils/emailResilience.ts").read_text()
checks = {
"Resend request has no idempotency option/header": (
"idempotencyKey" not in sender and "Idempotency-Key" not in sender
),
"Resend retry throws on unsuccessful result": (
"const result = await sendEmail(options);" in sender
and "if (!result.success)" in sender
and "throw new Error(result.error" in sender
),
"Resend retry count is six by default": (
"const maxAttempts = options.maxAttempts ?? delaysMs.length + 1;" in resilience
and "DEFAULT_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000]" in resilience
),
"EmailJS request has no idempotency mechanism": (
"'Content-Type': 'application/json'" in bulk
and "Idempotency-Key" not in bulk
and "idempotency" not in bulk.lower()
),
"EmailJS request is retried on thrown fetch/status errors": (
"retryWithExponentialBackoff(" in bulk
and "const res = await fetch(" in bulk
and "if (!res.ok)" in bulk
and "throw new Error(" in bulk
),
"EmailJS retries use the same payload": (
"body: JSON.stringify(payload)" in bulk
and bulk.count("const payload = {") == 1
),
}
for name, passed in checks.items():
print(f"{'PASS' if passed else 'FAIL'}: {name}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: roshankumar0036singh/Uni-Event
Length of output: 471
Make retried delivery idempotent. A timeout or transport error can occur after the provider accepts an email. Retrying the same request can deliver duplicate reminders or bulk messages. For Resend, pass the same stable Idempotency-Key for every retry of one delivery. EmailJS does not document a REST idempotency key, so persist a durable delivery record and gate retries with application-level deduplication.
cloud-functions/src/utils/emailSender.ts#L129-L141cloud-functions/src/sendBulkEmails.ts#L161-L190
📍 Affects 2 files
cloud-functions/src/utils/emailSender.ts#L129-L141(this comment)cloud-functions/src/sendBulkEmails.ts#L161-L190
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/utils/emailSender.ts` around lines 129 - 141, Make
retried email delivery idempotent: in cloud-functions/src/utils/emailSender.ts
lines 129-141, generate one stable delivery Idempotency-Key per invocation and
reuse it for every Resend retry; in cloud-functions/src/sendBulkEmails.ts lines
161-190, add durable delivery-record persistence and application-level
deduplication before retrying EmailJS sends, using the existing email-send and
bulk-send symbols without changing successful-delivery behavior.
| const deadLetter: DeadLetterEntry = { | ||
| to: options.to, | ||
| provider: 'resend', | ||
| subject: options.subject, | ||
| templateId: options.templateName, | ||
| eventId: retryContext.eventId, | ||
| reason: finalResult.error ?? 'Unknown email send failure', | ||
| attempts: retryContext.attempts ?? 1, | ||
| }; | ||
| await enqueueDeadLetter(admin.firestore(), deadLetter); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not enqueue a second record during a dead-letter retry.
cloud-functions/src/retryDeadLetterEmail.ts retries an existing queue entry through this function. If that retry fails, this code creates a new dead-letter entry. The callable then also updates the original entry back to queued. Repeated manual retries create duplicate records. Pass the existing entry ID or an enqueueOnFailure flag so a retry updates only its original record.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cloud-functions/src/utils/emailSender.ts` around lines 158 - 167, Update the
dead-letter handling in the email-send flow around the deadLetter object and
enqueueDeadLetter call to distinguish retries from initial sends. Pass the
existing entry ID or an enqueueOnFailure=false option from retryDeadLetterEmail
so failed retries update only the original dead-letter record, while initial
failures continue creating new records.
f1f4b15 to
074d934
Compare
|



Closes #671
Problem
The reminder scheduler (cloud-functions/src/reminders.ts) fired in-app + push notifications only — registrants never got an email ahead of the event.
Changes
processDueReminders(db)which, alongside the existing in-app/push flow, fetches the event participants and emails each registrant throughsendEmailWithRetry(exponential backoff + circuit breaker + dead-letter queue from Add retry logic and circuit breaker for email service #326), rendering event title/time/location and a deep link via the existinguniversal_email_template.emailed/emailCountflags prevent resending, deleted events degrade gracefully.Summary by CodeRabbit
New Features
Bug Fixes