Skip to content

feat: add SMTP support as alternative to Resend#2259

Open
aryansingh1718 wants to merge 14 commits into
openstatusHQ:mainfrom
aryansingh1718:feat/smtp-support
Open

feat: add SMTP support as alternative to Resend#2259
aryansingh1718 wants to merge 14 commits into
openstatusHQ:mainfrom
aryansingh1718:feat/smtp-support

Conversation

@aryansingh1718

Copy link
Copy Markdown

Summary

This PR adds SMTP as an alternative email transport to Resend, allowing self-hosters to use any SMTP provider instead of being locked into Resend.

Closes #2213

Changes

  • packages/emails/src/env.ts — added optional SMTP environment variables (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM)
  • packages/emails/src/client.ts — refactored EmailClient to support both Resend and SMTP via nodemailer
  • packages/emails/src/send.ts — updated generic email functions to support Resend/SMTP. Removed legacy sendEmailHtml function
  • packages/emails/src/index.ts — removed sendEmailHtml export
  • .env.docker.example — documented SMTP env vars alongside Resend
  • DOCKER.md, COOLIFY_ENVIRONMENT_GUIDE.md, COOLIFY_SETUP.md — updated email configuration docs

How it works

  • If SMTP_HOST is set → uses nodemailer with SMTP
  • If RESEND_API_KEY is set → uses Resend
  • If neither is set → throws an error at startup

Configuration

Option 1: Resend

RESEND_API_KEY=your-resend-api-key

Option 2: SMTP

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
SMTP_FROM=notifications@example.com

@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

@aryansingh1718 is attempting to deploy a commit to the OpenStatus Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai 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.

6 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/emails/src/send.ts">

<violation number="1" location="packages/emails/src/send.ts:10">
P2: SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like \"abc\" produce NaN and malformed values like \"587foo\" silently parse as 587, masking configuration errors.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/emails/src/env.ts Outdated
Comment thread packages/emails/src/send.ts Outdated
Comment thread packages/emails/src/client.tsx Outdated
Comment thread packages/emails/src/env.ts Outdated
Comment thread packages/emails/package.json Outdated
Comment thread packages/emails/src/send.ts Outdated
const resendClient = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
const smtpTransporter = env.SMTP_HOST ? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT ? Number.parseInt(env.SMTP_PORT) : 587,

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.

P2: SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like "abc" produce NaN and malformed values like "587foo" silently parse as 587, masking configuration errors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/emails/src/send.ts, line 10:

<comment>SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like \"abc\" produce NaN and malformed values like \"587foo\" silently parse as 587, masking configuration errors.</comment>

