From fbf6510dd85be1a4c5fad9eb88013df30f875812 Mon Sep 17 00:00:00 2001 From: Lawrence Christian <67164412+laurenschristian@users.noreply.github.com> Date: Sat, 28 Mar 2026 08:08:52 -0500 Subject: [PATCH] fix: join Reply-To addresses as string for SMTP compatibility (#28611) * fix: join multiple Reply-To addresses as comma-separated string Some SMTP providers (e.g., SendLayer) reject emails when Reply-To is passed as an array to nodemailer, which serializes it as multiple Reply-To headers. Using a comma-joined string is RFC 2822 compliant and works universally across all SMTP providers. Fixes #28610 * test: add unit tests for getReplyToHeader SMTP compatibility - Verify replyTo is always returned as comma-separated string, not array - Test single email, multiple emails, and empty email cases - Add RFC 5322 compliance test for SMTP compatibility Co-Authored-By: Claude Opus 4.5 * docs: add RFC 5322 reference comment to getReplyToHeader tests Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Lawrence Christian <67164412+LCNDevs@users.noreply.github.com> Co-authored-by: Romit <85230081+romitg2@users.noreply.github.com> Co-authored-by: Romit Co-authored-by: Claude Opus 4.5 --- packages/lib/getReplyToHeader.test.ts | 92 +++++++++++++++++++++++++++ packages/lib/getReplyToHeader.ts | 2 +- 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 packages/lib/getReplyToHeader.test.ts diff --git a/packages/lib/getReplyToHeader.test.ts b/packages/lib/getReplyToHeader.test.ts new file mode 100644 index 0000000000..0b558d948a --- /dev/null +++ b/packages/lib/getReplyToHeader.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getReplyToHeader } from "./getReplyToHeader"; + +/** + * RFC 5322 (Internet Message Format) specifies that the Reply-To header must be + * a comma-separated list of addresses, not an array. Many SMTP servers reject arrays. + * Spec: https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 + */ + +vi.mock("./getReplyToEmail", () => ({ + getReplyToEmail: vi.fn((calEvent, excludeOrganizerEmail) => { + if (excludeOrganizerEmail) return null; + return calEvent.organizer?.email || null; + }), +})); + +const createMockCalEvent = (organizerEmail: string) => ({ + organizer: { email: organizerEmail }, + hideOrganizerEmail: false, +}); + +describe("getReplyToHeader", () => { + describe("return type", () => { + it("always returns replyTo as a string, never an array", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["attendee1@test.com", "attendee2@test.com"]); + + expect(result).toHaveProperty("replyTo"); + expect(typeof result.replyTo).toBe("string"); + // Should NOT be an array + expect(Array.isArray(result.replyTo)).toBe(false); + }); + }); + + describe("with single email", () => { + it("returns single email as string", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any); + + expect(result).toEqual({ replyTo: "organizer@test.com" }); + }); + + it("returns additionalEmail as string when provided alone", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, "additional@test.com", true); + + expect(result).toEqual({ replyTo: "additional@test.com" }); + }); + }); + + describe("with multiple emails", () => { + it("returns comma-separated string for multiple emails", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["attendee1@test.com", "attendee2@test.com"]); + + expect(result).toEqual({ + replyTo: "attendee1@test.com, attendee2@test.com, organizer@test.com", + }); + }); + + it("returns comma-separated string when additionalEmails is array", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["a@test.com", "b@test.com", "c@test.com"], true); + + expect(result).toEqual({ + replyTo: "a@test.com, b@test.com, c@test.com", + }); + }); + }); + + describe("with no emails", () => { + it("returns empty object when no emails available", () => { + const calEvent = { organizer: { email: "" }, hideOrganizerEmail: true }; + const result = getReplyToHeader(calEvent as any, undefined, true); + + expect(result).toEqual({}); + }); + }); + + describe("SMTP compatibility", () => { + it("produces RFC 5322 compliant Reply-To header format", () => { + const calEvent = createMockCalEvent("organizer@test.com"); + const result = getReplyToHeader(calEvent as any, ["a@test.com", "b@test.com"]); + + // RFC 5322 specifies comma-separated list for multiple addresses + expect(result.replyTo).toMatch(/^[^,]+, [^,]+, [^,]+$/); + expect(result.replyTo).not.toContain("["); + expect(result.replyTo).not.toContain("]"); + }); + }); +}); diff --git a/packages/lib/getReplyToHeader.ts b/packages/lib/getReplyToHeader.ts index 36254dfe54..cff6e458e1 100644 --- a/packages/lib/getReplyToHeader.ts +++ b/packages/lib/getReplyToHeader.ts @@ -26,6 +26,6 @@ export function getReplyToHeader( return {}; } - const replyTo = emailArray.length === 1 ? emailArray[0] : emailArray; + const replyTo = emailArray.join(", "); return { replyTo }; }