block sending emails on limits (#216)
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
- Formatting: Prettier 3; run `pnpm format`.
|
- Formatting: Prettier 3; run `pnpm format`.
|
||||||
- Files: React components PascalCase (e.g., `AppSideBar.tsx`); folders kebab/lowercase.
|
- 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"`).
|
- Paths (web): use alias `~/` for src imports (e.g., `import { x } from "~/utils/x"`).
|
||||||
|
- Never use dynamic imports
|
||||||
|
|
||||||
## Testing Guidelines
|
## 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)
|
apiRateLimit Int @default(2)
|
||||||
billingEmail String?
|
billingEmail String?
|
||||||
sesTenantId String?
|
sesTenantId String?
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
dailyEmailLimit Int @default(10000)
|
||||||
|
isBlocked Boolean @default(false)
|
||||||
teamUsers TeamUser[]
|
teamUsers TeamUser[]
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
|
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
renderOtpEmail,
|
renderOtpEmail,
|
||||||
renderTeamInviteEmail,
|
renderTeamInviteEmail,
|
||||||
|
renderUsageWarningEmail,
|
||||||
|
renderUsageLimitReachedEmail,
|
||||||
} from "~/server/email-templates";
|
} from "~/server/email-templates";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
@@ -28,6 +30,28 @@ export async function GET(request: NextRequest) {
|
|||||||
inviterName: "John Doe",
|
inviterName: "John Doe",
|
||||||
role: "admin",
|
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 {
|
} else {
|
||||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,9 @@ export enum LimitReason {
|
|||||||
DOMAIN = "DOMAIN",
|
DOMAIN = "DOMAIN",
|
||||||
CONTACT_BOOK = "CONTACT_BOOK",
|
CONTACT_BOOK = "CONTACT_BOOK",
|
||||||
TEAM_MEMBER = "TEAM_MEMBER",
|
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<
|
export const PLAN_LIMITS: Record<
|
||||||
|
@@ -15,6 +15,7 @@ import {
|
|||||||
getManageSessionUrl,
|
getManageSessionUrl,
|
||||||
} from "~/server/billing/payments";
|
} from "~/server/billing/payments";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
import { TeamService } from "~/server/service/team-service";
|
||||||
|
|
||||||
export const billingRouter = createTRPCRouter({
|
export const billingRouter = createTRPCRouter({
|
||||||
createCheckoutSession: teamAdminProcedure.mutation(async ({ ctx }) => {
|
createCheckoutSession: teamAdminProcedure.mutation(async ({ ctx }) => {
|
||||||
@@ -47,9 +48,6 @@ export const billingRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { billingEmail } = input;
|
const { billingEmail } = input;
|
||||||
|
|
||||||
await db.team.update({
|
await TeamService.updateTeam(ctx.team.id, { billingEmail });
|
||||||
where: { id: ctx.team.id },
|
|
||||||
data: { billingEmail },
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "~/env";
|
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||||
|
|
||||||
@@ -71,6 +70,7 @@ export const invitationRouter = createTRPCRouter({
|
|||||||
id: input.inviteId,
|
id: input.inviteId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// No need to invalidate cache here again
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
@@ -8,7 +8,7 @@ export const limitsRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.nativeEnum(LimitReason),
|
type: z.nativeEnum(LimitReason),
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
switch (input.type) {
|
switch (input.type) {
|
||||||
@@ -18,8 +18,6 @@ export const limitsRouter = createTRPCRouter({
|
|||||||
return LimitService.checkDomainLimit(ctx.team.id);
|
return LimitService.checkDomainLimit(ctx.team.id);
|
||||||
case LimitReason.TEAM_MEMBER:
|
case LimitReason.TEAM_MEMBER:
|
||||||
return LimitService.checkTeamMemberLimit(ctx.team.id);
|
return LimitService.checkTeamMemberLimit(ctx.team.id);
|
||||||
case LimitReason.EMAIL:
|
|
||||||
return LimitService.checkEmailLimit(ctx.team.id);
|
|
||||||
default:
|
default:
|
||||||
// exhaustive guard
|
// exhaustive guard
|
||||||
throw new Error("Unsupported limit type");
|
throw new Error("Unsupported limit type");
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
|
import { TeamService } from "../service/team-service";
|
||||||
|
|
||||||
export function getStripe() {
|
export function getStripe() {
|
||||||
if (!env.STRIPE_SECRET_KEY) {
|
if (!env.STRIPE_SECRET_KEY) {
|
||||||
@@ -14,12 +15,9 @@ async function createCustomerForTeam(teamId: number) {
|
|||||||
const stripe = getStripe();
|
const stripe = getStripe();
|
||||||
const customer = await stripe.customers.create({ metadata: { teamId } });
|
const customer = await stripe.customers.create({ metadata: { teamId } });
|
||||||
|
|
||||||
await db.team.update({
|
await TeamService.updateTeam(teamId, {
|
||||||
where: { id: teamId },
|
billingEmail: customer.email,
|
||||||
data: {
|
stripeCustomerId: customer.id,
|
||||||
stripeCustomerId: customer.id,
|
|
||||||
billingEmail: customer.email,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return customer;
|
return customer;
|
||||||
@@ -183,14 +181,11 @@ export async function syncStripeData(customerId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await db.team.update({
|
await TeamService.updateTeam(team.id, {
|
||||||
where: { id: team.id },
|
plan:
|
||||||
data: {
|
subscription.status === "canceled"
|
||||||
plan:
|
? "FREE"
|
||||||
subscription.status === "canceled"
|
: getPlanFromPriceIds(priceIds),
|
||||||
? "FREE"
|
isActive: subscription.status === "active",
|
||||||
: 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} />);
|
||||||
|
}
|
@@ -8,7 +8,7 @@ interface EmailFooterProps {
|
|||||||
|
|
||||||
export function EmailFooter({
|
export function EmailFooter({
|
||||||
companyName = "useSend",
|
companyName = "useSend",
|
||||||
supportUrl = "https://usesend.com"
|
supportUrl = "mailto:hey@usesend.com",
|
||||||
}: EmailFooterProps) {
|
}: EmailFooterProps) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
|
@@ -1,5 +1,13 @@
|
|||||||
export { OtpEmail, renderOtpEmail } from "./OtpEmail";
|
export { OtpEmail, renderOtpEmail } from "./OtpEmail";
|
||||||
export { TeamInviteEmail, renderTeamInviteEmail } from "./TeamInviteEmail";
|
export { TeamInviteEmail, renderTeamInviteEmail } from "./TeamInviteEmail";
|
||||||
|
export {
|
||||||
|
UsageWarningEmail,
|
||||||
|
renderUsageWarningEmail,
|
||||||
|
} from "./UsageWarningEmail";
|
||||||
|
export {
|
||||||
|
UsageLimitReachedEmail,
|
||||||
|
renderUsageLimitReachedEmail,
|
||||||
|
} from "./UsageLimitReachedEmail";
|
||||||
|
|
||||||
export * from "./components/EmailLayout";
|
export * from "./components/EmailLayout";
|
||||||
export * from "./components/EmailHeader";
|
export * from "./components/EmailHeader";
|
||||||
|
@@ -69,11 +69,12 @@ export async function sendTeamInviteEmail(
|
|||||||
await sendMail(email, subject, text, html);
|
await sendMail(email, subject, text, html);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMail(
|
export async function sendMail(
|
||||||
email: string,
|
email: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
text: string,
|
text: string,
|
||||||
html: string
|
html: string,
|
||||||
|
replyTo?: string
|
||||||
) {
|
) {
|
||||||
if (isSelfHosted()) {
|
if (isSelfHosted()) {
|
||||||
logger.info("Sending email using self hosted");
|
logger.info("Sending email using self hosted");
|
||||||
@@ -107,6 +108,7 @@ async function sendMail(
|
|||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
replyTo,
|
||||||
});
|
});
|
||||||
} else if (env.UNSEND_API_KEY && env.FROM_EMAIL) {
|
} else if (env.UNSEND_API_KEY && env.FROM_EMAIL) {
|
||||||
const resp = await getClient().emails.send({
|
const resp = await getClient().emails.send({
|
||||||
@@ -115,6 +117,7 @@ async function sendMail(
|
|||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
html,
|
html,
|
||||||
|
replyTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.data) {
|
if (resp.data) {
|
||||||
@@ -123,7 +126,7 @@ async function sendMail(
|
|||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ code: resp.error?.code, message: resp.error?.message },
|
{ code: resp.error?.code, message: resp.error?.message },
|
||||||
"Error sending email using usesend, so fallback to resend"
|
"Error sending email using usesend"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@@ -11,3 +11,40 @@ export const getRedis = () => {
|
|||||||
}
|
}
|
||||||
return connection;
|
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: {
|
data: {
|
||||||
name,
|
name,
|
||||||
teamId,
|
teamId,
|
||||||
properties: {},
|
properties: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContactBookDetails(contactBookId: string) {
|
export async function getContactBookDetails(contactBookId: string) {
|
||||||
@@ -79,5 +81,7 @@ export async function updateContactBook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContactBook(contactBookId: string) {
|
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 subdomain = tldts.getSubdomain(name);
|
||||||
const dkimSelector = "usesend";
|
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({
|
const domain = await db.domain.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -190,9 +195,9 @@ export async function deleteDomain(id: number) {
|
|||||||
throw new Error("Error in deleting domain");
|
throw new Error("Error in deleting domain");
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.domain.delete({
|
const deletedRecord = await db.domain.delete({ where: { id } });
|
||||||
where: { id },
|
|
||||||
});
|
return deletedRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDomains(teamId: number) {
|
export async function getDomains(teamId: number) {
|
||||||
|
@@ -10,6 +10,8 @@ import { DEFAULT_QUEUE_OPTIONS } from "../queue/queue-constants";
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { logger } from "../logger/log";
|
import { logger } from "../logger/log";
|
||||||
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
|
||||||
|
import { LimitService } from "./limit-service";
|
||||||
|
// Notifications about limits are handled inside LimitService.
|
||||||
|
|
||||||
type QueueEmailJob = TeamJob<{
|
type QueueEmailJob = TeamJob<{
|
||||||
emailId: string;
|
emailId: string;
|
||||||
@@ -366,6 +368,29 @@ async function executeEmail(job: QueueEmailJob) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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({
|
const messageId = await sendRawEmail({
|
||||||
to: email.to,
|
to: email.to,
|
||||||
from: email.from,
|
from: email.from,
|
||||||
|
@@ -35,7 +35,7 @@ async function checkIfValidEmail(emailId: string) {
|
|||||||
|
|
||||||
export const replaceVariables = (
|
export const replaceVariables = (
|
||||||
text: string,
|
text: string,
|
||||||
variables: Record<string, string>,
|
variables: Record<string, string>
|
||||||
) => {
|
) => {
|
||||||
return Object.keys(variables).reduce((accum, key) => {
|
return Object.keys(variables).reduce((accum, key) => {
|
||||||
const re = new RegExp(`{{${key}}}`, "g");
|
const re = new RegExp(`{{${key}}}`, "g");
|
||||||
@@ -48,7 +48,7 @@ export const replaceVariables = (
|
|||||||
Send transactional email
|
Send transactional email
|
||||||
*/
|
*/
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
emailContent: EmailContent & { teamId: number; apiKeyId?: number },
|
emailContent: EmailContent & { teamId: number; apiKeyId?: number }
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
to,
|
to,
|
||||||
@@ -84,18 +84,18 @@ export async function sendEmail(
|
|||||||
|
|
||||||
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
||||||
allEmailsToCheck,
|
allEmailsToCheck,
|
||||||
teamId,
|
teamId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter each field separately
|
// Filter each field separately
|
||||||
const filteredToEmails = toEmails.filter(
|
const filteredToEmails = toEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
const filteredCcEmails = ccEmails.filter(
|
const filteredCcEmails = ccEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
const filteredBccEmails = bccEmails.filter(
|
const filteredBccEmails = bccEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only block the email if all TO recipients are suppressed
|
// Only block the email if all TO recipients are suppressed
|
||||||
@@ -105,7 +105,7 @@ export async function sendEmail(
|
|||||||
to,
|
to,
|
||||||
teamId,
|
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({
|
const email = await db.email.create({
|
||||||
@@ -147,7 +147,7 @@ export async function sendEmail(
|
|||||||
filteredCc: filteredCcEmails,
|
filteredCc: filteredCcEmails,
|
||||||
teamId,
|
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,
|
filteredBcc: filteredBccEmails,
|
||||||
teamId,
|
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] || "";
|
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>,
|
{} as Record<string, string>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,7 +251,7 @@ export async function sendEmail(
|
|||||||
domain.region,
|
domain.region,
|
||||||
true,
|
true,
|
||||||
undefined,
|
undefined,
|
||||||
delay,
|
delay
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await db.emailEvent.create({
|
await db.emailEvent.create({
|
||||||
@@ -280,7 +280,7 @@ export async function updateEmail(
|
|||||||
scheduledAt,
|
scheduledAt,
|
||||||
}: {
|
}: {
|
||||||
scheduledAt?: string;
|
scheduledAt?: string;
|
||||||
},
|
}
|
||||||
) {
|
) {
|
||||||
const { email, domain } = await checkIfValidEmail(emailId);
|
const { email, domain } = await checkIfValidEmail(emailId);
|
||||||
|
|
||||||
@@ -344,7 +344,7 @@ export async function sendBulkEmails(
|
|||||||
teamId: number;
|
teamId: number;
|
||||||
apiKeyId?: number;
|
apiKeyId?: number;
|
||||||
}
|
}
|
||||||
>,
|
>
|
||||||
) {
|
) {
|
||||||
if (emailContents.length === 0) {
|
if (emailContents.length === 0) {
|
||||||
throw new UnsendApiError({
|
throw new UnsendApiError({
|
||||||
@@ -382,18 +382,18 @@ export async function sendBulkEmails(
|
|||||||
|
|
||||||
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
const suppressionResults = await SuppressionService.checkMultipleEmails(
|
||||||
allEmailsToCheck,
|
allEmailsToCheck,
|
||||||
content.teamId,
|
content.teamId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter each field separately
|
// Filter each field separately
|
||||||
const filteredToEmails = toEmails.filter(
|
const filteredToEmails = toEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
const filteredCcEmails = ccEmails.filter(
|
const filteredCcEmails = ccEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
const filteredBccEmails = bccEmails.filter(
|
const filteredBccEmails = bccEmails.filter(
|
||||||
(email) => !suppressionResults[email],
|
(email) => !suppressionResults[email]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only consider it suppressed if all TO recipients are suppressed
|
// Only consider it suppressed if all TO recipients are suppressed
|
||||||
@@ -410,13 +410,13 @@ export async function sendBulkEmails(
|
|||||||
suppressed: hasSuppressedToEmails,
|
suppressed: hasSuppressedToEmails,
|
||||||
suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
|
suppressedEmails: toEmails.filter((email) => suppressionResults[email]),
|
||||||
suppressedCcEmails: ccEmails.filter(
|
suppressedCcEmails: ccEmails.filter(
|
||||||
(email) => suppressionResults[email],
|
(email) => suppressionResults[email]
|
||||||
),
|
),
|
||||||
suppressedBccEmails: bccEmails.filter(
|
suppressedBccEmails: bccEmails.filter(
|
||||||
(email) => suppressionResults[email],
|
(email) => suppressionResults[email]
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const validEmails = emailChecks.filter((check) => !check.suppressed);
|
const validEmails = emailChecks.filter((check) => !check.suppressed);
|
||||||
@@ -433,7 +433,7 @@ export async function sendBulkEmails(
|
|||||||
suppressedAddresses: info.suppressedEmails,
|
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] || "";
|
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>,
|
{} as Record<string, string>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -647,7 +647,7 @@ export async function sendBulkEmails(
|
|||||||
acc[`{{${key}}}`] = variables?.[key] || "";
|
acc[`{{${key}}}`] = variables?.[key] || "";
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, string>,
|
{} as Record<string, string>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -709,7 +709,7 @@ export async function sendBulkEmails(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err: error, to },
|
{ err: error, to },
|
||||||
`Failed to create email record for recipient`,
|
`Failed to create email record for recipient`
|
||||||
);
|
);
|
||||||
// Continue processing other emails
|
// Continue processing other emails
|
||||||
}
|
}
|
||||||
@@ -744,7 +744,7 @@ export async function sendBulkEmails(
|
|||||||
where: { id: email.email.id },
|
where: { id: email.email.id },
|
||||||
data: { latestStatus: "FAILED" },
|
data: { latestStatus: "FAILED" },
|
||||||
});
|
});
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,10 @@
|
|||||||
import { PLAN_LIMITS, LimitReason } from "~/lib/constants/plans";
|
import { PLAN_LIMITS, LimitReason } from "~/lib/constants/plans";
|
||||||
import { db } from "../db";
|
import { env } from "~/env";
|
||||||
import { getThisMonthUsage } from "./usage-service";
|
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 {
|
function isLimitExceeded(current: number, limit: number): boolean {
|
||||||
if (limit === -1) return false; // unlimited
|
if (limit === -1) return false; // unlimited
|
||||||
@@ -13,23 +17,16 @@ export class LimitService {
|
|||||||
limit: number;
|
limit: number;
|
||||||
reason?: LimitReason;
|
reason?: LimitReason;
|
||||||
}> {
|
}> {
|
||||||
const team = await db.team.findUnique({
|
// Limits only apply in cloud mode
|
||||||
where: { id: teamId },
|
if (!env.NEXT_PUBLIC_IS_CLOUD) {
|
||||||
include: {
|
return { isLimitReached: false, limit: -1 };
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
domains: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
throw new Error("Team not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const team = await TeamService.getTeamCached(teamId);
|
||||||
|
const currentCount = await db.domain.count({ where: { teamId } });
|
||||||
|
|
||||||
const limit = PLAN_LIMITS[team.plan].domains;
|
const limit = PLAN_LIMITS[team.plan].domains;
|
||||||
if (isLimitExceeded(team._count.domains, limit)) {
|
if (isLimitExceeded(currentCount, limit)) {
|
||||||
return {
|
return {
|
||||||
isLimitReached: true,
|
isLimitReached: true,
|
||||||
limit,
|
limit,
|
||||||
@@ -48,23 +45,16 @@ export class LimitService {
|
|||||||
limit: number;
|
limit: number;
|
||||||
reason?: LimitReason;
|
reason?: LimitReason;
|
||||||
}> {
|
}> {
|
||||||
const team = await db.team.findUnique({
|
// Limits only apply in cloud mode
|
||||||
where: { id: teamId },
|
if (!env.NEXT_PUBLIC_IS_CLOUD) {
|
||||||
include: {
|
return { isLimitReached: false, limit: -1 };
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
contactBooks: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
throw new Error("Team not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const team = await TeamService.getTeamCached(teamId);
|
||||||
|
const currentCount = await db.contactBook.count({ where: { teamId } });
|
||||||
|
|
||||||
const limit = PLAN_LIMITS[team.plan].contactBooks;
|
const limit = PLAN_LIMITS[team.plan].contactBooks;
|
||||||
if (isLimitExceeded(team._count.contactBooks, limit)) {
|
if (isLimitExceeded(currentCount, limit)) {
|
||||||
return {
|
return {
|
||||||
isLimitReached: true,
|
isLimitReached: true,
|
||||||
limit,
|
limit,
|
||||||
@@ -83,19 +73,16 @@ export class LimitService {
|
|||||||
limit: number;
|
limit: number;
|
||||||
reason?: LimitReason;
|
reason?: LimitReason;
|
||||||
}> {
|
}> {
|
||||||
const team = await db.team.findUnique({
|
// Limits only apply in cloud mode
|
||||||
where: { id: teamId },
|
if (!env.NEXT_PUBLIC_IS_CLOUD) {
|
||||||
include: {
|
return { isLimitReached: false, limit: -1 };
|
||||||
teamUsers: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
throw new Error("Team not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const team = await TeamService.getTeamCached(teamId);
|
||||||
|
const currentCount = await db.teamUser.count({ where: { teamId } });
|
||||||
|
|
||||||
const limit = PLAN_LIMITS[team.plan].teamMembers;
|
const limit = PLAN_LIMITS[team.plan].teamMembers;
|
||||||
if (isLimitExceeded(team.teamUsers.length, limit)) {
|
if (isLimitExceeded(currentCount, limit)) {
|
||||||
return {
|
return {
|
||||||
isLimitReached: true,
|
isLimitReached: true,
|
||||||
limit,
|
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<{
|
static async checkEmailLimit(teamId: number): Promise<{
|
||||||
isLimitReached: boolean;
|
isLimitReached: boolean;
|
||||||
limit: number;
|
limit: number;
|
||||||
reason?: LimitReason;
|
reason?: LimitReason;
|
||||||
|
available?: number;
|
||||||
}> {
|
}> {
|
||||||
const team = await db.team.findUnique({
|
// Limits only apply in cloud mode
|
||||||
where: { id: teamId },
|
if (!env.NEXT_PUBLIC_IS_CLOUD) {
|
||||||
});
|
return { isLimitReached: false, limit: -1 };
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
throw new Error("Team not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FREE plan has hard limits; paid plans are unlimited (-1)
|
const team = await TeamService.getTeamCached(teamId);
|
||||||
if (team.plan === "FREE") {
|
|
||||||
const usage = await getThisMonthUsage(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(
|
const monthlyUsage = usage.month.reduce(
|
||||||
(acc, curr) => acc + curr.sent,
|
(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 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)) {
|
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 {
|
return {
|
||||||
isLimitReached: true,
|
isLimitReached: true,
|
||||||
limit: monthlyLimit,
|
limit: monthlyLimit,
|
||||||
reason: LimitReason.EMAIL,
|
reason: LimitReason.EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED,
|
||||||
|
available: monthlyLimit - monthlyUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLimitExceeded(dailyUsage, dailyLimit)) {
|
// Warn: nearing daily limit (e.g., < 20% available)
|
||||||
return {
|
if (
|
||||||
isLimitReached: true,
|
dailyLimit !== -1 &&
|
||||||
limit: dailyLimit,
|
dailyLimit > 0 &&
|
||||||
reason: LimitReason.EMAIL,
|
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 {
|
return {
|
||||||
isLimitReached: false,
|
isLimitReached: false,
|
||||||
limit: PLAN_LIMITS[team.plan].emailsPerMonth,
|
limit: dailyLimit,
|
||||||
|
available: dailyLimit - dailyUsage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,56 @@
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { sendTeamInviteEmail } from "~/server/mailer";
|
import { sendMail, sendTeamInviteEmail } from "~/server/mailer";
|
||||||
import { logger } from "~/server/logger/log";
|
import { logger } from "~/server/logger/log";
|
||||||
import type { Team, TeamInvite } from "@prisma/client";
|
import type { Prisma, Team, TeamInvite } from "@prisma/client";
|
||||||
import { LimitService } from "./limit-service";
|
|
||||||
import { UnsendApiError } from "../public-api/api-error";
|
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 {
|
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(
|
static async createTeam(
|
||||||
userId: number,
|
userId: number,
|
||||||
name: string
|
name: string
|
||||||
@@ -37,7 +80,7 @@ export class TeamService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.team.create({
|
const created = await db.team.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
teamUsers: {
|
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) {
|
static async getUserTeams(userId: number) {
|
||||||
@@ -102,12 +161,14 @@ export class TeamService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isLimitReached, reason } =
|
const cachedTeam = await TeamService.getTeamCached(teamId);
|
||||||
await LimitService.checkTeamMemberLimit(teamId);
|
const memberLimit = PLAN_LIMITS[cachedTeam.plan].teamMembers;
|
||||||
if (isLimitReached) {
|
const currentMembers = await db.teamUser.count({ where: { teamId } });
|
||||||
|
const isExceeded = memberLimit !== -1 && currentMembers >= memberLimit;
|
||||||
|
if (isExceeded) {
|
||||||
throw new UnsendApiError({
|
throw new UnsendApiError({
|
||||||
code: "FORBIDDEN",
|
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: {
|
where: {
|
||||||
teamId_userId: {
|
teamId_userId: {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -189,6 +250,9 @@ export class TeamService {
|
|||||||
role,
|
role,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Role updates might influence permissions; refresh cache to be safe
|
||||||
|
await TeamService.invalidateTeamCache(teamId);
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async deleteTeamUser(
|
static async deleteTeamUser(
|
||||||
@@ -233,7 +297,7 @@ export class TeamService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.teamUser.delete({
|
const deleted = await db.teamUser.delete({
|
||||||
where: {
|
where: {
|
||||||
teamId_userId: {
|
teamId_userId: {
|
||||||
teamId,
|
teamId,
|
||||||
@@ -241,6 +305,8 @@ export class TeamService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await TeamService.invalidateTeamCache(teamId);
|
||||||
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resendTeamInvite(inviteId: string, teamName: string) {
|
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