Add multi-tenant cloud mode: self-serve purchase, credit metering, core-decoupling
Introduces RECAP_MODE=multi alongside single-mode self-host: - Tenant auth + accounts (magic-link via System SMTP), per-tenant credit pool, anonymous trial minting with per-IP/-64 caps - Self-serve Pro/Max purchase: inline Lightning (BTCPay) + card (Zaprite), prepaid 30-day periods, expiry-reminder emails - Core-decoupling: relay owns cloud tier/expiry keyed by Recaps user-id - SQLite (better-sqlite3) schema for multi-mode; filesystem unchanged for single - StartOS actions/versions through 0.2.155
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
// Magic-link email body builder. Returns { subject, text, html } for
|
||||
// nodemailer. Keeps the HTML and text in sync — both carry the same
|
||||
// verifyUrl and the same expiry copy.
|
||||
//
|
||||
// Style is deliberately minimal: one paragraph, one button, no images,
|
||||
// no fancy CSS. Spam filters like simple emails; users skim them and
|
||||
// click the link. Anything fancier risks the email landing in spam,
|
||||
// which is fatal to a magic-link auth flow.
|
||||
|
||||
// renderMagicLinkEmail({ verifyUrl, brandName, expiresInMinutes })
|
||||
// → { subject, text, html }
|
||||
export function renderMagicLinkEmail({
|
||||
verifyUrl,
|
||||
brandName = "Recaps",
|
||||
expiresInMinutes = 15,
|
||||
}) {
|
||||
const subject = `Sign in to ${brandName}`;
|
||||
|
||||
const text = [
|
||||
`Sign in to ${brandName} by opening this link:`,
|
||||
"",
|
||||
verifyUrl,
|
||||
"",
|
||||
`This link expires in ${expiresInMinutes} minutes and can only be used once.`,
|
||||
"",
|
||||
`If you didn't request this, you can safely ignore this email — no one else can use this link without access to your inbox.`,
|
||||
].join("\n");
|
||||
|
||||
// Inline-styled HTML. Most email clients strip <style> blocks, so
|
||||
// everything that needs to look right has to be inline.
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:16px;">
|
||||
Sign in to ${escapeHtml(brandName)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444;padding-bottom:24px;">
|
||||
Click the button below to sign in. This link expires in ${expiresInMinutes} minutes and can only be used once.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<a href="${escapeAttr(verifyUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">Sign in</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:13px;line-height:1.5;color:#888;padding-bottom:8px;">
|
||||
Or copy and paste this URL into your browser:
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#888;word-break:break-all;padding-bottom:24px;">
|
||||
${escapeHtml(verifyUrl)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||
If you didn't request this, you can safely ignore this email — no one can use this link without access to your inbox.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
// renderSubscriptionReminderEmail({ brandName, tier, expiresAt, daysLeft,
|
||||
// kind, manageUrl }) → { subject, text, html }
|
||||
// kind: 'upcoming_7d' | 'upcoming_1d' | 'lapsed'. Same minimal,
|
||||
// spam-filter-friendly style as the magic-link email: one message, one
|
||||
// button. `expiresAt` is an ISO string or Date; `daysLeft` is a number
|
||||
// (<= 0 means already expired).
|
||||
export function renderSubscriptionReminderEmail({
|
||||
brandName = "Recaps",
|
||||
tier = "pro",
|
||||
expiresAt,
|
||||
daysLeft = 0,
|
||||
kind = "upcoming_7d",
|
||||
manageUrl,
|
||||
}) {
|
||||
const tierLabel = tier === "max" ? "Max" : "Pro";
|
||||
const lapsed = kind === "lapsed";
|
||||
let when;
|
||||
if (lapsed) when = "has expired";
|
||||
else if (daysLeft <= 1) when = "expires tomorrow";
|
||||
else when = `expires in ${daysLeft} days`;
|
||||
|
||||
let expiryDateStr = "";
|
||||
try {
|
||||
const d = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
expiryDateStr = d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const subject = lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}`;
|
||||
|
||||
const lead = lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired${expiryDateStr ? ` (${expiryDateStr})` : ""}, so your account is back on the free Core tier. Renew anytime to restore ${tierLabel} — it's a one-time payment, no auto-charges.`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}${expiryDateStr ? ` (${expiryDateStr})` : ""}. Renew to keep your ${tierLabel} features — it's a one-time payment for another period, no auto-charges.`;
|
||||
|
||||
const cta = lapsed ? `Renew ${tierLabel}` : `Renew now`;
|
||||
|
||||
const text = [
|
||||
lapsed
|
||||
? `Your ${brandName} ${tierLabel} plan has expired.`
|
||||
: `Your ${brandName} ${tierLabel} plan ${when}.`,
|
||||
"",
|
||||
lead,
|
||||
"",
|
||||
`${cta}: ${manageUrl}`,
|
||||
"",
|
||||
`You're receiving this because you have a ${brandName} account. Prepaid plans never auto-renew — you're only charged when you choose to.`,
|
||||
].join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#fafafa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#fafafa;padding:32px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;padding:32px;max-width:90%;">
|
||||
<tr>
|
||||
<td style="font-size:18px;font-weight:600;color:#111;padding-bottom:16px;">
|
||||
Your ${escapeHtml(brandName)} ${escapeHtml(tierLabel)} plan ${escapeHtml(lapsed ? "has expired" : when)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:15px;line-height:1.5;color:#444;padding-bottom:24px;">
|
||||
${escapeHtml(lead)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:24px;">
|
||||
<a href="${escapeAttr(manageUrl)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-size:15px;font-weight:500;padding:12px 24px;border-radius:6px;">${escapeHtml(cta)}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-size:12px;line-height:1.5;color:#888;border-top:1px solid #eee;padding-top:16px;">
|
||||
You're receiving this because you have a ${escapeHtml(brandName)} account. Prepaid plans never auto-renew — you're only charged when you choose to.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { subject, text, html };
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return escapeHtml(s);
|
||||
}
|
||||
Reference in New Issue
Block a user