block sending emails on limits (#216)

This commit is contained in:
KM Koushik
2025-09-08 18:08:57 +10:00
committed by GitHub
parent 5b3022c27b
commit 55d8c7e998
21 changed files with 844 additions and 132 deletions

View File

@@ -27,6 +27,7 @@
- Formatting: Prettier 3; run `pnpm format`.
- Files: React components PascalCase (e.g., `AppSideBar.tsx`); folders kebab/lowercase.
- Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`).
- Never use dynamic imports
## Testing Guidelines

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "dailyEmailLimit" INTEGER NOT NULL DEFAULT 10000,
ADD COLUMN "isBlocked" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isVerified" BOOLEAN NOT NULL DEFAULT false;
-- DropEnum
DROP TYPE "SendingDisabledReason";

View File

@@ -107,6 +107,9 @@ model Team {
apiRateLimit Int @default(2)
billingEmail String?
sesTenantId String?
isVerified Boolean @default(false)
dailyEmailLimit Int @default(10000)
isBlocked Boolean @default(false)
teamUsers TeamUser[]
domains Domain[]
apiKeys ApiKey[]

View File

@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import {
renderOtpEmail,
renderTeamInviteEmail,
renderUsageWarningEmail,
renderUsageLimitReachedEmail,
} from "~/server/email-templates";
export async function GET(request: NextRequest) {
@@ -28,6 +30,28 @@ export async function GET(request: NextRequest) {
inviterName: "John Doe",
role: "admin",
});
} else if (type === "usage-warning") {
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
const period = searchParams.get("period") || "daily";
html = await renderUsageWarningEmail({
teamName: "Acme Inc",
used: 8000,
limit: 10000,
period: period as "daily" | "monthly",
manageUrl: "https://app.usesend.com/settings/billing",
isPaidPlan: isPaidPlan,
});
} else if (type === "usage-limit") {
const isPaidPlan = searchParams.get("isPaidPlan") === "true";
const period = searchParams.get("period") || "daily";
html = await renderUsageLimitReachedEmail({
teamName: "Acme Inc",
limit: 10000,
period: period as "daily" | "monthly",
manageUrl: "https://app.usesend.com/settings/billing",
isPaidPlan: isPaidPlan,
});
} else {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}

View File

@@ -4,7 +4,9 @@ export enum LimitReason {
DOMAIN = "DOMAIN",
CONTACT_BOOK = "CONTACT_BOOK",
TEAM_MEMBER = "TEAM_MEMBER",
EMAIL = "EMAIL",
EMAIL_BLOCKED = "EMAIL_BLOCKED",
EMAIL_DAILY_LIMIT_REACHED = "EMAIL_DAILY_LIMIT_REACHED",
EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED = "EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED",
}
export const PLAN_LIMITS: Record<

View File

@@ -15,6 +15,7 @@ import {
getManageSessionUrl,
} from "~/server/billing/payments";
import { db } from "~/server/db";
import { TeamService } from "~/server/service/team-service";
export const billingRouter = createTRPCRouter({
createCheckoutSession: teamAdminProcedure.mutation(async ({ ctx }) => {
@@ -47,9 +48,6 @@ export const billingRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const { billingEmail } = input;
await db.team.update({
where: { id: ctx.team.id },
data: { billingEmail },
});
await TeamService.updateTeam(ctx.team.id, { billingEmail });
}),
});

View File

@@ -1,6 +1,5 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { env } from "~/env";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
@@ -71,6 +70,7 @@ export const invitationRouter = createTRPCRouter({
id: input.inviteId,
},
});
// No need to invalidate cache here again
return true;
}),

View File

@@ -8,7 +8,7 @@ export const limitsRouter = createTRPCRouter({
.input(
z.object({
type: z.nativeEnum(LimitReason),
}),
})
)
.query(async ({ ctx, input }) => {
switch (input.type) {
@@ -18,8 +18,6 @@ export const limitsRouter = createTRPCRouter({
return LimitService.checkDomainLimit(ctx.team.id);
case LimitReason.TEAM_MEMBER:
return LimitService.checkTeamMemberLimit(ctx.team.id);
case LimitReason.EMAIL:
return LimitService.checkEmailLimit(ctx.team.id);
default:
// exhaustive guard
throw new Error("Unsupported limit type");

View File

@@ -1,6 +1,7 @@
import Stripe from "stripe";
import { env } from "~/env";
import { db } from "../db";
import { TeamService } from "../service/team-service";
export function getStripe() {
if (!env.STRIPE_SECRET_KEY) {
@@ -14,12 +15,9 @@ async function createCustomerForTeam(teamId: number) {
const stripe = getStripe();
const customer = await stripe.customers.create({ metadata: { teamId } });
await db.team.update({
where: { id: teamId },
data: {
stripeCustomerId: customer.id,
billingEmail: customer.email,
},
await TeamService.updateTeam(teamId, {
billingEmail: customer.email,
stripeCustomerId: customer.id,
});
return customer;
@@ -183,14 +181,11 @@ export async function syncStripeData(customerId: string) {
},
});
await db.team.update({
where: { id: team.id },
data: {
plan:
subscription.status === "canceled"
? "FREE"
: getPlanFromPriceIds(priceIds),
isActive: subscription.status === "active",
},
await TeamService.updateTeam(team.id, {
plan:
subscription.status === "canceled"
? "FREE"
: getPlanFromPriceIds(priceIds),
isActive: subscription.status === "active",
});
}

View File

@@ -0,0 +1,110 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";
interface UsageLimitReachedEmailProps {
teamName: string;
limit: number;
isPaidPlan: boolean;
period?: "daily" | "monthly";
manageUrl?: string;
logoUrl?: string;
}
export function UsageLimitReachedEmail({
teamName,
limit,
isPaidPlan,
period = "daily",
manageUrl = "#",
logoUrl,
}: UsageLimitReachedEmailProps) {
const preview = `You've reached your ${period} email limit`;
return (
<EmailLayout preview={preview}>
<EmailHeader logoUrl={logoUrl} title="You've reached your email limit" />
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi {teamName} team,
</Text>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
You've reached your {period} limit of{" "}
<strong style={{ color: "#000" }}>{limit.toLocaleString()}</strong>{" "}
emails.
</Text>
<Container
style={{
backgroundColor: "#fef2f2",
border: "1px solid #fecaca",
padding: "12px 16px",
margin: "0 0 24px 0",
borderRadius: "4px",
}}
>
<Text
style={{
margin: 0,
color: "#991b1b",
fontSize: 14,
textAlign: "left" as const,
}}
>
Sending is temporarily paused until your limit resets or{" "}
{isPaidPlan ? "your team is verified" : "your plan is upgraded"}
</Text>
</Container>
<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={manageUrl}>Manage plan</EmailButton>
</Container>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: 0,
lineHeight: 1.5,
textAlign: "left" as const,
}}
>
Consider{" "}
{isPaidPlan
? "verifying your team by replying to this email"
: "upgrading your plan"}
</Text>
</Container>
<EmailFooter />
</EmailLayout>
);
}
export async function renderUsageLimitReachedEmail(
props: UsageLimitReachedEmailProps
): Promise<string> {
return render(<UsageLimitReachedEmail {...props} />);
}