<file context>
@@ -1,10 +1,18 @@
+const resendClient = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
+const smtpTransporter = env.SMTP_HOST ? nodemailer.createTransport({
+  host: env.SMTP_HOST,
+  port: env.SMTP_PORT ? Number.parseInt(env.SMTP_PORT) : 587,
+  auth: env.SMTP_USER && env.SMTP_PASS ? {
+    user: env.SMTP_USER,
</file context>

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/emails/src/send.ts">

<violation number="1" location="packages/emails/src/send.ts:10">
P2: SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like \"abc\" produce NaN and malformed values like \"587foo\" silently parse as 587, masking configuration errors.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/emails/src/send.ts Outdated
@mxkaske

mxkaske commented Jun 11, 2026

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Jun 11, 2026

Copy link
Copy Markdown

Claude finished @mxkaske's task in 3m 16s —— View job


Code Review: SMTP Support for OpenStatus Emails

  • Gathering context
  • Reading changed files
  • Analyzing code quality and correctness
  • Posting review findings

Overall the approach is sound — reading provider config from env at construction time and routing through private sendSingle/sendBatch helpers is clean. There are a few issues worth addressing before merging, one of which is a build-breaking regression.


🔴 Critical: Breaking constructor change leaves 3 callers unupdated

EmailClient's constructor was changed from constructor(opts: { apiKey: string }) to constructor(), but three call sites were not updated:

File Line Current (broken) call
apps/server/src/routes/v1/statusReports/update/post.ts top-level new EmailClient({ apiKey: env.RESEND_API_KEY })
apps/status-page/src/lib/auth/providers.ts 14 new EmailClient({ apiKey: process.env.RESEND_API_KEY ?? "" })
apps/workflows/src/cron/emails.ts 12 new EmailClient({ apiKey: env().RESEND_API_KEY })

TypeScript will reject new EmailClient({ apiKey: "..." }) when the constructor signature is constructor() — passing an argument to a zero-argument constructor is a type error. This will break the build for apps/server, apps/status-page, and apps/workflows. All three callers need to be updated to new EmailClient().

Fix this →


🟡 Important: Optional chaining silently no-ops on constructor-guaranteed fields

In client.tsx, sendSingle and sendBatch use optional chaining on this.smtpTransporter and this.resendClient even though the constructor guarantees exactly one of them is set based on this.type. When this.type === "smtp", smtpTransporter cannot be undefined; when this.type === "resend", resendClient cannot be undefined.

// client.tsx:68 — smtpTransporter is guaranteed set when type === "smtp"
await this.smtpTransporter?.sendMail(...)  // ← silent no-op if undefined

// client.tsx:77 — resendClient is guaranteed set when type === "resend"  
await this.resendClient?.emails.send(...)  // ← silent no-op if undefined

Same applies to sendBatch at lines 96 and 109. These should use ! assertions (or as nodemailer.Transporter) with a comment explaining the invariant, rather than silently swallowing a configuration bug.


🟡 Important: SMTP_FROM overrides per-page sender branding

Both sendSingle and sendBatch in client.tsx use env.SMTP_FROM || opts.from. For status report updates and maintenance notifications, the from field is set to ${req.pageTitle} <notifications@notifications.openstatus.dev> — this provides per-status-page branding to subscribers. With SMTP, this is always replaced by the global SMTP_FROM.

This means self-hosters using SMTP will see all notification emails coming from a single address rather than their individual page names. This is likely an acceptable trade-off for self-hosters, but it should be documented in the PR/env file, and the behavior difference between Resend and SMTP modes should be explicit.


🟡 Important: Rate limit detection is Resend-specific

sendFollowUpBatched and sendSlackFeedbackBatched in client.tsx check err.name === "rate_limit_exceeded" to decide whether to re-throw. This is a Resend-specific error name. nodemailer rate-limiting errors use different naming conventions, so in SMTP mode this check always falls through to the swallowed-error path even when it shouldn't.


🟠 Minor: Duplicate SMTP/Resend initialization

Both send.ts and client.tsx independently initialize nodemailer and Resend clients at module load time. When an app uses both sendEmail (from send.ts) and EmailClient, two separate transporters are created with potentially different configs (e.g., send.ts uses Number.parseInt(env.SMTP_PORT, 10) || 587 while client.tsx uses the same pattern). This is maintainable for now but worth noting — any future config changes need to be applied in both places.


🟢 What's done well

  • Env validation in env.ts is solid: SMTP_PORT: z.string().regex(/^\d+$/).refine(n => +n >= 1 && +n <= 65535) correctly rejects non-numeric values, empty strings, and out-of-range ports. This resolves the cubic reviewer's concern about port parsing.
  • Fail-fast at startup in both send.ts:17-19 (module-level throw) and client.tsx:54-56 (constructor throw) — configuration errors surface immediately.
  • Catalog versioning (nodemailer: "catalog:", @types/nodemailer: "catalog:") was correctly addressed from the cubic review.
  • sendEmail callers in apps/web, apps/server (pageSubscribers), and apps/dashboard all pass to: [email] (array form) matching the to: string[] type — no breakage there.

@mxkaske

mxkaske commented Jun 11, 2026

Copy link
Copy Markdown
Member

thanks @aryansingh1718! bare with us for the review

@aryansingh1718

aryansingh1718 commented Jun 11, 2026

Copy link
Copy Markdown
Author

Hey @mxkaske , I've addressed the review comments — updated the 3 callers to new EmailClient(), replaced ?. with ! assertions with comments, and added the this.type === "resend" guard for the rate limit check. Please take a look!

@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
openstatus-dashboard Error Error Jun 19, 2026 10:21am
openstatus-status-page Error Error Jun 19, 2026 10:21am

Request Review

@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, fixed the CI test failures — the EmailClient constructor was throwing when neither RESEND_API_KEY nor SMTP_HOST was set, which crashed module imports in the test environment. Added a non-production early return in the constructor and updated all send method guards from === "development" to !== "production". Please take a look!

@mxkaske

mxkaske commented Jun 12, 2026

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Jun 12, 2026

Copy link
Copy Markdown

Claude finished @mxkaske's task in 3m 18s —— View job


Code Review: SMTP Support for OpenStatus Emails

  • Gathering context
  • Reading changed files
  • Analyzing code quality and correctness
  • Posting review findings

Good progress on addressing the previous round of feedback — env validation, catalog versioning, ! assertions, and rate-limit guarding are all done well. However, two more call sites with the old constructor signature were missed, and there are a few other issues worth addressing.


🔴 Critical: Two more EmailClient callers still use the old constructor

The previous review caught 3 callers; two more were missed and are not in the changed files:

File Lines Broken call
packages/notifications/email/src/index.ts 20, 49, 77 new EmailClient({ apiKey: env.RESEND_API_KEY })
packages/subscriptions/src/channels/email.ts 16 new EmailClient({ apiKey: process.env.RESEND_API_KEY })

Both will fail TypeScript compilation with the current zero-arg constructor signature.

Fix this →


🔴 Critical: packages/subscriptions/src/channels/email.ts doesn't support SMTP

getEmailClient() in packages/subscriptions/src/channels/email.ts:10-18 hardcodes a check for RESEND_API_KEY and throws if it's absent, even when SMTP is configured. Self-hosters using only SMTP will get Error: RESEND_API_KEY environment variable is required for email notifications when subscriptions try to send status report updates.

// packages/subscriptions/src/channels/email.ts:10-15 — blocks SMTP users
function getEmailClient(): EmailClient {
  if (!process.env.RESEND_API_KEY) {
    throw new Error("RESEND_API_KEY environment variable is required ...");
  }
  emailClient = new EmailClient({ apiKey: ... }); // also broken signature
}

The check should be removed (the constructor already throws with a proper error if neither provider is configured).

Fix this →


🟡 Important: send.ts module-level throw has no NODE_ENV guard

client.tsx constructor returns early in non-production (if (process.env.NODE_ENV !== "production") return), but send.ts throws unconditionally at module import time when neither provider is configured:

// packages/emails/src/send.ts:23-25 — throws at import time, no env guard
if (!resendClient && !smtpTransporter) {
  throw new Error("Either RESEND_API_KEY or SMTP_HOST must be provided.");
}

CI currently passes because packages/subscriptions/src/test-preload.ts and packages/services/test/preload.ts stub RESEND_API_KEY. That's a workaround but the inconsistency creates a trap: any new test suite that transitively imports send.ts without reading the preload pattern will crash confusingly. Recommend adding if (process.env.NODE_ENV === "production") around that block to match client.tsx.


🟡 Important: SMTP batch sends have no concurrency limit

sendBatch in client.tsx:106-115 sends all SMTP emails simultaneously via Promise.all with no chunking:

// client.tsx:106-115 — unbounded parallelism for SMTP
const sendEmailPromises = opts.map(async (email) => {
  return transporter.sendMail({ ... });
});
await Promise.all(sendEmailPromises);

Resend is capped at 100 per batch. For a self-hoster with 500+ subscribers getting a status report update, this fires 500 concurrent SMTP connections. Most SMTP servers will either reject the excess or start queuing. Worth adding a chunk(opts, 50) loop similar to the Resend path.


🟠 Minor: Stale mock signature in index.test.ts

packages/notifications/email/src/index.test.ts:10 mocks EmailClient with the old { apiKey: string } constructor signature:

EmailClient: mock((_args: { apiKey: string }) => { ... })

This should be mock(() => { ... }) to match the current zero-arg constructor (and avoid misleading future readers).


🟠 Minor: Redundant ?. in send.ts

send.ts:54 and send.ts:79 use optional chaining inside else if (resendClient) blocks:

} else if (resendClient) {
  await resendClient?.emails.send(...)   // ?.  is dead code
  ...
  await resendClient?.batch.send(...)    // ?.  is dead code

The ?. can't be reached when resendClient is falsy, so these should be .emails.send(...) and .batch.send(...).


✅ What's done well

  • Constructor callers: The 3 previously-identified call sites (apps/server, apps/status-page, apps/workflows) are all updated to new EmailClient().
  • ! assertions: sendSingle and sendBatch in client.tsx now throw with a clear error message when the expected transporter is missing.
  • Rate limit guard: sendFollowUpBatched and sendSlackFeedbackBatched correctly gate the re-throw on this.type === "resend".
  • Env validation: SMTP_PORT uses /^\d+$/ regex + range refine, and all fields use .min(1) to reject empty strings.
  • Catalog versioning: nodemailer and @types/nodemailer are correctly in pnpm-workspace.yaml's catalog: section.
  • Non-production guard in constructor: EmailClient returns early in non-production, preventing test crashes.

@mxkaske

mxkaske commented Jun 12, 2026

Copy link
Copy Markdown
Member

few thoughts @aryansingh1718!

  • builds are failing because of the very first remark of claude
  • we need to be careful with changes with bun and react-email (we had issues in the past regarding rendering html)

@cubic-dev-ai cubic-dev-ai 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.

2 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/emails/src/send.ts">

<violation number="1" location="packages/emails/src/send.ts:10">
P2: SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like \"abc\" produce NaN and malformed values like \"587foo\" silently parse as 587, masking configuration errors.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/emails/src/send.ts Outdated
Comment thread packages/emails/src/send.ts Outdated

@cubic-dev-ai cubic-dev-ai 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.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/emails/src/send.ts">

<violation number="1" location="packages/emails/src/send.ts:10">
P2: SMTP port parsing lacks validation for NaN or valid range, and the env schema treats SMTP_PORT as an unconstrained string. Invalid values like \"abc\" produce NaN and malformed values like \"587foo\" silently parse as 587, masking configuration errors.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/emails/src/send.ts Outdated
@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, I've addressed all the review comments:

  • Fixed the two missing EmailClient callers in packages/notifications/email/src/index.ts and packages/subscriptions/src/channels/email.ts
  • Updated packages/subscriptions/src/channels/email.ts to support SMTP (removed the hardcoded RESEND_API_KEY check)
  • Added NODE_ENV === 'production' guard around the module-level throw in send.ts
  • Added controlled concurrency (chunks of 10) for SMTP batch sends in client.tsx
  • Fixed stale mock signature in index.test.ts
  • Removed redundant ?. in send.ts
  • Fixed reply_toreplyTo mapping in resendClient.batch.send()

Let me know if anything else needs to be addressed!

@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, just a gentle reminder on this PR — it's been a few days and I wanted to make sure it didn't get lost. Let me know if any changes are needed!

@mxkaske

mxkaske commented Jun 18, 2026

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @mxkaske's task in 2m 51s —— View job


Code Review: SMTP Support for OpenStatus Emails

  • Gathering context
  • Reading changed files
  • Analyzing code quality and correctness
  • Posting review findings

Good progress across all rounds — the constructor callers, env validation, catalog versioning, non-prod guards, and replyTo mapping are all resolved. A few remaining issues to address before merging.


🔴 CI is failing: formatting errors in send.ts

The autofix.ci job is failing because packages/emails/src/send.ts has formatting inconsistencies detected by oxfmt. The autofix log shows three problems in the file:

  1. Import order: env import should come before chunk import (oxfmt enforces alphabetical/conventional ordering)
  2. Spacing: }else if (resendClient){ at line 55 needs a space → } else if (resendClient) {
  3. Indentation: Inside sendBatchEmailHtml, the const chunks, for loop, and their body are at the wrong indent level (the SMTP if block body is outdented)

Fix: run pnpm format locally and commit the result. The autofix.ci diff shows exactly what oxfmt expects:

-import { chunk } from "./utils";
+
 import { env } from "./env";
+import { chunk } from "./utils";
 
...
-    }else if (resendClient){
+  } else if (resendClient) {
 
...
-  if (smtpTransporter) {
-  const chunks = chunk(emails, 10); // 10 concurrent at a time
-  for (const batch of chunks) {
+  if (smtpTransporter) {
+    const chunks = chunk(emails, 10); // 10 concurrent at a time
+    for (const batch of chunks) {

Fix this →


🟡 sendBatch in client.tsx passes reply_to (snake_case) to Resend

The sendBatch method (line 114) passes the batch objects directly to resendClient.batch.send(batch), but the batch items have reply_to (snake_case) while the Resend SDK expects replyTo (camelCase). The previous round's fix applied the replyTo: email.reply_to mapping to send.ts, but client.tsx wasn't updated:

// client.tsx:112-114 — reply_to is passed, but Resend needs replyTo
const chunks = chunk(opts, 100);
for (const batch of chunks) {
  await client.batch.send(batch); // ← batch items have reply_to, not replyTo
}

None of the current callers of sendBatch actually pass reply_to (status report updates, maintenance notifications, follow-ups don't set it), so this is latent rather than actively breaking. But it's inconsistent with how the single-send path at line 80 explicitly maps replyTo: opts.reply_to.

Fix this →


🟠 SMTP batch concurrency differs between client.tsx and send.ts

sendBatch in client.tsx (lines 98-107) fires all SMTP emails via an unbounded Promise.all:

const sendEmailPromises = opts.map(async (email) => {
  return transporter.sendMail({ ... });
});
await Promise.all(sendEmailPromises);  // ← no chunk limit

sendBatchEmailHtml in send.ts chunks at 10 concurrent. The callers of client.tsx's sendBatch do chunk to 100 subscribers before calling it (via sendStatusReportUpdate and sendMaintenanceNotification), so the effective max concurrency is 100 parallel SMTP connections, which may still overwhelm restrictive SMTP servers. Aligning to send.ts's pattern of chunk(opts, 10) within the SMTP path would be more conservative.


✅ What's well done

  • All 5 EmailClient callers updated to the new no-arg constructor (apps/server, apps/status-page, apps/workflows, packages/notifications/email, packages/subscriptions) — this was the main blocker from prior rounds.
  • env.ts port validation: SMTP_PORT: z.string().regex(/^\d+$/).refine(n => +n >= 1 && +n <= 65535) correctly rejects all malformed values.
  • Non-prod guard: if (process.env.NODE_ENV !== "production") return in the constructor prevents test environment crashes without requiring mock setup.
  • Rate-limit guard: if (this.type === "resend" && err.name === "rate_limit_exceeded") correctly gates the re-throw.
  • ! assertions: sendSingle and sendBatch throw with clear messages when the expected transporter is missing rather than silently no-oping.
  • Catalog versioning: nodemailer: "catalog:" and @types/nodemailer: "catalog:" in both package.json and pnpm-workspace.yaml.
  • chunk utility: Properly centralized in utils.ts and shared between send.ts and client.tsx.
  • replyTo mapping fixed in send.ts: Both sendEmail and sendBatchEmailHtml now correctly pass replyTo: email.reply_to.

Summary: CI is failing due to a formatter issue in send.ts — that's the blocker. The reply_toreplyTo mapping gap in client.tsx and the SMTP concurrency inconsistency are worth addressing before merge but won't break current behaviour since no existing caller sets reply_to on batched sends.

@mxkaske

mxkaske commented Jun 18, 2026

Copy link
Copy Markdown
Member

@aryansingh1718 thanks! will try to give it a push so we can merge this week

@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, just pushed a follow-up commit (990b8e8) that addresses the two review points from earlier:

  • Mapped reply_toreplyTo in client.tsx's sendBatch for the Resend batch path, consistent with the mapping already in send.ts
  • Chunked the SMTP batch sends at 10 concurrent (chunk(opts, 10)) instead of firing an unbounded Promise.all, matching sendBatchEmailHtml's pattern

The formatting issue (import order/spacing/indentation) was already resolved by the autofix-ci bot in cfc830f.

Looks like the only thing blocking checks right now is the pending workflow approval (Vercel deploys need maintainer auth) — would you be able to approve those when you get a chance? Happy to make any further changes if needed. Thanks again for the review!

@mxkaske

mxkaske commented Jun 19, 2026

Copy link
Copy Markdown
Member

@claude review

@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown

Claude finished @mxkaske's task in 2m 11s —— View job


Code Review: SMTP Support for OpenStatus Emails

  • Gathering context
  • Reading changed files
  • Analyzing code quality and correctness
  • Posting review findings

All CI checks are now passing (autofix.ci ✅, DX Check ✅, Tests ✅). All issues raised in previous review rounds have been addressed. Here's the final verification:


✅ All Previously Raised Issues Resolved

Constructor callers — all 5 sites updated to new EmailClient():

  • apps/server/src/routes/v1/statusReports/update/post.ts — module-level singleton ✅
  • apps/status-page/src/lib/auth/providers.ts
  • apps/workflows/src/cron/emails.ts
  • packages/notifications/email/src/index.ts
  • packages/subscriptions/src/channels/email.tsRESEND_API_KEY guard also removed ✅

reply_toreplyTo mapping — fixed in both send.ts and client.tsx's sendBatch (the batch.map(email => ({ ..., replyTo: email.reply_to })) pattern is consistent everywhere) ✅

SMTP batch concurrencyclient.tsx:98 now chunks at 10 via chunk(opts, 10), matching send.ts

Formatting — autofix.ci applied the import-order and indentation fixes in cfc830f

Env validationSMTP_PORT uses regex(/^\d+$/).refine(n => +n >= 1 && +n <= 65535), all string fields use .min(1).optional()

Catalog versioningnodemailer: "catalog:" and @types/nodemailer: "catalog:" in both package.json and pnpm-workspace.yaml

Non-prod guard — constructor returns early when NODE_ENV !== "production", preventing test crashes ✅

Rate-limit guard — correctly gated on this.type === "resend" in sendFollowUpBatched and sendSlackFeedbackBatched

! assertionssendSingle and sendBatch throw descriptively rather than silent no-op when the expected transporter is missing ✅

chunk utility — deduplicated into utils.ts and shared between send.ts and client.tsx


🟠 Minor Residual Notes (non-blocking)

sendBatchEmailHtml in send.ts has no Resend chunk limit — the function calls resendClient.batch.send(emails.map(...)) with no 100-per-batch guard. This is pre-existing behaviour from before this PR (not a regression), and sendBatchEmailHtml is not currently called anywhere with large subscriber lists. Worth noting for future use.

SMTP_FROM overrides per-page sender branding — when using SMTP, env.SMTP_FROM || email.from means all notification emails come from a single configured address rather than ${pageTitle} <notifications@...>. This is acknowledged in the docs and is an acceptable self-hosting trade-off.


Summary

Three rounds of review feedback have been systematically addressed. The implementation is clean, fail-fast, and edge-cases around env validation, concurrency, type mapping, and test isolation are all handled correctly. This is ready to merge.

@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, looks like all the review feedback has been addressed and the bot review marked this ready to merge. The only outstanding items are the Vercel deployment checks, which seem to need authorization on your end rather than anything I can fix from my side. Could you take a look and merge whenever you get a chance? Thanks!

@aryansingh1718

Copy link
Copy Markdown
Author

Hey @mxkaske, just following up on this — would love to get it merged when you get a chance. Happy to make any additional changes if needed. Thanks for your time!

@aggmoulik

Copy link
Copy Markdown
Collaborator

Hey @aryansingh1718 Can you rebase the branch with main for resolving merge conflicts.

@aryansingh1718

aryansingh1718 commented Jul 13, 2026

Copy link
Copy Markdown
Author

@aggmoulik, I rebased the branch with main and fixed all the merge conflicts. Looks like the CI workflows and Vercel previews are waiting on maintainer approval to run — let me know if there's anything else needed from my side in the meantime!
Also, whenever you get a chance to review, I'd really appreciate it if we could get this merged. Thanks so much for your time and help throughout this!

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.

[Feature Request] Support SMTP as an alternative to Resend

3 participants