Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a24ec76
feat(email): add organization credit top-up variants
evanjacobson May 6, 2026
f85e03a
feat(email): add org top-up recipient helper
evanjacobson May 6, 2026
637e40f
feat(email): add org top-up email send helper
evanjacobson May 6, 2026
ff8e430
feat(email): wire org top-up confirmations
evanjacobson May 6, 2026
0ef9b16
feat(email): classify org auto top-up setup emails
evanjacobson May 7, 2026
3d9d71d
test(email): cover org top-up confirmations
evanjacobson May 7, 2026
0d4b0a7
test(email): tighten org top-up email coverage
evanjacobson May 7, 2026
4a51633
test(gdpr): cover org email log retention
evanjacobson May 7, 2026
cb930f1
fix(email): satisfy org top-up typecheck
evanjacobson May 7, 2026
eb5f80d
fix(email): regenerate org top-up migration
evanjacobson May 7, 2026
702c234
fix(email): handle org top-up skip markers
evanjacobson May 7, 2026
3da4828
add check constraint and delete old migration
evanjacobson May 7, 2026
cf918e7
Redo migration
evanjacobson May 7, 2026
bcee250
Split the broad organization top-up confirmation email try/catch into…
evanjacobson May 7, 2026
792856e
undo migrations again
evanjacobson May 7, 2026
4cc2563
Merge branch 'main' of github.com:Kilo-Org/cloud into feat/transactio…
evanjacobson May 7, 2026
f2d3ac7
Redo migrations
evanjacobson May 7, 2026
5a19367
undo migration
evanjacobson May 7, 2026
2bf84cf
Merge branch 'main' of github.com:Kilo-Org/cloud into feat/transactio…
evanjacobson May 7, 2026
09f9877
redo migration again, including the new ON DELETE CASCADE
evanjacobson May 7, 2026
1dddc89
Merge branch 'main' of github.com:Kilo-Org/cloud into feat/transactio…
evanjacobson May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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