Skip to content

feat: send email reminders to registered participants (#671) - #771

Open
saurabhhhcodes wants to merge 3 commits into
roshankumar0036singh:mainfrom
saurabhhhcodes:fix/671-email-reminders
Open

feat: send email reminders to registered participants (#671)#771
saurabhhhcodes wants to merge 3 commits into
roshankumar0036singh:mainfrom
saurabhhhcodes:fix/671-email-reminders

Conversation

@saurabhhhcodes

@saurabhhhcodes saurabhhhcodes commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

  • reminders.ts: scheduled handler delegates to a new testable processDueReminders(db) which, alongside the existing in-app/push flow, fetches the event participants and emails each registrant through sendEmailWithRetry (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 existing universal_email_template.
  • Safety rails: max 100 emails per scheduler run, max 100 participants per event, malformed/duplicate emails filtered, emailed/emailCount flags prevent resending, deleted events degrade gracefully.
  • reminders.test.ts: 4 tests (both participants emailed + flags set; already-emailed skip; missing event; malformed-email filtering and count) following the repo's mocked firebase-admin pattern — 19 tests green across the email suites, tsc clean.

Note: stacks on #765 (email retry/circuit-breaker infra, also in flight) — rebases cleanly once that merges.

Summary by CodeRabbit

  • New Features

    • Due reminders can now notify participants by email alongside existing in-app and push notifications.
    • Email reminders avoid duplicate deliveries, filter invalid addresses, and track delivery attempts.
    • Authorized administrators and clubs can retry failed email deliveries from the dead-letter queue.
  • Bug Fixes

    • Improved handling of temporary email delivery failures with automatic retries.
    • Failed deliveries are safely queued for later retry, while administrators receive alerts when email service issues persist.

Saurabh Kumar Bajpai added 2 commits August 12, 2026 11:31
…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
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@saurabhhhcodes, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f779fc4d-87fe-4eae-9e18-a8df7f6a3025

📥 Commits

Reviewing files that changed from the base of the PR and between 6ec3778 and 074d934.

📒 Files selected for processing (5)
  • cloud-functions/src/reminders.test.ts
  • cloud-functions/src/reminders.ts
  • cloud-functions/src/retryDeadLetterEmail.test.ts
  • cloud-functions/src/retryDeadLetterEmail.ts
  • cloud-functions/src/sendBulkEmails.ts
📝 Walkthrough

Walkthrough

The 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.

Changes

Email delivery resilience

Layer / File(s) Summary
Resilience primitives and persistence
cloud-functions/src/utils/emailResilience.ts, cloud-functions/src/utils/emailResilience.test.ts
Adds exponential-backoff retries, a three-state circuit breaker, Firestore dead-letter entries, administrator alerts, and tests for these behaviors.
Resilient email delivery
cloud-functions/src/utils/emailSender.ts, cloud-functions/src/sendBulkEmails.ts, cloud-functions/src/utils/sendEmailWithRetry.test.ts
Adds retry-aware email sending. Failed deliveries update circuit-breaker state, alert administrators, and enter the dead-letter queue.
Reminder email processing
cloud-functions/src/reminders.ts, cloud-functions/src/reminders.test.ts
Processes due reminders through a reusable function, sends deduplicated emails to valid participants within configured limits, and records email metadata.
Dead-letter retry callable
cloud-functions/src/retryDeadLetterEmail.ts, cloud-functions/src/index.ts, cloud-functions/src/retryDeadLetterEmail.test.ts
Exports an authorized callable that validates, retries, updates, and requeues dead-letter email entries. Tests cover authorization and successful retry behavior.

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
Loading

Possibly related PRs

Suggested labels: level:advanced, type:testing, type:refactor

Suggested reviewers: roshankumar0036singh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements email reminders, but issue #671 also requires configurable intervals, organizer controls, template customization, unsubscribe support, and GDPR opt-in. Add configurable intervals, organizer controls, template customization, one-click unsubscribe, and GDPR opt-in handling.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: sending email reminders to registered participants.
Out of Scope Changes check ✅ Passed The email resilience, retry, circuit-breaker, and dead-letter changes directly support reliable reminder email delivery.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 71cc0da and 6ec3778.

📒 Files selected for processing (10)
  • cloud-functions/src/index.ts
  • cloud-functions/src/reminders.test.ts
  • cloud-functions/src/reminders.ts
  • cloud-functions/src/retryDeadLetterEmail.test.ts
  • cloud-functions/src/retryDeadLetterEmail.ts
  • cloud-functions/src/sendBulkEmails.ts
  • cloud-functions/src/utils/emailResilience.test.ts
  • cloud-functions/src/utils/emailResilience.ts
  • cloud-functions/src/utils/emailSender.ts
  • cloud-functions/src/utils/sendEmailWithRetry.test.ts

Comment thread cloud-functions/src/reminders.test.ts Outdated
Comment on lines +46 to +47
if (data.emailed === true || emailBudget.remaining <= 0) {
return { sent: 0, attempted: false };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +66 to +93
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',
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 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.

Comment on lines +66 to +99
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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +69 to +74
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 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.

Comment on lines +98 to +106
export interface DeadLetterEntry {
to: string;
provider: 'resend' | 'emailjs';
subject?: string;
templateId?: string;
eventId?: string;
reason: string;
attempts: number;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ 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.

Comment on lines +123 to +129
logger.error({
message: 'email queued to dead-letter queue',
to: entry.to,
provider: entry.provider,
eventId: entry.eventId ?? null,
reason: entry.reason,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 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 hash entry.to in the dead-letter log.
  • cloud-functions/src/utils/emailSender.ts#L121-L125: mask or hash options.to in 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 hash p.email in the failure log.
📍 Affects 3 files
  • cloud-functions/src/utils/emailResilience.ts#L123-L129 (this comment)
  • cloud-functions/src/utils/emailSender.ts#L121-L125
  • cloud-functions/src/utils/emailSender.ts#L151-L155
  • cloud-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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 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-L148
  • cloud-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.

Comment on lines +129 to +141
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 };
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ 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:


🏁 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.ts

Repository: 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 || true

Repository: 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)
PY

Repository: 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-L141
  • cloud-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.

Comment on lines +158 to +167
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ 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.

@saurabhhhcodes
saurabhhhcodes force-pushed the fix/671-email-reminders branch 2 times, most recently from f1f4b15 to 074d934 Compare August 13, 2026 13:42
@sonarqubecloud

Copy link
Copy Markdown

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.

Implement email reminders for upcoming events

1 participant