Skip to content
Open
54 changes: 27 additions & 27 deletions apps/web/src/emails/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,30 +61,30 @@ Every template must include this branding footer below the content table:

## Template Variables

| Template file | Variables | Customer.io ID (crosswalk) |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `orgSubscription.html` | `seats`, `organization_url`, `invoices_url`, `year` | `10` |
| `orgRenewed.html` | `seats`, `invoices_url`, `year` | `11` |
| `orgCancelled.html` | `invoices_url`, `year` | `12` |
| `orgSSOUserJoined.html` | `new_user_email`, `organization_url`, `year` | `13` |
| `orgInvitation.html` | `organization_name`, `inviter_name`, `accept_invite_url`, `year` | `6` |
| `magicLink.html` | `magic_link_url`, `email`, `expires_in`, `year` | `14` |
| `balanceAlert.html` | `minimum_balance`, `organization_url`, `year` | `16` |
| `autoTopUpFailed.html` | `reason`, `credits_url`, `year` | `17` |
| `ossInviteNewUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `accept_invite_url`, `integrations_url`, `code_reviews_url`, `year` | `18` |
| `ossInviteExistingUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `19` |
| `ossExistingOrgProvisioned.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `20` |
| `deployFailed.html` | `deployment_name`, `deployment_url`, `repository`, `year` | `21` |
| `clawTrialEndingSoon.html` | `days_remaining`, `claw_url`, `year` | `22` |
| `clawTrialExpiresTomorrow.html` | `claw_url`, `year` | `23` |
| `clawSuspendedTrial.html` | `destruction_date`, `claw_url`, `year` | `24` |
| `clawSuspendedSubscription.html` | `destruction_date`, `claw_url`, `year` | `25` |
| `clawSuspendedPayment.html` | `destruction_date`, `claw_url`, `year` | `26` |
| `clawDestructionWarning.html` | `destruction_date`, `claw_url`, `year` | `27` |
| `clawInstanceDestroyed.html` | `claw_url`, `year` | `28` |
| `clawEarlybirdEndingSoon.html` | `days_remaining`, `expiry_date`, `claw_url`, `year` | `29` |
| `clawEarlybirdExpiresTomorrow.html` | `expiry_date`, `claw_url`, `year` | `30` |
| `clawComplementaryInferenceEnded.html` | `claw_url`, `year` | — |
| `accountDeletionRequest.html` | `email`, `year` | — |
| `creditsTopUp.html` | `heading`, `intro`, `amount_usd`, `credits_usd`, `purchase_date`, `credits_url`, `receipt_section`, `year` | — |
| `kiloClawSubscriptionStarted.html` | `plan_name`, `price_usd`, `billing_period`, `next_billing_date`, `manage_url`, `year` | — |
| Template file | Variables | Customer.io ID (crosswalk) |
| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
| `orgSubscription.html` | `seats`, `organization_url`, `invoices_url`, `year` | `10` |
| `orgRenewed.html` | `seats`, `invoices_url`, `year` | `11` |
| `orgCancelled.html` | `invoices_url`, `year` | `12` |
| `orgSSOUserJoined.html` | `new_user_email`, `organization_url`, `year` | `13` |
| `orgInvitation.html` | `organization_name`, `inviter_name`, `accept_invite_url`, `year` | `6` |
| `magicLink.html` | `magic_link_url`, `email`, `expires_in`, `year` | `14` |
| `balanceAlert.html` | `minimum_balance`, `organization_url`, `year` | `16` |
| `autoTopUpFailed.html` | `reason`, `credits_url`, `year` | `17` |
| `ossInviteNewUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `accept_invite_url`, `integrations_url`, `code_reviews_url`, `year` | `18` |
| `ossInviteExistingUser.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `19` |
| `ossExistingOrgProvisioned.html` | `tier_name`, `seats`, `seat_value`, `credits_section`, `organization_url`, `integrations_url`, `code_reviews_url`, `year` | `20` |
| `deployFailed.html` | `deployment_name`, `deployment_url`, `repository`, `year` | `21` |
| `clawTrialEndingSoon.html` | `days_remaining`, `claw_url`, `year` | `22` |
| `clawTrialExpiresTomorrow.html` | `claw_url`, `year` | `23` |
| `clawSuspendedTrial.html` | `destruction_date`, `claw_url`, `year` | `24` |
| `clawSuspendedSubscription.html` | `destruction_date`, `claw_url`, `year` | `25` |
| `clawSuspendedPayment.html` | `destruction_date`, `claw_url`, `year` | `26` |
| `clawDestructionWarning.html` | `destruction_date`, `claw_url`, `year` | `27` |
| `clawInstanceDestroyed.html` | `claw_url`, `year` | `28` |
| `clawEarlybirdEndingSoon.html` | `days_remaining`, `expiry_date`, `claw_url`, `year` | `29` |
| `clawEarlybirdExpiresTomorrow.html` | `expiry_date`, `claw_url`, `year` | `30` |
| `clawComplementaryInferenceEnded.html` | `claw_url`, `year` | — |
| `accountDeletionRequest.html` | `email`, `year` | — |
| `creditsTopUp.html` | `heading`, `intro`, `amount_usd`, `credits_usd`, `purchase_date`, `credits_url`, `receipt_section`, `year`. Org variants render org-specific copy into `intro` before template rendering; when provided, the organization name is interpolated there rather than passed as a separate template variable. | — |
| `kiloClawSubscriptionStarted.html` | `plan_name`, `price_usd`, `billing_period`, `next_billing_date`, `manage_url`, `year` | — |
55 changes: 48 additions & 7 deletions apps/web/src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,30 +396,62 @@ export async function sendAccountDeletionSupportNotification(

const CREDITS_TOPUP_COPY = {
manual: {
subject: 'Your Kilo credit top-up',
subject: subjects.creditsTopUp,
heading: 'Thanks for your top-up',
intro:
intro: () =>
'Your Kilo credit top-up has been processed and the credits are now available on your account.',
},
auto: {
subject: 'Kilo auto top-up successful',
heading: 'Your auto top-up was successful',
intro:
intro: () =>
'Your account was automatically topped up so you can keep using Kilo without interruption. The new credits are available now.',
},
org_manual: {
subject: 'Your Kilo org credit top-up',
heading: 'Team credits added',
intro: (organizationName: string) =>
`A Kilo credit top-up has been processed for ${organizationName}. The credits are now available to the organization.`,
},
org_auto: {
subject: 'Kilo team auto top-up successful',
heading: 'Team auto top-up was successful',
intro: (organizationName: string) =>
`${organizationName} was automatically topped up so your team can keep using Kilo without interruption. The new credits are available now.`,
},
} as const;

export type CreditsTopUpVariant = keyof typeof CREDITS_TOPUP_COPY;

type SendCreditsTopUpEmailProps = {
type BaseSendCreditsTopUpEmailProps = {
to: string;
variant: CreditsTopUpVariant;
amountCents: number;
creditsCents: number;
purchaseDate: Date;
receiptUrl?: string | null;
};

type PersonalCreditsTopUpEmailProps = BaseSendCreditsTopUpEmailProps & {
variant: 'manual' | 'auto';
};

type OrganizationCreditsTopUpEmailProps = BaseSendCreditsTopUpEmailProps & {
variant: 'org_manual' | 'org_auto';
creditsUrl?: string;
organizationId?: Organization['id'];
organizationName?: Organization['name'];
} & ({ creditsUrl: string } | { organizationId: Organization['id'] });

type SendCreditsTopUpEmailProps =
| PersonalCreditsTopUpEmailProps
| OrganizationCreditsTopUpEmailProps;

function isOrganizationCreditsTopUpEmail(
props: SendCreditsTopUpEmailProps
): props is OrganizationCreditsTopUpEmailProps {
return props.variant === 'org_manual' || props.variant === 'org_auto';
}

export function buildCreditsTopUpReceiptSection(receiptUrl: string | null | undefined): RawHtml {
if (!receiptUrl) return new RawHtml('');
const escaped = escapeHtml(receiptUrl);
Expand Down Expand Up @@ -447,14 +479,23 @@ export async function sendCreditsTopUpEmail(
props: SendCreditsTopUpEmailProps
): Promise<SendResult> {
const copy = CREDITS_TOPUP_COPY[props.variant];
const credits_url = `${NEXTAUTH_URL}/credits`;
const isOrgVariant = isOrganizationCreditsTopUpEmail(props);

if (isOrgVariant && !props.creditsUrl && !props.organizationId) {
throw new Error('Organization top-up emails require creditsUrl or organizationId');
}

const organizationName = isOrgVariant ? (props.organizationName ?? 'your organization') : '';
const credits_url = isOrgVariant
? props.creditsUrl || `${NEXTAUTH_URL}/organizations/${props.organizationId}/payment-details`
: `${NEXTAUTH_URL}/credits`;
return send({
to: props.to,
templateName: 'creditsTopUp',
subjectOverride: copy.subject,
templateVars: {
heading: copy.heading,
intro: copy.intro,
intro: copy.intro(organizationName),
amount_usd: formatUsd(props.amountCents),
credits_usd: formatUsd(props.creditsCents),
purchase_date: formatDate(props.purchaseDate),
Expand Down
Loading