diff --git a/AGENTS.md b/AGENTS.md
index 39fe4c5..a9f27a9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/apps/web/prisma/migrations/20250907195449_add_team_verification_and_limits/migration.sql b/apps/web/prisma/migrations/20250907195449_add_team_verification_and_limits/migration.sql
new file mode 100644
index 0000000..d0c1a34
--- /dev/null
+++ b/apps/web/prisma/migrations/20250907195449_add_team_verification_and_limits/migration.sql
@@ -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";
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 6cf2030..a2ae7ed 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -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[]
diff --git a/apps/web/src/app/api/dev/email-preview/route.ts b/apps/web/src/app/api/dev/email-preview/route.ts
index e35c080..973f4c7 100644
--- a/apps/web/src/app/api/dev/email-preview/route.ts
+++ b/apps/web/src/app/api/dev/email-preview/route.ts
@@ -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 });
}
diff --git a/apps/web/src/lib/constants/plans.ts b/apps/web/src/lib/constants/plans.ts
index cc3378c..bd1e709 100644
--- a/apps/web/src/lib/constants/plans.ts
+++ b/apps/web/src/lib/constants/plans.ts
@@ -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<
diff --git a/apps/web/src/server/api/routers/billing.ts b/apps/web/src/server/api/routers/billing.ts
index eee9691..7b3d196 100644
--- a/apps/web/src/server/api/routers/billing.ts
+++ b/apps/web/src/server/api/routers/billing.ts
@@ -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 });
}),
});
diff --git a/apps/web/src/server/api/routers/invitiation.ts b/apps/web/src/server/api/routers/invitiation.ts
index 4f94a16..0fb5010 100644
--- a/apps/web/src/server/api/routers/invitiation.ts
+++ b/apps/web/src/server/api/routers/invitiation.ts
@@ -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;
}),
diff --git a/apps/web/src/server/api/routers/limits.ts b/apps/web/src/server/api/routers/limits.ts
index 0425d55..94369ee 100644
--- a/apps/web/src/server/api/routers/limits.ts
+++ b/apps/web/src/server/api/routers/limits.ts
@@ -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");
diff --git a/apps/web/src/server/billing/payments.ts b/apps/web/src/server/billing/payments.ts
index a442627..41cec92 100644
--- a/apps/web/src/server/billing/payments.ts
+++ b/apps/web/src/server/billing/payments.ts
@@ -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",
});
}
diff --git a/apps/web/src/server/email-templates/UsageLimitReachedEmail.tsx b/apps/web/src/server/email-templates/UsageLimitReachedEmail.tsx
new file mode 100644
index 0000000..11c9351
--- /dev/null
+++ b/apps/web/src/server/email-templates/UsageLimitReachedEmail.tsx
@@ -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 (
+
+
+
+
+
+ Hi {teamName} team,
+
+
+
+ You've reached your {period} limit of{" "}
+ {limit.toLocaleString()}{" "}
+ emails.
+
+
+
+
+ Sending is temporarily paused until your limit resets or{" "}
+ {isPaidPlan ? "your team is verified" : "your plan is upgraded"}
+
+
+
+
+ Manage plan
+
+
+
+ Consider{" "}
+ {isPaidPlan
+ ? "verifying your team by replying to this email"
+ : "upgrading your plan"}
+
+
+
+
+
+ );
+}
+
+export async function renderUsageLimitReachedEmail(
+ props: UsageLimitReachedEmailProps
+): Promise {
+ return render();
+}
diff --git a/apps/web/src/server/email-templates/UsageWarningEmail.tsx b/apps/web/src/server/email-templates/UsageWarningEmail.tsx
new file mode 100644
index 0000000..d044d21
--- /dev/null
+++ b/apps/web/src/server/email-templates/UsageWarningEmail.tsx
@@ -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 (
+
+
+
+
+
+ Hi {teamName} team,
+
+
+
+ You've used{" "}
+ {used.toLocaleString()} of
+ your{" "}
+ {limit.toLocaleString()}{" "}
+ {period} email limit.
+
+
+
+
+ Heads up: you're at approximately {percent}% of your limit.
+
+
+
+
+
+ {isPaidPlan ? "Verify team" : "Upgrade"}
+
+
+
+
+ Consider{" "}
+ {isPaidPlan
+ ? "verifying your team by replying to this email"
+ : "upgrading your plan"}
+
+
+
+
+
+ );
+}
+
+export async function renderUsageWarningEmail(
+ props: UsageWarningEmailProps
+): Promise {
+ return render();
+}
diff --git a/apps/web/src/server/email-templates/components/EmailFooter.tsx b/apps/web/src/server/email-templates/components/EmailFooter.tsx
index d03723e..8a2cc60 100644
--- a/apps/web/src/server/email-templates/components/EmailFooter.tsx
+++ b/apps/web/src/server/email-templates/components/EmailFooter.tsx
@@ -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 (
{
}
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(
+ key: string,
+ fetcher: () => Promise,
+ options?: { ttlSeconds?: number; disable?: boolean }
+): Promise {
+ 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;
+}
diff --git a/apps/web/src/server/service/contact-book-service.ts b/apps/web/src/server/service/contact-book-service.ts
index 7714909..2e7043e 100644
--- a/apps/web/src/server/service/contact-book-service.ts
+++ b/apps/web/src/server/service/contact-book-service.ts
@@ -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;
}
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 3580d1e..dffdca2 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -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) {
diff --git a/apps/web/src/server/service/email-queue-service.ts b/apps/web/src/server/service/email-queue-service.ts
index 1de53cd..3316a7c 100644
--- a/apps/web/src/server/service/email-queue-service.ts
+++ b/apps/web/src/server/service/email-queue-service.ts
@@ -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,
diff --git a/apps/web/src/server/service/email-service.ts b/apps/web/src/server/service/email-service.ts
index 6533d7a..a95bd96 100644
--- a/apps/web/src/server/service/email-service.ts
+++ b/apps/web/src/server/service/email-service.ts
@@ -35,7 +35,7 @@ async function checkIfValidEmail(emailId: string) {
export const replaceVariables = (
text: string,
- variables: Record,
+ variables: Record
) => {
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,
+ {} as Record
),
};
@@ -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,
+ {} as Record
),
};
@@ -647,7 +647,7 @@ export async function sendBulkEmails(
acc[`{{${key}}}`] = variables?.[key] || "";
return acc;
},
- {} as Record,
+ {} as Record
),
};
@@ -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;
}
diff --git a/apps/web/src/server/service/limit-service.ts b/apps/web/src/server/service/limit-service.ts
index 867848f..8aa5d1e 100644
--- a/apps/web/src/server/service/limit-service.ts
+++ b/apps/web/src/server/service/limit-service.ts
@@ -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,
};
}
}
diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts
index f7c6c61..9e11832 100644
--- a/apps/web/src/server/service/team-service.ts
+++ b/apps/web/src/server/service/team-service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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;
}