View File

@@ -0,0 +1,116 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";
interface UsageWarningEmailProps {
teamName: string;
used: number;
limit: number;
isPaidPlan: boolean;
period?: "daily" | "monthly";
manageUrl?: string;
logoUrl?: string;
}
export function UsageWarningEmail({
teamName,
used,
limit,
isPaidPlan,
period = "daily",
manageUrl = "#",
logoUrl,
}: UsageWarningEmailProps) {
const percent = limit > 0 ? Math.round((used / limit) * 100) : 80;
const preview = `You've used ${percent}% of your ${period} email limit`;
return (
<EmailLayout preview={preview}>
<EmailHeader logoUrl={logoUrl} title="You're nearing your email limit" />
<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi {teamName} team,
</Text>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 16px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
You've used{" "}
<strong style={{ color: "#000" }}>{used.toLocaleString()}</strong> of
your{" "}
<strong style={{ color: "#000" }}>{limit.toLocaleString()}</strong>{" "}
{period} email limit.
</Text>
<Container
style={{
backgroundColor: "#fff7ed",
border: "1px solid #fed7aa",
padding: "12px 16px",
margin: "0 0 24px 0",
borderRadius: "4px",
}}
>
<Text
style={{
margin: 0,
color: "#9a3412",
fontSize: 14,
textAlign: "left" as const,
}}
>
Heads up: you're at approximately {percent}% of your limit.
</Text>
</Container>
<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={manageUrl}>
{isPaidPlan ? "Verify team" : "Upgrade"}
</EmailButton>
</Container>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: 0,
lineHeight: 1.5,
textAlign: "left" as const,
}}
>
Consider{" "}
{isPaidPlan
? "verifying your team by replying to this email"
: "upgrading your plan"}
</Text>
</Container>
<EmailFooter />
</EmailLayout>
);
}
export async function renderUsageWarningEmail(
props: UsageWarningEmailProps
): Promise<string> {
return render(<UsageWarningEmail {...props} />);
}

