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; }