block sending emails on limits (#216)
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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";
|
@@ -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[]
|
||||
|
@@ -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 });
|
||||
}
|
||||
|
@@ -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<
|
||||
|
@@ -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 });
|
||||
}),
|
||||
});
|
||||
|
@@ -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;
|
||||
}),
|
||||
|
@@ -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");
|
||||
|
@@ -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",
|
||||
});
|
||||
}
|
||||
|
110
apps/web/src/server/email-templates/UsageLimitReachedEmail.tsx
Normal file
110
apps/web/src/server/email-templates/UsageLimitReachedEmail.tsx
Normal 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} />);
|
||||
}
|
116
apps/web/src/server/email-templates/UsageWarningEmail.tsx
Normal file
116
apps/web/src/server/email-templates/UsageWarningEmail.tsx
Normal 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} />);
|
||||
}
|
@@ -6,9 +6,9 @@ interface EmailFooterProps {
|
||||
supportUrl?: string;
|
||||
}
|
||||
|
||||
export function EmailFooter({
|
||||
companyName = "useSend",
|
||||
supportUrl = "https://usesend.com"
|
||||
export function EmailFooter({
|
||||
companyName = "useSend",
|
||||
supportUrl = "mailto:hey@usesend.com",
|
||||
}: EmailFooterProps) {
|
||||
return (
|
||||
<Container
|
||||
|
@@ -1,7 +1,15 @@
|
||||
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";
|
||||
export * from "./components/EmailFooter";
|
||||
export * from "./components/EmailButton";
|
||||
export * from "./components/EmailButton";
|
||||
|
@@ -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 {
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user