Keep a published rule when some invitation emails already went out, and only set Reply-To when the inviter address is a mailbox.
This commit is contained in:
1 parent
3c984e00c5
commit
ab6a0c807d
7 files changed
+161
-10
No files matched your search
@@ -9,6 +9,7 @@ import { sendRuleStakeholderInviteEmail } from "../../../../../lib/server/mail";
|
|||||||
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
import { apiRoute } from "../../../../../lib/server/apiRoute";
|
||||||
import { logRouteError } from "../../../../../lib/server/requestId";
|
import { logRouteError } from "../../../../../lib/server/requestId";
|
||||||
import { stakeholderInviteVerifyUrl } from "../../../../../lib/server/ruleStakeholderInviteOps";
|
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 { STAKEHOLDER_INVITE_TTL_MS } from "../../../../../lib/server/ruleStakeholders";
|
||||||
import {
|
import {
|
||||||
dbUnavailable,
|
dbUnavailable,
|
||||||
@@ -102,7 +103,9 @@ export const POST = apiRoute(
|
|||||||
const origin = getPublicOrigin(request);
|
const origin = getPublicOrigin(request);
|
||||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken);
|
const verifyUrl = stakeholderInviteVerifyUrl(origin, nextToken);
|
||||||
const inviterEmail =
|
const inviterEmail =
|
||||||
row.invitedBy?.email ?? row.rule.user?.email ?? "CommunityRule";
|
row.invitedBy?.email ??
|
||||||
|
row.rule.user?.email ??
|
||||||
|
RULE_STAKEHOLDER_INVITER_FALLBACK;
|
||||||
try {
|
try {
|
||||||
await sendRuleStakeholderInviteEmail({
|
await sendRuleStakeholderInviteEmail({
|
||||||
to: row.email,
|
to: row.email,
|
||||||
|
|||||||
+12
-7
@@ -205,9 +205,10 @@ export const POST = apiRoute(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const origin = getPublicOrigin(request);
|
const origin = getPublicOrigin(request);
|
||||||
try {
|
let sent = 0;
|
||||||
for (const inv of invites) {
|
for (const inv of invites) {
|
||||||
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
|
const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token);
|
||||||
|
try {
|
||||||
await sendRuleStakeholderInviteEmail({
|
await sendRuleStakeholderInviteEmail({
|
||||||
to: inv.email,
|
to: inv.email,
|
||||||
verifyUrl,
|
verifyUrl,
|
||||||
@@ -217,11 +218,15 @@ export const POST = apiRoute(
|
|||||||
summary,
|
summary,
|
||||||
document,
|
document,
|
||||||
});
|
});
|
||||||
|
sent += 1;
|
||||||
|
} catch (err) {
|
||||||
|
logRouteError("rules.publish", requestId, err, {
|
||||||
|
phase: "sendRuleStakeholderInviteEmail",
|
||||||
|
email: inv.email,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}
|
||||||
logRouteError("rules.publish", requestId, err, {
|
if (sent === 0) {
|
||||||
phase: "sendRuleStakeholderInviteEmail",
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await prisma.publishedRule.delete({ where: { id: rule.id } });
|
await prisma.publishedRule.delete({ where: { id: rule.id } });
|
||||||
} catch (delErr) {
|
} catch (delErr) {
|
||||||
|
|||||||
+3
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
import { getSmtpUrl } from "./env";
|
import { getSmtpUrl } from "./env";
|
||||||
import {
|
import {
|
||||||
buildRuleStakeholderInviteMail,
|
buildRuleStakeholderInviteMail,
|
||||||
|
stakeholderInviteReplyTo,
|
||||||
type RuleStakeholderInviteMailInput,
|
type RuleStakeholderInviteMailInput,
|
||||||
} from "./ruleStakeholderInviteMail";
|
} from "./ruleStakeholderInviteMail";
|
||||||
|
|
||||||
@@ -102,12 +103,13 @@ export async function sendRuleStakeholderInviteEmail(
|
|||||||
input: RuleStakeholderInviteMailInput,
|
input: RuleStakeholderInviteMailInput,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { subject, text, html } = buildRuleStakeholderInviteMail(input);
|
const { subject, text, html } = buildRuleStakeholderInviteMail(input);
|
||||||
|
const replyTo = stakeholderInviteReplyTo(input.inviterEmail);
|
||||||
await sendHtmlMail({
|
await sendHtmlMail({
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
replyTo: input.inviterEmail.trim().toLowerCase(),
|
...(replyTo ? { replyTo } : {}),
|
||||||
devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`,
|
devLog: `[dev] Rule stakeholder invite (${input.communityName}) for ${input.to}: ${input.verifyUrl}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ import {
|
|||||||
|
|
||||||
export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK);
|
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 =
|
const PREVIEW_TEXT =
|
||||||
"It covers how they talk, who belongs, how they decide and what happens when they disagree.";
|
"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,
|
input: RuleStakeholderInviteMailInput,
|
||||||
): RuleStakeholderInviteMailContent {
|
): RuleStakeholderInviteMailContent {
|
||||||
const communityName = input.communityName.trim() || "A community";
|
const communityName = input.communityName.trim() || "A community";
|
||||||
const inviterEmail = input.inviterEmail.trim().toLowerCase();
|
const inviterEmail = stakeholderInviteInviterLabel(input.inviterEmail);
|
||||||
const origin = input.origin.replace(/\/$/, "");
|
const origin = input.origin.replace(/\/$/, "");
|
||||||
const verifyUrl = input.verifyUrl;
|
const verifyUrl = input.verifyUrl;
|
||||||
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
|
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
|
||||||
|
|||||||
@@ -1,13 +1,47 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
RULE_STAKEHOLDER_INVITE_LOGO_PATH,
|
RULE_STAKEHOLDER_INVITE_LOGO_PATH,
|
||||||
|
RULE_STAKEHOLDER_INVITER_FALLBACK,
|
||||||
buildRuleStakeholderInviteMail,
|
buildRuleStakeholderInviteMail,
|
||||||
ruleStakeholderInviteCopyUrl,
|
ruleStakeholderInviteCopyUrl,
|
||||||
|
stakeholderInviteInviterLabel,
|
||||||
|
stakeholderInviteReplyTo,
|
||||||
} from "../../lib/server/ruleStakeholderInviteMail";
|
} from "../../lib/server/ruleStakeholderInviteMail";
|
||||||
|
|
||||||
const VERIFY_URL =
|
const VERIFY_URL =
|
||||||
"https://communityrule.info/api/invites/rule-stakeholder/verify?token=abc";
|
"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", () => {
|
describe("ruleStakeholderInviteCopyUrl", () => {
|
||||||
it("adds copy=1 to the invitation link", () => {
|
it("adds copy=1 to the invitation link", () => {
|
||||||
expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe(
|
expect(ruleStakeholderInviteCopyUrl(VERIFY_URL)).toBe(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ vi.mock("../../lib/server/rateLimit", () => ({
|
|||||||
rateLimitKeyMock(key, minIntervalMs),
|
rateLimitKeyMock(key, minIntervalMs),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { RULE_STAKEHOLDER_INVITER_FALLBACK } from "../../lib/server/ruleStakeholderInviteMail";
|
||||||
import { POST } from "../../app/api/invites/rule-stakeholder/resend/route";
|
import { POST } from "../../app/api/invites/rule-stakeholder/resend/route";
|
||||||
|
|
||||||
beforeEach(() => {
|
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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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<unknown>,
|
||||||
|
) => {
|
||||||
|
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 () => {
|
it("creates an unlisted rule when there is no session", async () => {
|
||||||
getSessionUserMock.mockResolvedValueOnce(null);
|
getSessionUserMock.mockResolvedValueOnce(null);
|
||||||
publishedRuleCreateMock.mockResolvedValueOnce({
|
publishedRuleCreateMock.mockResolvedValueOnce({
|
||||||
|
|||||||
Reference in new issue
Block a user