334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
import { resolveTransactionalBrandTokens } from "@hivegrid/brand";
|
|
import { defaultLocale, normalizeLocaleCandidate } from "@hivegrid/i18n";
|
|
import { buildBrand, renderTransactionalEmail, type MailBrand, type MailFooterCopy, type MailLanguage } from "@hivegrid/mail";
|
|
import type { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
|
import { localizedMailChrome } from "./LocalizedMailChrome";
|
|
|
|
/**
|
|
* Legacy template identifiers preserved for downstream call-sites.
|
|
* The React-Email / 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;
|
|
/** Mail namespace translator used for content and shared mail chrome. */
|
|
t?: MailTranslator;
|
|
/** User/request locale normalized to the supported mail document languages. */
|
|
locale?: string;
|
|
};
|
|
|
|
type EmailTemplate<TData extends EmailTemplateData = EmailTemplateData> = {
|
|
name: EmailTemplateName;
|
|
description: string;
|
|
describe: (t?: MailTranslator) => string;
|
|
parse: (input: EmailTemplateParseInput<TData>) => {
|
|
subject: string;
|
|
body: string;
|
|
text: 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),
|
|
};
|
|
}
|
|
|
|
function normalizeMailLanguage(locale?: string): MailLanguage {
|
|
return normalizeLocaleCandidate(locale) ?? defaultLocale;
|
|
}
|
|
|
|
function mailChrome(
|
|
t: MailTranslator | undefined,
|
|
locale?: string
|
|
): {
|
|
language: MailLanguage;
|
|
footerCopy: MailFooterCopy;
|
|
} {
|
|
if (t) return localizedMailChrome(t, locale);
|
|
|
|
const language = normalizeMailLanguage(locale);
|
|
const fallback = {
|
|
de: {
|
|
privacyLabel: "Datenschutz",
|
|
termsLabel: "AGB",
|
|
imprintLabel: "Impressum",
|
|
unsubscribeLabel: "Abmelden",
|
|
supportBefore: "Du hast Fragen? Dann",
|
|
supportLinkLabel: "schreib uns gerne",
|
|
supportAfter: ".",
|
|
signoffClosing: "Viele Grüße",
|
|
signoffTeam: "Dein HiveGrid-Team",
|
|
},
|
|
en: {
|
|
privacyLabel: "Privacy",
|
|
termsLabel: "Terms",
|
|
imprintLabel: "Imprint",
|
|
unsubscribeLabel: "Unsubscribe",
|
|
supportBefore: "Questions? Feel free to",
|
|
supportLinkLabel: "email us",
|
|
supportAfter: ".",
|
|
signoffClosing: "Kind regards,",
|
|
signoffTeam: "Your HiveGrid team",
|
|
},
|
|
pl: {
|
|
privacyLabel: "Polityka prywatności",
|
|
termsLabel: "Regulamin",
|
|
imprintLabel: "Nota prawna",
|
|
unsubscribeLabel: "Zrezygnuj z subskrypcji",
|
|
supportBefore: "Masz pytania?",
|
|
supportLinkLabel: "Napisz do nas",
|
|
supportAfter: ".",
|
|
signoffClosing: "Pozdrawiamy,",
|
|
signoffTeam: "Zespół HiveGrid",
|
|
},
|
|
ru: {
|
|
privacyLabel: "Конфиденциальность",
|
|
termsLabel: "Условия",
|
|
imprintLabel: "Правовая информация",
|
|
unsubscribeLabel: "Отписаться",
|
|
supportBefore: "Есть вопросы?",
|
|
supportLinkLabel: "Напишите нам",
|
|
supportAfter: ".",
|
|
signoffClosing: "С наилучшими пожеланиями,",
|
|
signoffTeam: "Команда HiveGrid",
|
|
},
|
|
uk: {
|
|
privacyLabel: "Конфіденційність",
|
|
termsLabel: "Умови",
|
|
imprintLabel: "Правова інформація",
|
|
unsubscribeLabel: "Відписатися",
|
|
supportBefore: "Є запитання?",
|
|
supportLinkLabel: "Напишіть нам",
|
|
supportAfter: ".",
|
|
signoffClosing: "З найкращими побажаннями,",
|
|
signoffTeam: "Команда HiveGrid",
|
|
},
|
|
hu: {
|
|
privacyLabel: "Adatvédelem",
|
|
termsLabel: "ÁSZF",
|
|
imprintLabel: "Impresszum",
|
|
unsubscribeLabel: "Leiratkozás",
|
|
supportBefore: "Kérdésed van?",
|
|
supportLinkLabel: "Írj nekünk",
|
|
supportAfter: ".",
|
|
signoffClosing: "Üdvözlettel,",
|
|
signoffTeam: "A HiveGrid csapata",
|
|
},
|
|
}[language];
|
|
|
|
return {
|
|
language,
|
|
footerCopy: {
|
|
privacyLabel: mailText(t, "common.legal.privacyLabel", fallback.privacyLabel),
|
|
termsLabel: mailText(t, "common.legal.termsLabel", fallback.termsLabel),
|
|
imprintLabel: mailText(t, "common.legal.imprintLabel", fallback.imprintLabel),
|
|
unsubscribeLabel: mailText(t, "common.legal.unsubscribeLabel", fallback.unsubscribeLabel),
|
|
supportBefore: mailText(t, "common.support.htmlBefore", fallback.supportBefore),
|
|
supportLinkLabel: mailText(t, "common.support.emailLinkLabel", fallback.supportLinkLabel),
|
|
supportAfter: mailText(t, "common.support.htmlAfter", fallback.supportAfter),
|
|
signoffClosing: mailText(t, "common.signoff.closing", fallback.signoffClosing),
|
|
signoffTeam: mailText(t, "common.signoff.team", fallback.signoffTeam),
|
|
},
|
|
};
|
|
}
|
|
|
|
const WELCOME_EMAIL: EmailTemplate<{ name?: string; action_url: string }> = {
|
|
name: "welcome",
|
|
...description("registry.welcomeDescription", "Welcome email with setup link"),
|
|
parse: ({ appConfiguration, data, t, locale }) => {
|
|
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, text } = renderTransactionalEmail("welcome", {
|
|
brand,
|
|
...mailChrome(t, locale),
|
|
recipientName: data.name,
|
|
actionUrl: data.action_url,
|
|
subject,
|
|
eyebrow: mailText(t, "welcome.eyebrow", "Welcome"),
|
|
title: data.name
|
|
? mailText(t, "welcome.title_named", "Great to have you here, {{name}}", { name: data.name })
|
|
: mailText(t, "welcome.title", "Great to have you here"),
|
|
intro: mailText(t, "welcome.intro", "Your {{productName}} account has been created. Sign in to review your plan and finish setup. Booked features unlock after payment is confirmed.", {
|
|
productName: brand.productName,
|
|
}),
|
|
actionLabel: mailText(t, "welcome.actionLabel", "Continue setup"),
|
|
});
|
|
return { subject, body: html, text, 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, locale }) => {
|
|
const brand = brandFor(appConfiguration);
|
|
const subject = mailText(t, "passwordReset.subject", "Reset your {{productName}} password", { productName: brand.productName });
|
|
const { html, text } = renderTransactionalEmail("password-reset", {
|
|
brand,
|
|
...mailChrome(t, locale),
|
|
recipientName: data.name,
|
|
actionUrl: data.action_url,
|
|
subject,
|
|
eyebrow: mailText(t, "passwordReset.eyebrow", "Password reset"),
|
|
title: data.name
|
|
? mailText(t, "passwordReset.title_named", "Choose a new password, {{name}}", { name: data.name })
|
|
: mailText(t, "passwordReset.title", "Choose a new password"),
|
|
intro: mailText(t, "passwordReset.intro", "We received a request to reset the password for your {{productName}} account.", {
|
|
productName: brand.productName,
|
|
}),
|
|
actionLabel: mailText(t, "passwordReset.actionLabel", "Choose a new password"),
|
|
outro: mailText(t, "passwordReset.outro", "If you did not make this request, you can ignore this email. Your current password will remain unchanged."),
|
|
});
|
|
return { subject, body: html, text, 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, locale }) => {
|
|
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}} in {{productName}}", vars);
|
|
const { html, text } = renderTransactionalEmail("invitation", {
|
|
brand,
|
|
...mailChrome(t, locale),
|
|
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", "{{name}}, you have been invited", vars)
|
|
: mailText(t, "userInvitation.title", "An invitation to collaborate"),
|
|
intro: mailText(t, "userInvitation.intro", "{{senderName}} invited you to collaborate with {{senderOrganization}} in {{productName}}.", vars),
|
|
actionLabel: mailText(t, "userInvitation.actionLabel", "Join {{senderOrganization}}", vars),
|
|
});
|
|
return { subject, body: html, text, 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, locale }) => {
|
|
const brand = brandFor(appConfiguration);
|
|
const vars = { productName: brand.productName, name: data.name };
|
|
const subject = mailText(t, "emailVerification.subject", "Please confirm your email address", vars);
|
|
const { html, text } = renderTransactionalEmail("magic-link", {
|
|
brand,
|
|
...mailChrome(t, locale),
|
|
recipientName: data.name,
|
|
actionUrl: data.action_url,
|
|
subject,
|
|
previewText: mailText(t, "emailVerification.previewText", "Verify your email address with {{productName}}", vars),
|
|
eyebrow: mailText(t, "emailVerification.eyebrow", "Email verification"),
|
|
title: data.name ? mailText(t, "emailVerification.title_named", "Almost there, {{name}}", vars) : mailText(t, "emailVerification.title", "Almost there"),
|
|
intro: mailText(
|
|
t,
|
|
"emailVerification.intro",
|
|
"Confirm your email address. Then set your password and review your plan, add-ons, and payment method. Features unlock only after payment is confirmed.",
|
|
vars
|
|
),
|
|
actionLabel: mailText(t, "emailVerification.actionLabel", "Confirm email address"),
|
|
outro: mailText(t, "emailVerification.outro", "If you did not request this confirmation, you can safely ignore this email."),
|
|
expiresText: mailText(t, "emailVerification.expiresText", "This confirmation link is valid for {{expiresInHours}} hours and can only be used once.", {
|
|
expiresInHours: 24,
|
|
}),
|
|
expiresInMinutes: 24 * 60,
|
|
});
|
|
return { subject, body: html, text, 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, locale }) => {
|
|
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, text } = renderTransactionalEmail("welcome", {
|
|
brand,
|
|
...mailChrome(t, locale),
|
|
actionUrl: data.action_url,
|
|
subject,
|
|
eyebrow: mailText(t, "accountSetup.eyebrow", "Account setup"),
|
|
title: mailText(t, "accountSetup.title", "Your account is waiting for you", vars),
|
|
intro: mailText(t, "accountSetup.intro", "Your {{plan}} access for {{email}} is ready. Choose your password and start using {{productName}}.", vars),
|
|
actionLabel: mailText(t, "accountSetup.actionLabel", "Finish account setup"),
|
|
});
|
|
return { subject, body: html, text, 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,
|
|
};
|