Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultBrandTokens } from "@toolctrl/brand";
|
||||
import EmailTemplates from "./EmailTemplates";
|
||||
import type { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
|
||||
const appConfiguration = {
|
||||
app: { name: "Acme Portal" },
|
||||
email: { fromName: "Acme Support", supportEmail: "help@acme.test" },
|
||||
branding: { logo: "https://cdn.acme.test/logo.png", favicon: "https://cdn.acme.test/favicon.ico" },
|
||||
} as AppConfiguration;
|
||||
|
||||
const actionUrl = "https://app.acme.test/action";
|
||||
|
||||
describe("EmailTemplates", () => {
|
||||
it("keeps the exported template registry stable", () => {
|
||||
expect(EmailTemplates.allTemplates.map((template) => template.name)).toEqual([
|
||||
"welcome",
|
||||
"password-reset",
|
||||
"user-invitation",
|
||||
"email-verification",
|
||||
"account-setup",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders welcome emails through the React-Email pipeline", () => {
|
||||
const rendered = EmailTemplates.WELCOME_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { name: "Ada", action_url: actionUrl },
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe("Welcome to Acme Portal, Ada!");
|
||||
expect(rendered.body).toContain(actionUrl);
|
||||
expect(rendered.body).toContain("Acme Portal");
|
||||
expect(rendered.body.startsWith("<!DOCTYPE html>")).toBe(true);
|
||||
expect(rendered.data).toEqual({ name: "Ada", action_url: actionUrl });
|
||||
});
|
||||
|
||||
it("renders password reset emails with brand-aware subject", () => {
|
||||
const rendered = EmailTemplates.PASSWORD_RESET_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { name: "Ada", action_url: actionUrl },
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe("Reset your Acme Portal password");
|
||||
expect(rendered.body).toContain(actionUrl);
|
||||
expect(rendered.body).toContain("Acme Portal");
|
||||
});
|
||||
|
||||
it("renders user invitation emails with sender and organization data", () => {
|
||||
const rendered = EmailTemplates.USER_INVITATION_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: {
|
||||
name: "Ada",
|
||||
invite_sender_name: "Grace",
|
||||
invite_sender_organization: "Example Org",
|
||||
action_url: actionUrl,
|
||||
},
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe("Grace invited you to Example Org on Acme Portal");
|
||||
expect(rendered.body).toContain("Grace");
|
||||
expect(rendered.body).toContain("Example Org");
|
||||
expect(rendered.body).toContain(actionUrl);
|
||||
});
|
||||
|
||||
it("renders email verification emails via the magic-link template", () => {
|
||||
const rendered = EmailTemplates.VERIFICATION_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { name: "Ada", action_url: actionUrl },
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe("Verify your Acme Portal email address");
|
||||
expect(rendered.body).toContain(actionUrl);
|
||||
expect(rendered.body).toMatch(/expires? in 30 minutes/i);
|
||||
});
|
||||
|
||||
it("renders account setup emails via the welcome template with plan-aware copy", () => {
|
||||
const rendered = EmailTemplates.ACCOUNT_SETUP_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { email: "ada@example.com", plan: "Pro", action_url: actionUrl },
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe("Finish setting up your Acme Portal account");
|
||||
expect(rendered.body).toContain("Pro plan");
|
||||
expect(rendered.body).toContain("ada@example.com");
|
||||
expect(rendered.body).toContain(actionUrl);
|
||||
});
|
||||
|
||||
it("falls back to default brand tokens when app configuration omits brand values", () => {
|
||||
const rendered = EmailTemplates.WELCOME_EMAIL.parse({
|
||||
appConfiguration: {} as AppConfiguration,
|
||||
data: { action_url: actionUrl },
|
||||
});
|
||||
|
||||
expect(rendered.subject).toBe(`Welcome to ${defaultBrandTokens.productName}!`);
|
||||
expect(rendered.body).toContain(defaultBrandTokens.productName);
|
||||
});
|
||||
|
||||
it("uses the optional translator for subject and body chrome", () => {
|
||||
const baseline = EmailTemplates.WELCOME_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { name: "Ada", action_url: actionUrl },
|
||||
});
|
||||
const withTranslator = EmailTemplates.WELCOME_EMAIL.parse({
|
||||
appConfiguration,
|
||||
data: { name: "Ada", action_url: actionUrl },
|
||||
t: (key, vars) => {
|
||||
const values: Record<string, string> = {
|
||||
"welcome.subject_named": `Willkommen bei ${vars?.productName}, ${vars?.name}!`,
|
||||
"welcome.eyebrow": "Willkommen",
|
||||
"welcome.title_named": `Hallo ${vars?.name}!`,
|
||||
"welcome.intro": `Danke fuer ${vars?.productName}.`,
|
||||
"welcome.actionLabel": "Einloggen",
|
||||
"common.signoff.thanks": "Danke,",
|
||||
};
|
||||
return values[key] ?? key;
|
||||
},
|
||||
locale: "de",
|
||||
});
|
||||
expect(withTranslator.subject).toBe("Willkommen bei Acme Portal, Ada!");
|
||||
expect(withTranslator.body).toContain("Hallo Ada!");
|
||||
expect(withTranslator.body).toContain("Einloggen");
|
||||
expect(withTranslator.body).toContain("Danke,");
|
||||
expect(withTranslator.body).not.toEqual(baseline.body);
|
||||
});
|
||||
|
||||
it("localizes registry descriptions when a mail translator is available", () => {
|
||||
const t = (key: string) => (key === "registry.welcomeDescription" ? "Lokalisierte Willkommensbeschreibung" : key);
|
||||
expect(EmailTemplates.WELCOME_EMAIL.describe(t)).toBe("Lokalisierte Willkommensbeschreibung");
|
||||
expect(EmailTemplates.WELCOME_EMAIL.description).toBe("Welcome email with login link");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { resolveTransactionalBrandTokens } from "@toolctrl/brand";
|
||||
import { buildBrand, renderTransactionalEmail, type MailBrand } from "@toolctrl/mail";
|
||||
import { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
|
||||
/**
|
||||
* Legacy template identifiers preserved for downstream call-sites.
|
||||
* The Stripe-19 / React-Email migration consolidates these onto the
|
||||
* React-Email pipeline:
|
||||
*
|
||||
* - `welcome` -> React `welcome`
|
||||
* - `password-reset` -> React `password-reset`
|
||||
* - `user-invitation` -> React `invitation`
|
||||
* - `email-verification` -> React `magic-link` (verification variant)
|
||||
* - `account-setup` -> React `welcome` (account-setup variant)
|
||||
*/
|
||||
export type EmailTemplateName = "welcome" | "password-reset" | "user-invitation" | "email-verification" | "account-setup";
|
||||
|
||||
export type EmailTemplateData = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Mail-side translator signature retained for call-site compatibility.
|
||||
* Legacy callers pass `t` + `locale` from `getMailTranslator`; the wrapper
|
||||
* now uses that hook for subject/body chrome while keeping English fallback
|
||||
* strings for tests and non-request call-sites.
|
||||
*/
|
||||
export type MailTranslator = (key: string, vars?: Record<string, unknown>) => string;
|
||||
|
||||
export type EmailTemplateParseInput<TData extends EmailTemplateData = EmailTemplateData> = {
|
||||
appConfiguration: AppConfiguration;
|
||||
data: TData;
|
||||
/** Reserved for future i18n hooks. Currently a no-op. */
|
||||
t?: MailTranslator;
|
||||
/** User/request locale (informational; reserved for future i18n hooks). */
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
type EmailTemplate<TData extends EmailTemplateData = EmailTemplateData> = {
|
||||
name: EmailTemplateName;
|
||||
description: string;
|
||||
describe: (t?: MailTranslator) => string;
|
||||
parse: (input: EmailTemplateParseInput<TData>) => {
|
||||
subject: string;
|
||||
body: string;
|
||||
data: TData;
|
||||
};
|
||||
};
|
||||
|
||||
function toBrandConfiguration(appConfiguration: AppConfiguration) {
|
||||
return {
|
||||
productName: appConfiguration.app?.name,
|
||||
supportEmail: appConfiguration.email?.supportEmail,
|
||||
supportFromName: appConfiguration.email?.fromName,
|
||||
emailFromName: appConfiguration.email?.fromName,
|
||||
logoUrl: appConfiguration.branding?.logo,
|
||||
faviconUrl: appConfiguration.branding?.favicon,
|
||||
};
|
||||
}
|
||||
|
||||
function brandFor(appConfiguration: AppConfiguration): MailBrand {
|
||||
return buildBrand(resolveTransactionalBrandTokens(toBrandConfiguration(appConfiguration)));
|
||||
}
|
||||
|
||||
function interpolate(template: string, vars: Record<string, unknown> = {}) {
|
||||
return template.replace(/\{\{\s*([.\w]+)\s*\}\}/g, (_match, key: string) => String(vars[key] ?? ""));
|
||||
}
|
||||
|
||||
function mailText(t: MailTranslator | undefined, key: string, fallback: string, vars?: Record<string, unknown>) {
|
||||
const translated = t?.(key, vars);
|
||||
return translated && translated !== key ? translated : interpolate(fallback, vars);
|
||||
}
|
||||
|
||||
function description(key: string, fallback: string) {
|
||||
return {
|
||||
description: mailText(undefined, key, fallback),
|
||||
describe: (t?: MailTranslator) => mailText(t, key, fallback),
|
||||
};
|
||||
}
|
||||
|
||||
const WELCOME_EMAIL: EmailTemplate<{ name?: string; action_url: string }> = {
|
||||
name: "welcome",
|
||||
...description("registry.welcomeDescription", "Welcome email with login link"),
|
||||
parse: ({ appConfiguration, data, t }) => {
|
||||
const brand = brandFor(appConfiguration);
|
||||
const subject = data.name
|
||||
? mailText(t, "welcome.subject_named", "Welcome to {{productName}}, {{name}}!", { productName: brand.productName, name: data.name })
|
||||
: mailText(t, "welcome.subject", "Welcome to {{productName}}!", { productName: brand.productName });
|
||||
const { html } = renderTransactionalEmail("welcome", {
|
||||
brand,
|
||||
recipientName: data.name,
|
||||
actionUrl: data.action_url,
|
||||
subject,
|
||||
eyebrow: mailText(t, "welcome.eyebrow", "Welcome"),
|
||||
title: data.name
|
||||
? mailText(t, "welcome.title_named", "Welcome, {{name}}!", { name: data.name })
|
||||
: mailText(t, "welcome.title", "Welcome!"),
|
||||
intro: mailText(t, "welcome.intro", "Thanks for trying {{productName}}.", { productName: brand.productName }),
|
||||
actionLabel: mailText(t, "welcome.actionLabel", "Click here to log in"),
|
||||
signoffThanks: mailText(t, "common.signoff.thanks", "Thanks,"),
|
||||
});
|
||||
return { subject, body: html, data };
|
||||
},
|
||||
};
|
||||
|
||||
const PASSWORD_RESET_EMAIL: EmailTemplate<{ name?: string; action_url: string }> = {
|
||||
name: "password-reset",
|
||||
...description("registry.passwordResetDescription", "Email sent to users who requested a password reset"),
|
||||
parse: ({ appConfiguration, data, t }) => {
|
||||
const brand = brandFor(appConfiguration);
|
||||
const subject = mailText(t, "passwordReset.subject", "Reset your {{productName}} password", { productName: brand.productName });
|
||||
const { html } = renderTransactionalEmail("password-reset", {
|
||||
brand,
|
||||
recipientName: data.name,
|
||||
actionUrl: data.action_url,
|
||||
subject,
|
||||
eyebrow: mailText(t, "passwordReset.eyebrow", "Password reset"),
|
||||
title: data.name
|
||||
? mailText(t, "passwordReset.title_named", "Hello, {{name}}!", { name: data.name })
|
||||
: mailText(t, "passwordReset.title", "Hello!"),
|
||||
intro: mailText(t, "passwordReset.intro", "You recently requested to reset your password for your {{productName}} account.", {
|
||||
productName: brand.productName,
|
||||
}),
|
||||
actionLabel: mailText(t, "passwordReset.actionLabel", "Reset password"),
|
||||
outro: mailText(
|
||||
t,
|
||||
"passwordReset.outro",
|
||||
"If you did not request a password reset, please ignore this email or contact support if you have questions."
|
||||
),
|
||||
signoffThanks: mailText(t, "common.signoff.thanks", "Thanks,"),
|
||||
});
|
||||
return { subject, body: html, data };
|
||||
},
|
||||
};
|
||||
|
||||
const USER_INVITATION_EMAIL: EmailTemplate<{
|
||||
name?: string;
|
||||
invite_sender_name: string;
|
||||
invite_sender_organization: string;
|
||||
action_url: string;
|
||||
}> = {
|
||||
name: "user-invitation",
|
||||
...description("registry.userInvitationDescription", "Email sent to users who are invited to join an organization"),
|
||||
parse: ({ appConfiguration, data, t }) => {
|
||||
const brand = brandFor(appConfiguration);
|
||||
const vars = {
|
||||
productName: brand.productName,
|
||||
senderName: data.invite_sender_name,
|
||||
senderOrganization: data.invite_sender_organization,
|
||||
name: data.name,
|
||||
};
|
||||
const subject = mailText(t, "userInvitation.subject", "{{senderName}} invited you to {{senderOrganization}} on {{productName}}", vars);
|
||||
const { html } = renderTransactionalEmail("invitation", {
|
||||
brand,
|
||||
recipientName: data.name,
|
||||
senderName: data.invite_sender_name,
|
||||
senderOrganization: data.invite_sender_organization,
|
||||
actionUrl: data.action_url,
|
||||
subject,
|
||||
eyebrow: mailText(t, "userInvitation.eyebrow", "Invitation"),
|
||||
title: data.name ? mailText(t, "userInvitation.title_named", "Hello, {{name}}!", vars) : mailText(t, "userInvitation.title", "Hello!"),
|
||||
intro: mailText(
|
||||
t,
|
||||
"userInvitation.intro",
|
||||
"{{senderName}} with {{senderOrganization}} has invited you to use {{productName}} to collaborate with them.",
|
||||
vars
|
||||
),
|
||||
actionLabel: mailText(t, "userInvitation.actionLabel", "Join {{senderOrganization}}", vars),
|
||||
signoffThanks: mailText(t, "common.signoff.thanks", "Thanks,"),
|
||||
});
|
||||
return { subject, body: html, data };
|
||||
},
|
||||
};
|
||||
|
||||
const VERIFICATION_EMAIL: EmailTemplate<{ name?: string; action_url: string }> = {
|
||||
name: "email-verification",
|
||||
...description("registry.emailVerificationDescription", "Email sent to users to verify their email address"),
|
||||
parse: ({ appConfiguration, data, t }) => {
|
||||
const brand = brandFor(appConfiguration);
|
||||
const vars = { productName: brand.productName, name: data.name };
|
||||
const subject = mailText(t, "emailVerification.subject", "Verify your {{productName}} email address", vars);
|
||||
const { html } = renderTransactionalEmail("magic-link", {
|
||||
brand,
|
||||
recipientName: data.name,
|
||||
actionUrl: data.action_url,
|
||||
subject,
|
||||
eyebrow: mailText(t, "emailVerification.eyebrow", "Email verification"),
|
||||
title: data.name
|
||||
? mailText(t, "emailVerification.title_named", "Thanks for signing up, {{name}}!", vars)
|
||||
: mailText(t, "emailVerification.title", "Thanks for signing up!"),
|
||||
intro: mailText(t, "emailVerification.intro", "Confirm your email address to finish setting up your {{productName}} account.", vars),
|
||||
actionLabel: mailText(t, "emailVerification.actionLabel", "Click here to verify your email"),
|
||||
expiresInMinutes: 30,
|
||||
signoffThanks: mailText(t, "common.signoff.thanks", "Thanks,"),
|
||||
});
|
||||
return { subject, body: html, data };
|
||||
},
|
||||
};
|
||||
|
||||
const ACCOUNT_SETUP_EMAIL: EmailTemplate<{ email: string; plan: string; action_url: string }> = {
|
||||
name: "account-setup",
|
||||
...description("registry.accountSetupDescription", "Email sent to users to set up their account"),
|
||||
parse: ({ appConfiguration, data, t }) => {
|
||||
const brand = brandFor(appConfiguration);
|
||||
const vars = { productName: brand.productName, plan: data.plan, email: data.email };
|
||||
const subject = mailText(t, "accountSetup.subject", "Finish setting up your {{productName}} account", vars);
|
||||
const { html } = renderTransactionalEmail("welcome", {
|
||||
brand,
|
||||
actionUrl: data.action_url,
|
||||
subject,
|
||||
eyebrow: mailText(t, "accountSetup.eyebrow", "Account setup"),
|
||||
title: mailText(t, "accountSetup.title", "Almost there - let's get you started", vars),
|
||||
intro: mailText(
|
||||
t,
|
||||
"accountSetup.intro",
|
||||
"Thanks for subscribing to the {{plan}} plan. Use the button below to finish setting up your account for {{email}}.",
|
||||
vars
|
||||
),
|
||||
actionLabel: mailText(t, "accountSetup.actionLabel", "Set up account"),
|
||||
signoffThanks: mailText(t, "common.signoff.thanks", "Thanks,"),
|
||||
});
|
||||
return { subject, body: html, data };
|
||||
},
|
||||
};
|
||||
|
||||
const allTemplates = [
|
||||
WELCOME_EMAIL,
|
||||
PASSWORD_RESET_EMAIL,
|
||||
USER_INVITATION_EMAIL,
|
||||
VERIFICATION_EMAIL,
|
||||
ACCOUNT_SETUP_EMAIL,
|
||||
];
|
||||
|
||||
export default {
|
||||
allTemplates,
|
||||
WELCOME_EMAIL,
|
||||
PASSWORD_RESET_EMAIL,
|
||||
USER_INVITATION_EMAIL,
|
||||
VERIFICATION_EMAIL,
|
||||
ACCOUNT_SETUP_EMAIL,
|
||||
};
|
||||
Reference in new issue
Block a user