Skip to content

fix: make email sender addresses configurable via env vars - #98

Open
VinneyJ wants to merge 5 commits into
main-stagingfrom
email-fix-clean
Open

VinneyJ wants to merge 5 commits into
main-stagingfrom
email-fix-clean

Conversation

@VinneyJ

@VinneyJ VinneyJ commented Oct 14, 2026

Copy link
Copy Markdown

Summary

Email sending was broken because upstream hardcodes system@dub.co as the sender address, which Resend rejects for non-dub.co deployments.

  • Made all three from addresses (primary, notifications, marketing) configurable via env vars, falling back to the upstream dub.co values
  • Made the replyTo fallback configurable via EMAIL_REPLY_TO env var

Env vars to set in Vercel

Var Example value
EMAIL_FROM_PRIMARY Code for Africa <system@mail.codeforafrica.org>
EMAIL_FROM_NOTIFICATIONS Code for Africa <notifications@mail.codeforafrica.org>
EMAIL_FROM_MARKETING Code for Africa <hello@mail.codeforafrica.org>
EMAIL_REPLY_TO support@codeforafrica.org

Values must use a domain verified in your Resend account.

Files changed

  • packages/email/src/resend/constants.ts
  • packages/email/src/send-via-resend.ts

Closes CodeForAfrica/infrastructure-as-code#133
Supersedes #97

Upstream hardcodes dub.co sender addresses which Resend rejects for
non-dub.co deployments. Read from EMAIL_FROM_PRIMARY,
EMAIL_FROM_NOTIFICATIONS, EMAIL_FROM_MARKETING, and EMAIL_REPLY_TO
env vars with dub.co values as fallbacks.
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
dub Ready Ready Preview Oct 16, 2026 11:56am UTC

Request Review

@nkasozi

nkasozi commented Oct 14, 2026 •

Copy link
Copy Markdown

Ok @VinneyJ ..this is better and this fixes the sender configuration, but please also address the existing false-success bug. Its actually this bug that made figuring out this issue a bit hard becoz it showed success in the UI

Currently, inviteUser returns Resend’s response directly:

return await sendEmail({
  subject: "You've been invited to join a workspace on Dub",
  to: email,
  react: WorkspaceInvite({
    email,
    url,
    workspaceName: workspace.name,
    workspaceUser: session?.user.name || null,
    workspaceUserEmail: session?.user.email || null,
  }),
});

The [endpoint only checks whether promises were rejected](

);
if (results.some((result) => result.status === "rejected")) {
const failedInvites = results.filter(
(result): result is PromiseRejectedResult =>
result.status === "rejected",
);
throw new DubApiError({
code: "bad_request",
message: `Failed to send ${pluralize("invitation", failedInvites.length)}: ${failedInvites.map((result) => result.reason.message).join(", ")}`,
});
}
):

if (results.some((result) => result.status === "rejected")) {

Resend can instead resolve with { data: null, error: {...} }. That counts as a fulfilled promise, so the endpoint incorrectly reaches [this success response](

return NextResponse.json({ message: "Invite(s) sent" });
):

return NextResponse.json({ message: "Invite(s) sent" });

Suggested change: capture the sendEmail(...) response and return an explicit result:

const response = await sendEmail({
  // Keep the existing email options above.
});

if (!response || ("error" in response && response.error)) {
  return {
    success: false as const,
    email,
    message: "Invitation saved, but its email could not be sent.",
  };
}

return { success: true as const, email };

Then have the endpoint check both rejected promises and returned failures:

const failedEmails = results.flatMap((result, index) => {
  if (result.status === "rejected") return [teammates[index].email];
  if (!result.value.success) return [result.value.email];
  return [];
});

if (failedEmails.length > 0) {
  return NextResponse.json(
    {
      error: {
        code: "internal_server_error",
        message:
          `Could not send invitations to: ${failedEmails.join(", ")}. ` +
          "Check pending invitations before retrying.",
      },
    },
    { status: 500 },
  );
}

return NextResponse.json({ message: "Invite(s) sent" });

Please add tests for Resend returning an error, a rejected send, and a mixed batch. These should never return “Invite(s) sent” for the whole batch.

Resend resolves with { data: null, error: {...} } on failure rather
than rejecting. The endpoint only checked for rejected promises so it
returned success even when email delivery failed. Now checks both
rejected promises and resolved error responses, returning 500 with
the affected addresses when any invite email fails.
@nkasozi

nkasozi commented Oct 15, 2026

Copy link
Copy Markdown

Minimum tests to add:

  1. Successful send: Resend returns { data: { id }, error: null } → success: true; endpoint returns HTTP 200.
  2. Resend returns an error: { data: null, error: {...} } → success: false; endpoint returns HTTP 500, never “Invite(s) sent.”
  3. Send rejects: sendEmail rejects → endpoint reports the affected recipient with HTTP 500.
  4. No provider configured: sendEmail returns undefined → failure, not success.
  5. Mixed batch: one send succeeds and another fails → error response identifies only the failed recipient; successful invitations remain intact.
  6. Duplicate invitation: already-invited recipient → HTTP 409/conflict, not HTTP 500; no email attempted.
  7. Pending invite retained: email failure leaves the saved invitation intact.

@nkasozi

nkasozi commented Oct 16, 2026

Copy link
Copy Markdown

@VinneyJ Seen the tests but they are still missing the crucial parts

Coverage is still incomplete:

  • No actual endpoint tests asserting HTTP 200/500/409. The mixed-batch test copies the filtering logic rather than exercising the endpoint.
  • The duplicate test checks the helper’s conflict error, but the endpoint still converts it to HTTP 500.

This branch was successfully deployed

1 active deployment
Preview — 1fac8942 Deployed Oct 16, 2026 by vercel[bot]
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.

2 participants