Send the invitation as a light letter that names the community, the inviter by email, and who can read the Rule.

This commit is contained in:
adilallo committed 2026-11-06 09:00:44 -07:00
1 parent 70ceb73104
commit 727a92a9ac
14 files changed
+534 -26

No files matched your search

+249
View File
@@ -0,0 +1,249 @@
import { ASSETS, getAssetPath } from "../assetUtils";
import {
clipCommunityInviteDescription,
communityInviteInitials,
communityPossessive,
inviteRuleCountsLabel,
} from "../create/ruleInviteCardMeta";
export const RULE_STAKEHOLDER_INVITE_LOGO_PATH = getAssetPath(ASSETS.LOGO_MARK);
const PREVIEW_TEXT =
"It covers how they talk, who belongs, how they decide and what happens when they disagree.";
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function ruleStakeholderInviteCopyUrl(verifyUrl: string): string {
return `${verifyUrl}${verifyUrl.includes("?") ? "&" : "?"}copy=1`;
}
export type RuleStakeholderInviteMailInput = {
to: string;
verifyUrl: string;
origin: string;
communityName: string;
inviterEmail: string;
summary?: string | null;
document?: unknown;
};
export type RuleStakeholderInviteMailContent = {
subject: string;
previewText: string;
text: string;
html: string;
};
export function buildRuleStakeholderInviteMail(
input: RuleStakeholderInviteMailInput,
): RuleStakeholderInviteMailContent {
const communityName = input.communityName.trim() || "A community";
const inviterEmail = input.inviterEmail.trim().toLowerCase();
const origin = input.origin.replace(/\/$/, "");
const verifyUrl = input.verifyUrl;
const copyUrl = ruleStakeholderInviteCopyUrl(verifyUrl);
const possessive = communityPossessive(communityName);
const description = clipCommunityInviteDescription(input.summary);
const initials = communityInviteInitials(communityName);
const countsLabel = inviteRuleCountsLabel(input.document);
const subject = `${communityName} invited you to read their Rule`;
const intro = `${inviterEmail} sent the invitation. Their Rule sets out how they talk, who belongs, how they decide and what happens when they disagree.`;
const privacy = `Every Rule starts private. Only the people ${communityName} invites can read it until the person who holds the pen turns on a link. A Rule is public and featured only when a community asks us in writing.`;
const ownLink =
"Your own link. Forwarding this email shares it. Ask the community to invite anyone else who should read the Rule.";
const copyLead =
"Writing a Rule for another community? Make a copy of this one and change what does not fit.";
const why = `You got this email because ${inviterEmail} added your address to ${possessive} Rule on CommunityRule. If you do not know the community, you can ignore this email.`;
const madeBy =
"CommunityRule is made by the Media Economies Design Lab at the University of Colorado Boulder.";
const privacyUrl = `${origin}/privacy`;
const aboutUrl = `${origin}/about`;
const logoUrl = `${origin}${RULE_STAKEHOLDER_INVITE_LOGO_PATH}`;
const text = [
subject,
"",
intro,
"",
"Read the Rule",
verifyUrl,
"",
privacy,
"",
ownLink,
"",
copyLead,
copyUrl,
"",
why,
"",
madeBy,
`Privacy ${privacyUrl}`,
].join("\n");
const html = inviteHtml({
subject,
intro,
verifyUrl,
copyUrl,
communityName,
description,
initials,
countsLabel,
privacy,
ownLink,
copyLead,
why,
madeBy,
privacyUrl,
aboutUrl,
logoUrl,
});
return { subject, previewText: PREVIEW_TEXT, text, html };
}
function inviteHtml(opts: {
subject: string;
intro: string;
verifyUrl: string;
copyUrl: string;
communityName: string;
description: string;
initials: string;
countsLabel: string;
privacy: string;
ownLink: string;
copyLead: string;
why: string;
madeBy: string;
privacyUrl: string;
aboutUrl: string;
logoUrl: string;
}): string {
const heading = escapeHtml(opts.subject);
const intro = escapeHtml(opts.intro);
const verifyHref = escapeHtml(opts.verifyUrl);
const copyHref = escapeHtml(opts.copyUrl);
const name = escapeHtml(opts.communityName);
const description = escapeHtml(opts.description);
const initials = escapeHtml(opts.initials);
const counts = escapeHtml(opts.countsLabel);
const privacyHtml = privacyHtmlWithLead(opts.privacy);
const ownLinkHtml = leadInHtml("Your own link.", opts.ownLink);
const copyHtml =
`<strong style="color:#000000;">Writing a Rule for another community?</strong>` +
` <a href="${copyHref}" style="color:#000000;text-decoration:underline;">Make a copy</a>` +
` of this one and change what does not fit.`;
const why = escapeHtml(opts.why);
const madeBy = escapeHtml(opts.madeBy);
const privacyUrl = escapeHtml(opts.privacyUrl);
const aboutUrl = escapeHtml(opts.aboutUrl);
const logoUrl = escapeHtml(opts.logoUrl);
const cardBody = description
? `<p style="margin:16px 0 0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:18px;font-weight:500;color:#000000;">${description}</p>`
: "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:0;background:#e4e4e4;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(
PREVIEW_TEXT,
)}</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#e4e4e4;">
<tr>
<td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="width:600px;max-width:600px;background:#ffffff;">
<tr>
<td style="background:#000000;padding:24px 32px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:22px;line-height:27px;color:#ffffff;padding-right:6px;">CommunityRule</td>
<td>
<img src="${logoUrl}" width="27" height="27" alt="" style="display:block;border:0;width:27px;height:27px;" />
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:40px 32px 56px;">
<h1 style="margin:0 0 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:36px;line-height:44px;font-weight:700;color:#000000;">${heading}</h1>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:18px;line-height:24px;color:#303030;">${intro}</p>
<p style="margin:0 0 16px;">
<a href="${verifyHref}" style="display:inline-block;background:#141414;color:#ffffff;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:20px;font-weight:500;text-decoration:none;border-radius:9999px;padding:12px 16px;">Read the Rule</a>
</p>
<p style="margin:0 0 4px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">Or paste this link into a browser</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;">
<a href="${verifyHref}" style="color:#000000;text-decoration:underline;">${verifyHref}</a>
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#c9fef9;border-radius:12px;">
<tr>
<td style="padding:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-bottom:1px solid #000000;">
<tr>
<td width="74" valign="middle" style="padding:8px 8px 8px 4px;">
<div style="width:56px;height:56px;border-radius:9999px;background:#fffdd2;font-family:Inter,Arial,sans-serif;font-size:24px;line-height:56px;font-weight:500;color:#000000;text-align:center;">${initials}</div>
</td>
<td valign="middle" style="border-left:1px solid #000000;padding:12px 16px;font-family:'Bricolage Grotesque',Arial,sans-serif;font-size:24px;line-height:32px;font-weight:700;color:#000000;">${name}</td>
</tr>
</table>
${cardBody}
<p style="margin:16px 0 0;">
<span style="display:inline-block;background:#303030;color:#c9fef9;font-family:Inter,Arial,sans-serif;font-size:10px;line-height:12px;font-weight:500;letter-spacing:0.2px;text-transform:uppercase;border-radius:2px;padding:2px 4px;">${counts}</span>
</p>
</td>
</tr>
</table>
<div style="height:1px;background:#e4e4e4;margin:32px 0;"></div>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${privacyHtml}</p>
<p style="margin:0 0 20px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${ownLinkHtml}</p>
<p style="margin:0 0 32px;font-family:Inter,Arial,sans-serif;font-size:16px;line-height:24px;color:#303030;">${copyHtml}</p>
<div style="border-top:1px solid #e4e4e4;padding-top:24px;">
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${why}</p>
<p style="margin:0 0 12px;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">${madeBy}</p>
<p style="margin:0;font-family:Inter,Arial,sans-serif;font-size:14px;line-height:20px;color:#464646;">
<a href="${privacyUrl}" style="color:#464646;text-decoration:underline;">Privacy</a>
&nbsp;&nbsp;
<a href="${aboutUrl}" style="color:#464646;text-decoration:underline;">About CommunityRule</a>
</p>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function privacyHtmlWithLead(privacy: string): string {
const lead = "Every Rule starts private.";
if (privacy.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
privacy.slice(lead.length),
)}`;
}
return escapeHtml(privacy);
}
function leadInHtml(lead: string, full: string): string {
if (full.startsWith(lead)) {
return `<strong style="color:#000000;">${escapeHtml(lead)}</strong>${escapeHtml(
full.slice(lead.length),
)}`;
}
return escapeHtml(full);
}