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
@@ -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;
}
@@ -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) {
@@ -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,
+25 -25
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;
}
+141 -60
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,
};
}
}
+305 -10
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;
}