View File

@@ -8,7 +8,7 @@ interface EmailFooterProps {
export function EmailFooter({
companyName = "useSend",
supportUrl = "https://usesend.com"
supportUrl = "mailto:hey@usesend.com",
}: EmailFooterProps) {
return (
<Container

View File

@@ -1,5 +1,13 @@
export { OtpEmail, renderOtpEmail } from "./OtpEmail";
export { TeamInviteEmail, renderTeamInviteEmail } from "./TeamInviteEmail";
export {
UsageWarningEmail,
renderUsageWarningEmail,
} from "./UsageWarningEmail";
export {
UsageLimitReachedEmail,
renderUsageLimitReachedEmail,
} from "./UsageLimitReachedEmail";
export * from "./components/EmailLayout";
export * from "./components/EmailHeader";

View File

@@ -69,11 +69,12 @@ export async function sendTeamInviteEmail(
await sendMail(email, subject, text, html);
}
async function sendMail(
export async function sendMail(
email: string,
subject: string,
text: string,
html: string
html: string,
replyTo?: string
) {
if (isSelfHosted()) {
logger.info("Sending email using self hosted");
@@ -107,6 +108,7 @@ async function sendMail(
subject,
text,
html,
replyTo,
});
} else if (env.UNSEND_API_KEY && env.FROM_EMAIL) {
const resp = await getClient().emails.send({
@@ -115,6 +117,7 @@ async function sendMail(
subject,
text,
html,
replyTo,
});
if (resp.data) {
@@ -123,7 +126,7 @@ async function sendMail(
} else {
logger.error(
{ code: resp.error?.code, message: resp.error?.message },
"Error sending email using usesend, so fallback to resend"
"Error sending email using usesend"
);
}
} else {

View File

@@ -11,3 +11,40 @@ export const getRedis = () => {
}
return connection;
};
/**
* Simple Redis caching helper. Stores JSON-serialized values under `key` for `ttlSeconds`.
* If the key exists, returns the parsed value; otherwise, runs `fetcher`, caches, and returns it.
*/
export async function withCache<T>(
key: string,
fetcher: () => Promise<T>,
options?: { ttlSeconds?: number; disable?: boolean }
): Promise<T> {
const { ttlSeconds = 120, disable = false } = options ?? {};
const redis = getRedis();
if (!disable) {
const cached = await redis.get(key);
if (cached) {
try {
return JSON.parse(cached) as T;
} catch {
// fallthrough to refresh cache
}
}
}
const value = await fetcher();
if (!disable) {
try {
await redis.setex(key, ttlSeconds, JSON.stringify(value));
} catch {
// ignore cache set errors
}
}
return value;
}

View File

@@ -28,13 +28,15 @@ export async function createContactBook(teamId: number, name: string) {
});
}
return db.contactBook.create({
const created = await db.contactBook.create({
data: {
name,
teamId,
properties: {},
},
});
return created;
}
export async function getContactBookDetails(contactBookId: string) {
@@ -79,5 +81,7 @@ export async function updateContactBook(
}
export async function deleteContactBook(contactBookId: string) {
return db.contactBook.delete({ where: { id: contactBookId } });
const deleted = await db.contactBook.delete({ where: { id: contactBookId } });
return deleted;
}

View File

@@ -87,7 +87,12 @@ export async function createDomain(
const subdomain = tldts.getSubdomain(name);
const dkimSelector = "usesend";
const publicKey = await ses.addDomain(name, region, sesTenantId, dkimSelector);
const publicKey = await ses.addDomain(
name,
region,
sesTenantId,
dkimSelector
);
const domain = await db.domain.create({
data: {
@@ -190,9 +195,9 @@ export async function deleteDomain(id: number) {
throw new Error("Error in deleting domain");
}
return db.domain.delete({
where: { id },
});
const deletedRecord = await db.domain.delete({ where: { id } });
return deletedRecord;
}
export async function getDomains(teamId: number) {

View File

@@ -10,6 +10,8 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
import { Prisma } from "@prisma/client";
import { logger } from "../logger/log";
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
import { LimitService } from "./limit-service";
// Notifications about limits are handled inside LimitService.
type QueueEmailJob = TeamJob<{
emailId: string;
@@ -366,6 +368,29 @@ async function executeEmail(job: QueueEmailJob) {
}
try {
// Check limits right before sending (cloud-only)
const limitCheck = await LimitService.checkEmailLimit(email.teamId);
logger.info({ limitCheck }, `[EmailQueueService]: Limit check`);
if (limitCheck.isLimitReached) {
await db.emailEvent.create({
data: {
emailId: email.id,
status: "FAILED",
data: {
error: "Email sending limit reached",
reason: limitCheck.reason,
limit: limitCheck.limit,
},
teamId: email.teamId,
},
});
await db.email.update({
where: { id: email.id },
data: { latestStatus: "FAILED" },
});
return;
}
const messageId = await sendRawEmail({
to: email.to,
from: email.from,

View File

@@ -35,7 +35,7 @@ async function checkIfValidEmail(emailId: string) {
export const replaceVariables = (
text: string,
variables: Record<string, string>,
variables: Record<string, string>
) => {
return Object.keys(variables).reduce((accum, key) => {
const re = new RegExp(`{{${key}}}`, "g");
@@ -48,7 +48,7 @@ export const replaceVariables = (
Send transactional email
*/
export async function sendEmail(
emailContent: EmailContent & { teamId: number; apiKeyId?: number },
emailContent: EmailContent & { teamId: number; apiKeyId?: number }
) {
const {
to,
@@ -84,18 +84,18 @@ export async function sendEmail(
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
teamId,
teamId
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
// Only block the email if all TO recipients are suppressed
@@ -105,7 +105,7 @@ export async function sendEmail(
to,
teamId,
},
"All TO recipients are suppressed. No emails to send.",
"All TO recipients are suppressed. No emails to send."
);
const email = await db.email.create({
@@ -147,7 +147,7 @@ export async function sendEmail(
filteredCc: filteredCcEmails,
teamId,
},
"Some CC recipients were suppressed and filtered out.",
"Some CC recipients were suppressed and filtered out."
);
}
@@ -158,7 +158,7 @@ export async function sendEmail(
filteredBcc: filteredBccEmails,
teamId,
},
"Some BCC recipients were suppressed and filtered out.",
"Some BCC recipients were suppressed and filtered out."
);
}
@@ -181,7 +181,7 @@ export async function sendEmail(
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
{} as Record<string, string>,
{} as Record<string, string>
),
};
@@ -251,7 +251,7 @@ export async function sendEmail(
domain.region,
true,
undefined,
delay,
delay
);
} catch (error: any) {
await db.emailEvent.create({
@@ -280,7 +280,7 @@ export async function updateEmail(
scheduledAt,
}: {
scheduledAt?: string;
},
}
) {
const { email, domain } = await checkIfValidEmail(emailId);
@@ -344,7 +344,7 @@ export async function sendBulkEmails(
teamId: number;
apiKeyId?: number;
}
>,
>
) {
if (emailContents.length === 0) {
throw new UnsendApiError({
@@ -382,18 +382,18 @@ export async function sendBulkEmails(
const suppressionResults = await SuppressionService.checkMultipleEmails(
allEmailsToCheck,
content.teamId,
content.teamId
);
// Filter each field separately
const filteredToEmails = toEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
const filteredCcEmails = ccEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
const filteredBccEmails = bccEmails.filter(
(email) => !suppressionResults[email],
(email) => !suppressionResults[email]
);
// Only consider it suppressed if all TO recipients are suppressed
@@ -410,13 +410,13 @@ export async function sendBulkEmails(
suppressed: hasSuppressedToEmails,
suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
suppressedCcEmails: ccEmails.filter(
(email) => suppressionResults[email],
(email) => suppressionResults[email]
),
suppressedBccEmails: bccEmails.filter(
(email) => suppressionResults[email],
(email) => suppressionResults[email]
),
};
}),
})
);
const validEmails = emailChecks.filter((check) => !check.suppressed);
@@ -433,7 +433,7 @@ export async function sendBulkEmails(
suppressedAddresses: info.suppressedEmails,
})),
},
"Filtered suppressed emails from bulk send",
"Filtered suppressed emails from bulk send"
);
}
@@ -490,7 +490,7 @@ export async function sendBulkEmails(
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
{} as Record<string, string>,
{} as Record<string, string>
),
};
@@ -647,7 +647,7 @@ export async function sendBulkEmails(
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
{} as Record<string, string>,
{} as Record<string, string>
),
};
@@ -709,7 +709,7 @@ export async function sendBulkEmails(
} catch (error: any) {
logger.error(
{ err: error, to },
`Failed to create email record for recipient`,
`Failed to create email record for recipient`
);
// Continue processing other emails
}
@@ -744,7 +744,7 @@ export async function sendBulkEmails(
where: { id: email.email.id },
data: { latestStatus: "FAILED" },
});
}),
})
);
throw error;
}

View File

@@ -1,6 +1,10 @@
import { PLAN_LIMITS, LimitReason } from "~/lib/constants/plans";
import { db } from "../db";
import { env } from "~/env";
import { getThisMonthUsage } from "./usage-service";
import { TeamService } from "./team-service";
import { withCache } from "../redis";
import { db } from "../db";
import { logger } from "../logger/log";
function isLimitExceeded(current: number, limit: number): boolean {
if (limit === -1) return false; // unlimited
@@ -13,23 +17,16 @@ export class LimitService {
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
domains: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
// Limits only apply in cloud mode
if (!env.NEXT_PUBLIC_IS_CLOUD) {
return { isLimitReached: false, limit: -1 };
}
const team = await TeamService.getTeamCached(teamId);
const currentCount = await db.domain.count({ where: { teamId } });
const limit = PLAN_LIMITS[team.plan].domains;
if (isLimitExceeded(team._count.domains, limit)) {
if (isLimitExceeded(currentCount, limit)) {
return {
isLimitReached: true,
limit,
@@ -48,23 +45,16 @@ export class LimitService {
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
_count: {
select: {
contactBooks: true,
},
},
},
});
if (!team) {
throw new Error("Team not found");
// Limits only apply in cloud mode
if (!env.NEXT_PUBLIC_IS_CLOUD) {
return { isLimitReached: false, limit: -1 };
}
const team = await TeamService.getTeamCached(teamId);
const currentCount = await db.contactBook.count({ where: { teamId } });
const limit = PLAN_LIMITS[team.plan].contactBooks;
if (isLimitExceeded(team._count.contactBooks, limit)) {
if (isLimitExceeded(currentCount, limit)) {
return {
isLimitReached: true,
limit,
@@ -83,19 +73,16 @@ export class LimitService {
limit: number;
reason?: LimitReason;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
include: {
teamUsers: true,
},
});
if (!team) {
throw new Error("Team not found");
// Limits only apply in cloud mode
if (!env.NEXT_PUBLIC_IS_CLOUD) {
return { isLimitReached: false, limit: -1 };
}
const team = await TeamService.getTeamCached(teamId);
const currentCount = await db.teamUser.count({ where: { teamId } });
const limit = PLAN_LIMITS[team.plan].teamMembers;
if (isLimitExceeded(team.teamUsers.length, limit)) {
if (isLimitExceeded(currentCount, limit)) {
return {
isLimitReached: true,
limit,
@@ -109,52 +96,146 @@ export class LimitService {
};
}
// Checks email sending limits and also triggers usage notifications.
// Side effects:
// - Sends "warning" emails when nearing daily/monthly limits (rate-limited in TeamService)
// - Sends "limit reached" notifications when limits are exceeded (rate-limited in TeamService)
static async checkEmailLimit(teamId: number): Promise<{
isLimitReached: boolean;
limit: number;
reason?: LimitReason;
available?: number;
}> {
const team = await db.team.findUnique({
where: { id: teamId },
});
if (!team) {
throw new Error("Team not found");
// Limits only apply in cloud mode
if (!env.NEXT_PUBLIC_IS_CLOUD) {
return { isLimitReached: false, limit: -1 };
}
// FREE plan has hard limits; paid plans are unlimited (-1)
if (team.plan === "FREE") {
const usage = await getThisMonthUsage(teamId);
const team = await TeamService.getTeamCached(teamId);
// In cloud, enforce verification and block flags first
if (team.isBlocked) {
return {
isLimitReached: true,
limit: 0,
reason: LimitReason.EMAIL_BLOCKED,
};
}
// Enforce daily sending limit (team-specific)
const usage = await withCache(
`usage:this-month:${teamId}`,
() => getThisMonthUsage(teamId),
{ ttlSeconds: 60 }
);
const dailyUsage = usage.day.reduce((acc, curr) => acc + curr.sent, 0);
const dailyLimit =
team.plan !== "FREE"
? team.dailyEmailLimit
: PLAN_LIMITS[team.plan].emailsPerDay;
logger.info(
{ dailyUsage, dailyLimit, team },
`[LimitService]: Daily usage and limit`
);
if (isLimitExceeded(dailyUsage, dailyLimit)) {
// Notify: daily limit reached
try {
await TeamService.maybeNotifyEmailLimitReached(
teamId,
dailyLimit,
LimitReason.EMAIL_DAILY_LIMIT_REACHED
);
} catch (e) {
logger.warn(
{ err: e },
"Failed to send daily limit reached notification"
);
}
return {
isLimitReached: true,
limit: dailyLimit,
reason: LimitReason.EMAIL_DAILY_LIMIT_REACHED,
available: dailyLimit - dailyUsage,
};
}
if (team.plan === "FREE") {
const monthlyUsage = usage.month.reduce(
(acc, curr) => acc + curr.sent,
0,
0
);
const dailyUsage = usage.day.reduce((acc, curr) => acc + curr.sent, 0);
const monthlyLimit = PLAN_LIMITS[team.plan].emailsPerMonth;
const dailyLimit = PLAN_LIMITS[team.plan].emailsPerDay;
logger.info(
{ monthlyUsage, monthlyLimit, team },
`[LimitService]: Monthly usage and limit`
);
if (monthlyUsage / monthlyLimit > 0.8 && monthlyUsage < monthlyLimit) {
await TeamService.sendWarningEmail(
teamId,
monthlyUsage,
monthlyLimit,
LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
);
}
logger.info(
{ monthlyUsage, monthlyLimit, team },
`[LimitService]: Monthly usage and limit`
);
if (isLimitExceeded(monthlyUsage, monthlyLimit)) {
// Notify: monthly (free plan) limit reached
try {
await TeamService.maybeNotifyEmailLimitReached(
teamId,
monthlyLimit,
LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
);
} catch (e) {
logger.warn(
{ err: e },
"Failed to send monthly limit reached notification"
);
}
return {
isLimitReached: true,
limit: monthlyLimit,
reason: LimitReason.EMAIL,
reason: LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED,
available: monthlyLimit - monthlyUsage,
};
}
}
if (isLimitExceeded(dailyUsage, dailyLimit)) {
return {
isLimitReached: true,
limit: dailyLimit,
reason: LimitReason.EMAIL,
};
// Warn: nearing daily limit (e.g., < 20% available)
if (
dailyLimit !== -1 &&
dailyLimit > 0 &&
dailyLimit - dailyUsage > 0 &&
(dailyLimit - dailyUsage) / dailyLimit < 0.2
) {
try {
await TeamService.sendWarningEmail(
teamId,
dailyUsage,
dailyLimit,
LimitReason.EMAIL_DAILY_LIMIT_REACHED
);
} catch (e) {
logger.warn({ err: e }, "Failed to send daily warning email");
}
}
return {
isLimitReached: false,
limit: PLAN_LIMITS[team.plan].emailsPerMonth,
limit: dailyLimit,
available: dailyLimit - dailyUsage,
};
}
}

View File

@@ -1,13 +1,56 @@
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import { db } from "~/server/db";
import { sendTeamInviteEmail } from "~/server/mailer";
import { sendMail, sendTeamInviteEmail } from "~/server/mailer";
import { logger } from "~/server/logger/log";
import type { Team, TeamInvite } from "@prisma/client";
import { LimitService } from "./limit-service";
import type { Prisma, Team, TeamInvite } from "@prisma/client";
import { UnsendApiError } from "../public-api/api-error";
import { getRedis } from "~/server/redis";
import { LimitReason, PLAN_LIMITS } from "~/lib/constants/plans";
import { renderUsageLimitReachedEmail } from "../email-templates/UsageLimitReachedEmail";
import { renderUsageWarningEmail } from "../email-templates/UsageWarningEmail";
// Cache stores exactly Prisma Team shape (no counts)
const TEAM_CACHE_TTL_SECONDS = 120; // 2 minutes
export class TeamService {
private static cacheKey(teamId: number) {
return `team:${teamId}`;
}
static async refreshTeamCache(teamId: number): Promise<Team | null> {
const team = await db.team.findUnique({ where: { id: teamId } });
if (!team) return null;
const redis = getRedis();
await redis.setex(
TeamService.cacheKey(teamId),
TEAM_CACHE_TTL_SECONDS,
JSON.stringify(team)
);
return team;
}
static async invalidateTeamCache(teamId: number) {
const redis = getRedis();
await redis.del(TeamService.cacheKey(teamId));
}
static async getTeamCached(teamId: number): Promise<Team> {
const redis = getRedis();
const raw = await redis.get(TeamService.cacheKey(teamId));
if (raw) {
return JSON.parse(raw) as Team;
}
const fresh = await TeamService.refreshTeamCache(teamId);
if (!fresh) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
}
return fresh;
}
static async createTeam(
userId: number,
name: string
@@ -37,7 +80,7 @@ export class TeamService {
}
}
return db.team.create({
const created = await db.team.create({
data: {
name,
teamUsers: {
@@ -48,6 +91,22 @@ export class TeamService {
},
},
});
// Warm cache for the new team
await TeamService.refreshTeamCache(created.id);
return created;
}
/**
* Update a team and refresh the cache.
* Returns the full Prisma Team object.
*/
static async updateTeam(
teamId: number,
data: Prisma.TeamUpdateInput
): Promise<Team> {
const updated = await db.team.update({ where: { id: teamId }, data });
await TeamService.refreshTeamCache(teamId);
return updated;
}
static async getUserTeams(userId: number) {
@@ -102,12 +161,14 @@ export class TeamService {
});
}
const { isLimitReached, reason } =
await LimitService.checkTeamMemberLimit(teamId);
if (isLimitReached) {
const cachedTeam = await TeamService.getTeamCached(teamId);
const memberLimit = PLAN_LIMITS[cachedTeam.plan].teamMembers;
const currentMembers = await db.teamUser.count({ where: { teamId } });
const isExceeded = memberLimit !== -1 && currentMembers >= memberLimit;
if (isExceeded) {
throw new UnsendApiError({
code: "FORBIDDEN",
message: reason ?? "Team invite limit reached",
message: "Team invite limit reached",
});
}
@@ -178,7 +239,7 @@ export class TeamService {
});
}
return db.teamUser.update({
const updated = await db.teamUser.update({
where: {
teamId_userId: {
teamId,
@@ -189,6 +250,9 @@ export class TeamService {
role,
},
});
// Role updates might influence permissions; refresh cache to be safe
await TeamService.invalidateTeamCache(teamId);
return updated;
}
static async deleteTeamUser(
@@ -233,7 +297,7 @@ export class TeamService {
});
}
return db.teamUser.delete({
const deleted = await db.teamUser.delete({
where: {
teamId_userId: {
teamId,
@@ -241,6 +305,8 @@ export class TeamService {
},
},
});
await TeamService.invalidateTeamCache(teamId);
return deleted;
}
static async resendTeamInvite(inviteId: string, teamName: string) {
@@ -290,4 +356,233 @@ export class TeamService {
},
});
}
/**
* Notify all team users that email limit has been reached, at most once per 6 hours.
*/
static async maybeNotifyEmailLimitReached(
teamId: number,
limit: number,
reason: LimitReason | undefined
) {
logger.info(
{ teamId, limit, reason },
"[TeamService]: maybeNotifyEmailLimitReached called"
);
if (!reason) {
logger.info(
{ teamId },
"[TeamService]: Skipping notify — no reason provided"
);
return;
}
// Only notify on actual email limit reasons
if (
![
LimitReason.EMAIL_DAILY_LIMIT_REACHED,
LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED,
].includes(reason)
) {
logger.info(
{ teamId, reason },
"[TeamService]: Skipping notify — reason not eligible"
);
return;
}
const redis = getRedis();
const cacheKey = `limit:notify:${teamId}:${reason}`;
const alreadySent = await redis.get(cacheKey);
if (alreadySent) {
logger.info(
{ teamId, cacheKey },
"[TeamService]: Skipping notify — cooldown active"
);
return; // within cooldown window
}
const team = await TeamService.getTeamCached(teamId);
const isPaidPlan = team.plan !== "FREE";
const html = await getLimitReachedEmail(teamId, limit, reason);
const subject =
reason === LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
? "useSend: You've reached your monthly email limit"
: "useSend: You've reached your daily email limit";
const text = `Hi ${team.name} team,\n\nYou've reached your ${
reason === LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
? "monthly"
: "daily"
} limit of ${limit.toLocaleString()} emails.\n\nSending is temporarily paused until your limit resets or ${
isPaidPlan ? "your team is verified" : "your plan is upgraded"
}.\n\nManage plan: ${env.NEXTAUTH_URL}/settings`;
const teamUsers = await TeamService.getTeamUsers(teamId);
const recipients = teamUsers
.map((tu) => tu.user?.email)
.filter((e): e is string => Boolean(e));
logger.info(
{ teamId, recipientsCount: recipients.length, reason },
"[TeamService]: Sending limit reached notifications"
);
// Send individually to all team users
try {
await Promise.all(
recipients.map((to) =>
sendMail(to, subject, text, html, "hey@usesend.com")
)
);
logger.info(
{ teamId, recipientsCount: recipients.length },
"[TeamService]: Limit reached notifications sent"
);
} catch (err) {
logger.error(
{ err, teamId },
"[TeamService]: Failed sending limit reached notifications"
);
throw err;
}
// Set cooldown for 6 hours
await redis.setex(cacheKey, 6 * 60 * 60, "1");
logger.info(
{ teamId, cacheKey },
"[TeamService]: Set limit reached notification cooldown"
);
}
/**
* Notify all team users that they're nearing their email limit.
* Rate limited via Redis to avoid spamming; sends at most once per 6 hours per reason.
*/
static async sendWarningEmail(
teamId: number,
used: number,
limit: number,
reason: LimitReason | undefined
) {
logger.info(
{ teamId, used, limit, reason },
"[TeamService]: sendWarningEmail called"
);
if (!reason) {
logger.info(
{ teamId },
"[TeamService]: Skipping warning — no reason provided"
);
return;
}
if (
![
LimitReason.EMAIL_DAILY_LIMIT_REACHED,
LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED,
].includes(reason)
) {
logger.info(
{ teamId, reason },
"[TeamService]: Skipping warning — reason not eligible"
);
return;
}
const redis = getRedis();
const cacheKey = `limit:warning:${teamId}:${reason}`;
const alreadySent = await redis.get(cacheKey);
if (alreadySent) {
logger.info(
{ teamId, cacheKey },
"[TeamService]: Skipping warning — cooldown active"
);
return; // within cooldown window
}
const team = await TeamService.getTeamCached(teamId);
const isPaidPlan = team.plan !== "FREE";
const period =
reason === LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
? "monthly"
: "daily";
const html = await renderUsageWarningEmail({
teamName: team.name,
used,
limit,
isPaidPlan,
period,
manageUrl: `${env.NEXTAUTH_URL}/settings`,
});
const subject =
period === "monthly"
? "useSend: You're nearing your monthly email limit"
: "useSend: You're nearing your daily email limit";
const text = `Hi ${team.name} team,\n\nYou've used ${used.toLocaleString()} of your ${period} limit of ${limit.toLocaleString()} emails.\n\nConsider ${
isPaidPlan
? "verifying your team by replying to this email"
: "upgrading your plan"
}.\n\nManage plan: ${env.NEXTAUTH_URL}/settings`;
const teamUsers = await TeamService.getTeamUsers(teamId);
const recipients = teamUsers
.map((tu) => tu.user?.email)
.filter((e): e is string => Boolean(e));
logger.info(
{ teamId, recipientsCount: recipients.length, reason },
"[TeamService]: Sending warning notifications"
);
try {
await Promise.all(
recipients.map((to) =>
sendMail(to, subject, text, html, "hey@usesend.com")
)
);
logger.info(
{ teamId, recipientsCount: recipients.length },
"[TeamService]: Warning notifications sent"
);
} catch (err) {
logger.error(
{ err, teamId },
"[TeamService]: Failed sending warning notifications"
);
throw err;
}
// Set cooldown for 6 hours
await redis.setex(cacheKey, 6 * 60 * 60, "1");
logger.info(
{ teamId, cacheKey },
"[TeamService]: Set warning notification cooldown"
);
}
}
async function getLimitReachedEmail(
teamId: number,
limit: number,
reason: LimitReason
) {
const team = await TeamService.getTeamCached(teamId);
const isPaidPlan = team.plan !== "FREE";
const email = await renderUsageLimitReachedEmail({
teamName: team.name,
limit,
isPaidPlan,
period:
reason === LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED
? "monthly"
: "daily",
manageUrl: `${env.NEXTAUTH_URL}/settings`,
});
return email;
}