From ab6a0c807d38fce68b9f1a183174fac7b1036cf1 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:22:06 -0700 Subject: [PATCH] Keep a published rule when some invitation emails already went out, and only set Reply-To when the inviter address is a mailbox. --- .../invites/rule-stakeholder/resend/route.ts | 5 +- app/api/rules/route.ts | 19 ++++--- lib/server/mail.ts | 4 +- lib/server/ruleStakeholderInviteMail.ts | 24 ++++++++- tests/unit/ruleStakeholderInviteMail.test.ts | 34 +++++++++++++ ...takeholderInviteResendExpiredRoute.test.ts | 36 ++++++++++++++ tests/unit/rulesPublishPostRoute.test.ts | 49 +++++++++++++++++++ 7 files changed, 161 insertions(+), 10 deletions(-) diff --git a/app/api/invites/rule-stakeholder/resend/route.ts b/app/api/invites/rule-stakeholder/resend/route.ts index 666b7d9..261921b 100644 --- a/app/api/invites/rule-stakeholder/resend/route.ts +++ b/app/api/invites/rule-stakeholder/resend/route.ts @@ -9,6 +9,7 @@ import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail"; import { apiRoute } from "../../../../../lib/server/apiRoute"; import { logRouteError } from "../../../../../lib/server/requestId"; import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps"; +import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../../../../lib/server/ruleStakeholderInviteMail"; import { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders"; import { dbUnavailable, @@ -102,7 +103,9 @@ export const POST = apiRoute( const origin = getPublicOrigin(request); const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken); const inviterEmail = - row.invitedBy?.email ?? row.rule.user?.email ?? "CommunityRule"; + row.invitedBy?.email ?? + row.rule.user?.email ?? + RULE_STAKEHOLDER_INVITER_FALLBACK; try { await sendRuleStakeholderInviteEmail({ to: row.email, diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 56de731..53b7e8d 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -205,9 +205,10 @@ export const POST = apiRoute( }); const origin = getPublicOrigin(request); - try { - for (const inv of invites) { - const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); + let sent = 0; + for (const inv of invites) { + const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); + try { await sendRuleStakeholderInviteEmail({ to: inv.email, verifyUrl, @@ -217,11 +218,15 @@ export const POST = apiRoute( summary, document, }); + sent += 1; + } catch (err) { + logRouteError("rules.publish", requestId, err, { + phase: "sendRuleStakeholderInviteEmail", + email: inv.email, + }); } - } catch (err) { - logRouteError("rules.publish", requestId, err, { - phase: "sendRuleStakeholderInviteEmail", - }); + } + if (sent === 0) { try { await prisma.publishedRule.delete({ where: { id: rule.id } }); } catch (delErr) { diff --git a/lib/server/mail.ts b/lib/server/mail.ts index 1fa6705..aad88b3 100644 --- a/lib/server/mail.ts +++ b/lib/server/mail.ts @@ -8,6 +8,7 @@ import { import { getSmtpUrl } from "./env"; import { buildRuleStakeholderInviteMail, + stakeholderInviteReplyTo, type RuleStakeholderInviteMailInput, } from "./ruleStakeholderInviteMail"; @@ -102,12 +103,13 @@ export async function sendRuleStakeholderInviteEmail( input: RuleStakeholderInviteMailInput, ): Promise { const { subject, text, html } = buildRuleStakeholderInviteMail(input); + const replyTo = stakeholderInviteReplyTo(input.inviterEmail); await sendHtmlMail({ to: input.to, subject, text, html, - replyTo: input.inviterEmail.trim().toLowerCase(), + ...(replyTo ? { replyTo } : {}), devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`, }); } diff --git a/lib/server/ruleStakeholderInviteMail.ts b/lib/server/ruleStakeholderInviteMail.ts index b8df030..86b72ed 100644 --- a/lib/server/ruleStakeholderInviteMail.ts +++ b/lib/server/ruleStakeholderInviteMail.ts @@ -8,6 +8,28 @@ import { export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK); +/** Letter name when neither the inviter nor the rule owner has an email. */ +export const RULE_STAKEHOLDER_INVITER_FALLBACK = "Someone from this community"; + +const MAILBOX_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Reply-To only when the inviter value is a mailbox. */ +export function stakeholderInviteReplyTo( + inviterEmail: string, +): string | undefined { + const mailbox = inviterEmail.trim().toLowerCase(); + if (!MAILBOX_PATTERN.test(mailbox)) return undefined; + return mailbox; +} + +/** Email addresses stay lowercase; any other label is kept for the letter. */ +export function stakeholderInviteInviterLabel(inviterEmail: string): string { + const replyTo = stakeholderInviteReplyTo(inviterEmail); + if (replyTo) return replyTo; + const trimmed = inviterEmail.trim(); + return trimmed.length > 0 ? trimmed : RULE_STAKEHOLDER_INVITER_FALLBACK; +} + const PREVIEW_TEXT = "It covers how they talk, who belongs, how they decide and what happens when they disagree."; @@ -44,7 +66,7 @@ export function buildRuleStakeholderInviteMail( input: RuleStakeholderInviteMailInput, ): RuleStakeholderInviteMailContent { const communityName = input.communityName.trim() || "A community"; - const inviterEmail = input.inviterEmail.trim().toLowerCase(); + const inviterEmail = stakeholderInviteInviterLabel(input.inviterEmail); const origin = input.origin.replace(/\/$/, ""); const verifyUrl = input.verifyUrl; const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl); diff --git a/tests/unit/ruleStakeholderInviteMail.test.ts b/tests/unit/ruleStakeholderInviteMail.test.ts index 9856167..a2f3e97 100644 --- a/tests/unit/ruleStakeholderInviteMail.test.ts +++ b/tests/unit/ruleStakeholderInviteMail.test.ts @@ -1,13 +1,47 @@ import { describe, expect, it } from "vitest"; import { RULE_STAKEHOLDER_INVITE_LOGO_PATH, + RULE_STAKEHOLDER_INVITER_FALLBACK, buildRuleStakeholderInviteMail, ruleStakeholderInviteCopyUrl, + stakeholderInviteInviterLabel, + stakeholderInviteReplyTo, } from "../../lib/server/ruleStakeholderInviteMail"; const VERIFY_URL = "https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc"; +describe("stakeholder invite reply address", () => { + it("uses a mailbox as Reply-To and keeps a missing address out of the header", () => { + expect(stakeholderInviteReplyTo(" Maya@Example.org ")).toBe( + "maya@example.org", + ); + expect(stakeholderInviteReplyTo(RULE_STAKEHOLDER_INVITER_FALLBACK)).toBe( + undefined, + ); + expect(stakeholderInviteReplyTo("CommunityRule")).toBe(undefined); + }); + + it("names a missing inviter without turning the label into an address", () => { + expect(stakeholderInviteInviterLabel("")).toBe( + RULE_STAKEHOLDER_INVITER_FALLBACK, + ); + expect(stakeholderInviteInviterLabel("Maya@Example.org")).toBe( + "maya@example.org", + ); + const mail = buildRuleStakeholderInviteMail({ + to: "rosa@example.org", + verifyUrl: VERIFY_URL, + origin: "https://communityrule.info", + communityName: "Juniper Street Food Share", + inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK, + }); + expect(mail.text).toContain( + `${RULE_STAKEHOLDER_INVITER_FALLBACK} sent the invitation.`, + ); + }); +}); + describe("ruleStakeholderInviteCopyUrl", () => { it("adds copy=1 to the invitation link", () => { expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe( diff --git a/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts index 8b4c3bf..95f9e90 100644 --- a/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts +++ b/tests/unit/ruleStakeholderInviteResendExpiredRoute.test.ts @@ -41,6 +41,7 @@ vi.mock("../../lib/server/rateLimit", () => ({ rateLimitKeyMock(key, minIntervalMs), })); +import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../lib/server/ruleStakeholderInviteMail"; import { POST } from "../../app/api/invites/rule-stakeholder/resend/route"; beforeEach(() => { @@ -109,4 +110,39 @@ describe("POST /api/invites/rule-stakeholder/resend", () => { }), ); }); + + it("does not use a fake mailbox when the inviter and owner have no email", async () => { + findUniqueMock.mockResolvedValueOnce({ + id: "st-1", + email: "inv@example.com", + inviteTokenHash: "hash-old", + inviteExpiresAt: new Date(Date.now() - 60_000), + invitedBy: null, + rule: { + title: "Juniper Street Food Share", + summary: null, + document: {}, + user: { email: null }, + }, + }); + + const res = await POST( + new NextRequest("https://x.test/api/invites/rule-stakeholder/resend", { + method: "POST", + body: JSON.stringify({ + token: "a".repeat(12), + email: "inv@example.com", + }), + headers: { "content-type": "application/json" }, + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(sendInviteMock).toHaveBeenCalledWith( + expect.objectContaining({ + inviterEmail: RULE_STAKEHOLDER_INVITER_FALLBACK, + }), + ); + }); }); diff --git a/tests/unit/rulesPublishPostRoute.test.ts b/tests/unit/rulesPublishPostRoute.test.ts index 1126321..8af7e13 100644 --- a/tests/unit/rulesPublishPostRoute.test.ts +++ b/tests/unit/rulesPublishPostRoute.test.ts @@ -202,6 +202,55 @@ describe("POST /api/rules", () => { }); }); + it("keeps the rule when a later invite send fails after one succeeded", async () => { + const created = { + id: "rule-partial", + title: "Published title", + summary: null, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }; + transactionMock.mockImplementation( + async ( + fn: (tx: { + publishedRule: { create: typeof vi.fn }; + ruleStakeholder: { create: typeof vi.fn }; + }) => Promise, + ) => { + const tx = { + publishedRule: { + create: vi.fn().mockResolvedValue(created), + }, + ruleStakeholder: { + create: vi.fn().mockResolvedValue({}), + }, + }; + return fn(tx); + }, + ); + sendInviteMock + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("smtp down")); + + const res = await POST( + new NextRequest("https://x.test/api/rules", { + method: "POST", + body: JSON.stringify({ + title: "Published title", + summary: null, + document: {}, + stakeholderEmails: ["first@example.com", "second@example.com"], + }), + }), + undefined, + ); + + expect(res.status).toBe(200); + expect(sendInviteMock).toHaveBeenCalledTimes(2); + expect(publishedRuleDeleteMock).not.toHaveBeenCalled(); + const body = (await res.json()) as { rule: { id: string } }; + expect(body.rule.id).toBe("rule-partial"); + }); + it("creates an unlisted rule when there is no session", async () => { getSessionUserMock.mockResolvedValueOnce(null); publishedRuleCreateMock.mockResolvedValueOnce({