diff --git a/app/api/e2/emails/reply.ts b/app/api/e2/emails/reply.ts index 2780654a..a52cc3cb 100644 --- a/app/api/e2/emails/reply.ts +++ b/app/api/e2/emails/reply.ts @@ -1,4 +1,4 @@ -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { SendEmailCommand } from "@aws-sdk/client-sesv2"; import { waitUntil } from "@vercel/functions"; import { Autumn as autumn } from "autumn-js"; import { and, eq } from "drizzle-orm"; @@ -8,6 +8,7 @@ import { getTenantSendingInfoForDomainOrParent, type TenantSendingInfo, } from "@/lib/aws-ses/identity-arn-helper"; +import { getSesClient } from "@/lib/aws-ses/ses-client"; import { db } from "@/lib/db"; import { SENT_EMAIL_STATUS, @@ -32,22 +33,7 @@ import { } from "../helper/attachment-processor"; import { validateAndRateLimit } from "../lib/auth"; -// Initialize SES client -const awsRegion = process.env.AWS_REGION || "us-east-2"; -const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; -const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - -let sesClient: SESv2Client | null = null; - -if (awsAccessKeyId && awsSecretAccessKey) { - sesClient = new SESv2Client({ - region: awsRegion, - credentials: { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - }, - }); -} +const sesClient = getSesClient(); // Request schema const AttachmentSchema = t.Object({ diff --git a/app/api/e2/emails/send.ts b/app/api/e2/emails/send.ts index 8548fe40..dca91d3e 100644 --- a/app/api/e2/emails/send.ts +++ b/app/api/e2/emails/send.ts @@ -1,4 +1,4 @@ -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import { SendEmailCommand } from "@aws-sdk/client-sesv2"; import { Client as QStashClient } from "@upstash/qstash"; import { waitUntil } from "@vercel/functions"; import { Autumn as autumn } from "autumn-js"; @@ -10,6 +10,7 @@ import { getTenantSendingInfoForDomainOrParent, type TenantSendingInfo, } from "@/lib/aws-ses/identity-arn-helper"; +import { getSesClient } from "@/lib/aws-ses/ses-client"; import { db } from "@/lib/db"; import { SCHEDULED_EMAIL_STATUS, @@ -39,22 +40,7 @@ import { import { buildRawEmailMessage } from "../helper/email-builder"; import { validateAndRateLimit } from "../lib/auth"; -// Initialize SES client -const awsRegion = process.env.AWS_REGION || "us-east-2"; -const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; -const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - -let sesClient: SESv2Client | null = null; - -if (awsAccessKeyId && awsSecretAccessKey) { - sesClient = new SESv2Client({ - region: awsRegion, - credentials: { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - }, - }); -} +const sesClient = getSesClient(); // Request schema const AttachmentSchema = t.Object({ diff --git a/app/api/e2/lib/auth.ts b/app/api/e2/lib/auth.ts index 51c79434..d1062808 100644 --- a/app/api/e2/lib/auth.ts +++ b/app/api/e2/lib/auth.ts @@ -137,9 +137,9 @@ export async function validateAndRateLimit( } else if ( apiSession?.valid && !apiSession?.error && - apiSession?.key?.userId + apiSession?.key?.referenceId ) { - userId = apiSession.key.userId; + userId = apiSession.key.referenceId; console.log("🔑 [E2] Auth Type: API_KEY"); console.log("🔑 [E2] API Key:", apiKey); console.log("✅ API key authentication successful for userId:", userId); diff --git a/app/api/webhooks/send-email/build-ses-command.ts b/app/api/webhooks/send-email/build-ses-command.ts new file mode 100644 index 00000000..2f86da18 --- /dev/null +++ b/app/api/webhooks/send-email/build-ses-command.ts @@ -0,0 +1,45 @@ +import { SendEmailCommand } from "@aws-sdk/client-sesv2"; +import type { TenantSendingInfo } from "@/lib/aws-ses/identity-arn-helper"; +import { extractEmailAddress } from "@/lib/utils/email-utils"; + +/** + * Build a SendEmailCommand for SES with tenant-level tracking. + * Shared between handleScheduledEmail and handleBatchEmail. + */ +export function buildSesCommand(params: { + fromAddress: string; + toAddresses: string[]; + ccAddresses: string[]; + bccAddresses: string[]; + rawMessage: string; + tenantSendingInfo: TenantSendingInfo; +}): SendEmailCommand { + return new SendEmailCommand({ + FromEmailAddress: params.fromAddress, + ...(params.tenantSendingInfo.identityArn && { + FromEmailAddressIdentityArn: params.tenantSendingInfo.identityArn, + }), + Destination: { + ToAddresses: params.toAddresses.map(extractEmailAddress), + CcAddresses: + params.ccAddresses.length > 0 + ? params.ccAddresses.map(extractEmailAddress) + : undefined, + BccAddresses: + params.bccAddresses.length > 0 + ? params.bccAddresses.map(extractEmailAddress) + : undefined, + }, + Content: { + Raw: { + Data: Buffer.from(params.rawMessage), + }, + }, + ...(params.tenantSendingInfo.configurationSetName && { + ConfigurationSetName: params.tenantSendingInfo.configurationSetName, + }), + ...(params.tenantSendingInfo.tenantName && { + TenantName: params.tenantSendingInfo.tenantName, + }), + }); +} diff --git a/app/api/webhooks/send-email/resolve-tenant-info.ts b/app/api/webhooks/send-email/resolve-tenant-info.ts new file mode 100644 index 00000000..81c31ad5 --- /dev/null +++ b/app/api/webhooks/send-email/resolve-tenant-info.ts @@ -0,0 +1,73 @@ +import { + getAgentIdentityArn, + getTenantSendingInfoForDomainOrParent, + type TenantSendingInfo, +} from "@/lib/aws-ses/identity-arn-helper"; +import { getRootDomain, isSubdomain } from "@/lib/domains-and-dns/domain-utils"; + +/** + * Resolve tenant sending info (identity ARN, configuration set, tenant name) + * for a given user/domain/agent combination. Shared between handleScheduledEmail + * and handleBatchEmail. + */ +export async function resolveTenantInfo( + userId: string, + fromDomain: string, + isAgentEmail: boolean, + label: string, +): Promise { + let tenantSendingInfo: TenantSendingInfo = { + identityArn: null, + configurationSetName: null, + tenantName: null, + }; + + if (isAgentEmail) { + tenantSendingInfo = { + identityArn: getAgentIdentityArn(), + configurationSetName: null, + tenantName: null, + }; + } else { + const parentDomain = isSubdomain(fromDomain) + ? getRootDomain(fromDomain) + : undefined; + tenantSendingInfo = await getTenantSendingInfoForDomainOrParent( + userId, + fromDomain, + parentDomain || undefined, + ); + } + + if (tenantSendingInfo.identityArn) { + console.log( + `🏢 Using SourceArn for ${label} tenant tracking: ${tenantSendingInfo.identityArn}`, + ); + } else { + console.warn( + `⚠️ No SourceArn available - ${label} will not be tracked at tenant level`, + ); + } + + if (tenantSendingInfo.configurationSetName) { + console.log( + `📋 Using ConfigurationSet for ${label} tenant tracking: ${tenantSendingInfo.configurationSetName}`, + ); + } else { + console.warn( + `⚠️ No ConfigurationSet available - ${label} metrics may not be tracked correctly`, + ); + } + + if (tenantSendingInfo.tenantName) { + console.log( + `🏠 Using TenantName for ${label} AWS SES tracking: ${tenantSendingInfo.tenantName}`, + ); + } else { + console.warn( + `⚠️ No TenantName available - ${label} will NOT appear in tenant dashboard!`, + ); + } + + return tenantSendingInfo; +} diff --git a/app/api/webhooks/send-email/route.ts b/app/api/webhooks/send-email/route.ts index bf72e827..6d89741a 100644 --- a/app/api/webhooks/send-email/route.ts +++ b/app/api/webhooks/send-email/route.ts @@ -1,15 +1,10 @@ -import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; import { Receiver } from "@upstash/qstash"; import { waitUntil } from "@vercel/functions"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; import { type NextRequest, NextResponse } from "next/server"; import type { PostEmailsRequest } from "@/lib/api-types"; -import { - getAgentIdentityArn, - getTenantSendingInfoForDomainOrParent, - type TenantSendingInfo, -} from "@/lib/aws-ses/identity-arn-helper"; +import { getSesClient } from "@/lib/aws-ses/ses-client"; import { db } from "@/lib/db"; import { SCHEDULED_EMAIL_STATUS, @@ -17,7 +12,6 @@ import { scheduledEmails, sentEmails, } from "@/lib/db/schema"; -import { getRootDomain, isSubdomain } from "@/lib/domains-and-dns/domain-utils"; import { canUserSendFromEmail, extractEmailAddress, @@ -25,7 +19,10 @@ import { import { evaluateSending } from "@/lib/email-management/email-evaluation"; import { enforceOutboundSendGuard } from "@/lib/email-management/outbound-send-guard"; import { checkSendingSpike } from "@/lib/email-management/sending-spike-detector"; +import { normalizeAttachments } from "@/lib/utils/attachment-utils"; import { buildRawEmailMessage } from "../../e2/helper/email-builder"; +import { buildSesCommand } from "./build-ses-command"; +import { resolveTenantInfo } from "./resolve-tenant-info"; /** * POST /api/webhooks/send-email @@ -39,26 +36,7 @@ import { buildRawEmailMessage } from "../../e2/helper/email-builder"; * Has types? ✅ */ -// Initialize SES client -const awsRegion = process.env.AWS_REGION || "us-east-2"; -const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; -const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - -let sesClient: SESv2Client | null = null; - -if (awsAccessKeyId && awsSecretAccessKey) { - sesClient = new SESv2Client({ - region: awsRegion, - credentials: { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - }, - }); -} else { - console.warn( - "⚠️ AWS credentials not configured. Scheduled email processing will not work.", - ); -} +const sesClient = getSesClient(); // Initialize QStash receiver for signature verification const qstashReceiver = new Receiver({ @@ -76,13 +54,6 @@ interface QStashPayload { batchIndex?: number; // for batch } -interface StoredAttachment { - filename?: string; - contentType?: string; - content_type?: string; - [key: string]: unknown; -} - export async function POST(request: NextRequest) { console.log("📨 QStash Webhook - Received scheduled email request"); @@ -257,71 +228,7 @@ async function handleScheduledEmail(payload: QStashPayload) { : []; // Validate and fix attachment data - ensure contentType is set - const attachments = rawAttachments.map( - (att: StoredAttachment, index: number) => { - if (!att.contentType && !att.content_type) { - console.log( - `⚠️ Attachment ${index + 1} missing contentType, using fallback`, - ); - const filename = att.filename || "unknown"; - const ext = filename.toLowerCase().split(".").pop(); - let contentType = "application/octet-stream"; - - // Common file type mappings - switch (ext) { - case "pdf": - contentType = "application/pdf"; - break; - case "jpg": - case "jpeg": - contentType = "image/jpeg"; - break; - case "png": - contentType = "image/png"; - break; - case "gif": - contentType = "image/gif"; - break; - case "txt": - contentType = "text/plain"; - break; - case "html": - contentType = "text/html"; - break; - case "json": - contentType = "application/json"; - break; - case "zip": - contentType = "application/zip"; - break; - case "doc": - contentType = "application/msword"; - break; - case "docx": - contentType = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - break; - case "xls": - contentType = "application/vnd.ms-excel"; - break; - case "xlsx": - contentType = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - break; - } - - return { - ...att, - contentType: contentType, - }; - } - - return { - ...att, - contentType: att.contentType || att.content_type, - }; - }, - ); + const attachments = normalizeAttachments(rawAttachments); // Create sent email record first (for tracking) const sentEmailId = nanoid(); @@ -365,95 +272,25 @@ async function handleScheduledEmail(payload: QStashPayload) { textBody: scheduledEmail.textBody || undefined, htmlBody: scheduledEmail.htmlBody || undefined, customHeaders: headers, - attachments: attachments, + attachments, date: new Date(), }); - // Get the tenant sending info (identity ARN, configuration set, and tenant name) for tenant-level tracking - const fromDomain = scheduledEmail.fromDomain; - - let tenantSendingInfo: TenantSendingInfo = { - identityArn: null, - configurationSetName: null, - tenantName: null, - }; - if (isAgentEmail) { - tenantSendingInfo = { - identityArn: getAgentIdentityArn(), - configurationSetName: null, - tenantName: null, - }; - } else { - const parentDomain = isSubdomain(fromDomain) - ? getRootDomain(fromDomain) - : undefined; - tenantSendingInfo = await getTenantSendingInfoForDomainOrParent( - scheduledEmail.userId, - fromDomain, - parentDomain || undefined, - ); - } - - if (tenantSendingInfo.identityArn) { - console.log( - `🏢 Using SourceArn for scheduled email tenant tracking: ${tenantSendingInfo.identityArn}`, - ); - } else { - console.warn( - "⚠️ No SourceArn available - scheduled email will not be tracked at tenant level", - ); - } - - if (tenantSendingInfo.configurationSetName) { - console.log( - `📋 Using ConfigurationSet for scheduled email tenant tracking: ${tenantSendingInfo.configurationSetName}`, - ); - } else { - console.warn( - "⚠️ No ConfigurationSet available - scheduled email metrics may not be tracked correctly", - ); - } - - if (tenantSendingInfo.tenantName) { - console.log( - `🏠 Using TenantName for scheduled email AWS SES tracking: ${tenantSendingInfo.tenantName}`, - ); - } else { - console.warn( - "⚠️ No TenantName available - scheduled email will NOT appear in tenant dashboard!", - ); - } + // Resolve tenant sending info and build SES command + const tenantSendingInfo = await resolveTenantInfo( + scheduledEmail.userId, + scheduledEmail.fromDomain, + isAgentEmail, + "scheduled email", + ); - // Send via AWS SES using SESv2 SendEmailCommand with TenantName - // Per AWS docs: https://docs.aws.amazon.com/ses/latest/dg/tenants.html - // Use full fromAddress (with display name) for proper sender name display - const rawCommand = new SendEmailCommand({ - FromEmailAddress: scheduledEmail.fromAddress, - ...(tenantSendingInfo.identityArn && { - FromEmailAddressIdentityArn: tenantSendingInfo.identityArn, - }), - Destination: { - ToAddresses: toAddresses.map(extractEmailAddress), - CcAddresses: - ccAddresses.length > 0 - ? ccAddresses.map(extractEmailAddress) - : undefined, - BccAddresses: - bccAddresses.length > 0 - ? bccAddresses.map(extractEmailAddress) - : undefined, - }, - Content: { - Raw: { - Data: Buffer.from(rawMessage), - }, - }, - ...(tenantSendingInfo.configurationSetName && { - ConfigurationSetName: tenantSendingInfo.configurationSetName, - }), - ...(tenantSendingInfo.tenantName && { - TenantName: tenantSendingInfo.tenantName, - }), + const rawCommand = buildSesCommand({ + fromAddress: scheduledEmail.fromAddress, + toAddresses, + ccAddresses, + bccAddresses, + rawMessage, + tenantSendingInfo, }); const sesResponse = await sesClient.send(rawCommand); @@ -666,71 +503,7 @@ async function handleBatchEmail( : []; // Validate and fix attachment data - ensure contentType is set - const attachments = rawAttachments.map( - (att: StoredAttachment, index: number) => { - if (!att.contentType && !att.content_type) { - console.log( - `⚠️ Attachment ${index + 1} missing contentType, using fallback`, - ); - const filename = att.filename || "unknown"; - const ext = filename.toLowerCase().split(".").pop(); - let contentType = "application/octet-stream"; - - // Common file type mappings - switch (ext) { - case "pdf": - contentType = "application/pdf"; - break; - case "jpg": - case "jpeg": - contentType = "image/jpeg"; - break; - case "png": - contentType = "image/png"; - break; - case "gif": - contentType = "image/gif"; - break; - case "txt": - contentType = "text/plain"; - break; - case "html": - contentType = "text/html"; - break; - case "json": - contentType = "application/json"; - break; - case "zip": - contentType = "application/zip"; - break; - case "doc": - contentType = "application/msword"; - break; - case "docx": - contentType = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - break; - case "xls": - contentType = "application/vnd.ms-excel"; - break; - case "xlsx": - contentType = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - break; - } - - return { - ...att, - contentType: contentType, - }; - } - - return { - ...att, - contentType: att.contentType || att.content_type, - }; - }, - ); + const attachments = normalizeAttachments(rawAttachments); // Build raw email message console.log("📧 Building raw email message for batch email"); @@ -744,95 +517,25 @@ async function handleBatchEmail( textBody: sentEmail.textBody || undefined, htmlBody: sentEmail.htmlBody || undefined, customHeaders: headers, - attachments: attachments, + attachments, date: new Date(), }); - // Get the tenant sending info (identity ARN, configuration set, and tenant name) for tenant-level tracking - const batchFromDomain = sentEmail.fromDomain; - - let batchTenantInfo: TenantSendingInfo = { - identityArn: null, - configurationSetName: null, - tenantName: null, - }; - if (batchIsAgentEmail) { - batchTenantInfo = { - identityArn: getAgentIdentityArn(), - configurationSetName: null, - tenantName: null, - }; - } else { - const batchParentDomain = isSubdomain(batchFromDomain) - ? getRootDomain(batchFromDomain) - : undefined; - batchTenantInfo = await getTenantSendingInfoForDomainOrParent( - effectiveUserId, - batchFromDomain, - batchParentDomain || undefined, - ); - } - - if (batchTenantInfo.identityArn) { - console.log( - `🏢 Using SourceArn for batch email tenant tracking: ${batchTenantInfo.identityArn}`, - ); - } else { - console.warn( - "⚠️ No SourceArn available - batch email will not be tracked at tenant level", - ); - } - - if (batchTenantInfo.configurationSetName) { - console.log( - `📋 Using ConfigurationSet for batch email tenant tracking: ${batchTenantInfo.configurationSetName}`, - ); - } else { - console.warn( - "⚠️ No ConfigurationSet available - batch email metrics may not be tracked correctly", - ); - } - - if (batchTenantInfo.tenantName) { - console.log( - `🏠 Using TenantName for batch email AWS SES tracking: ${batchTenantInfo.tenantName}`, - ); - } else { - console.warn( - "⚠️ No TenantName available - batch email will NOT appear in tenant dashboard!", - ); - } + // Resolve tenant sending info and build SES command + const batchTenantInfo = await resolveTenantInfo( + effectiveUserId, + sentEmail.fromDomain, + batchIsAgentEmail, + "batch email", + ); - // Send via AWS SES using SESv2 SendEmailCommand with TenantName - // Per AWS docs: https://docs.aws.amazon.com/ses/latest/dg/tenants.html - // Use sentEmail.from (with display name) for proper sender name display - const rawCommand = new SendEmailCommand({ - FromEmailAddress: sentEmail.from, - ...(batchTenantInfo.identityArn && { - FromEmailAddressIdentityArn: batchTenantInfo.identityArn, - }), - Destination: { - ToAddresses: toAddresses.map(extractEmailAddress), - CcAddresses: - ccAddresses.length > 0 - ? ccAddresses.map(extractEmailAddress) - : undefined, - BccAddresses: - bccAddresses.length > 0 - ? bccAddresses.map(extractEmailAddress) - : undefined, - }, - Content: { - Raw: { - Data: Buffer.from(rawMessage), - }, - }, - ...(batchTenantInfo.configurationSetName && { - ConfigurationSetName: batchTenantInfo.configurationSetName, - }), - ...(batchTenantInfo.tenantName && { - TenantName: batchTenantInfo.tenantName, - }), + const rawCommand = buildSesCommand({ + fromAddress: sentEmail.from, + toAddresses, + ccAddresses, + bccAddresses, + rawMessage, + tenantSendingInfo: batchTenantInfo, }); const sesResponse = await sesClient.send(rawCommand); diff --git a/bun.lockb b/bun.lockb index 56194634..9c762cab 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/features/settings/hooks/useApiKeyMutations.ts b/features/settings/hooks/useApiKeyMutations.ts index 41b3158f..785331aa 100644 --- a/features/settings/hooks/useApiKeyMutations.ts +++ b/features/settings/hooks/useApiKeyMutations.ts @@ -1,6 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { + CreateApiKeyData, + UpdateApiKeyData, +} from "@/features/settings/types"; import { authClient } from "@/lib/auth/auth-client"; -import { CreateApiKeyData, UpdateApiKeyData } from "@/features/settings/types"; export const useCreateApiKeyMutation = () => { const queryClient = useQueryClient(); @@ -98,11 +101,11 @@ export const useRevokeAllApiKeysMutation = () => { throw new Error(listError.message); } - if (!apiKeys || apiKeys.length === 0) { + if (!apiKeys?.apiKeys || apiKeys.apiKeys.length === 0) { return { count: 0 }; } - const enabledKeys = apiKeys.filter((key) => key.enabled); + const enabledKeys = apiKeys.apiKeys.filter((key) => key.enabled); if (enabledKeys.length === 0) { return { count: 0 }; } diff --git a/features/settings/hooks/useApiKeysQuery.ts b/features/settings/hooks/useApiKeysQuery.ts index 5b5e292a..fd1319f6 100644 --- a/features/settings/hooks/useApiKeysQuery.ts +++ b/features/settings/hooks/useApiKeysQuery.ts @@ -1,17 +1,17 @@ -import { useQuery } from '@tanstack/react-query' -import { authClient } from '@/lib/auth/auth-client' +import { useQuery } from "@tanstack/react-query"; +import { authClient } from "@/lib/auth/auth-client"; export const useApiKeysQuery = () => { - return useQuery({ - queryKey: ['apiKeys'], - queryFn: async () => { - const { data, error } = await authClient.apiKey.list() - if (error) { - throw new Error(error.message) - } - return data || [] - }, - staleTime: 3 * 60 * 1000, // 3 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - }) -} \ No newline at end of file + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const { data, error } = await authClient.apiKey.list(); + if (error) { + throw new Error(error.message); + } + return data?.apiKeys || []; + }, + staleTime: 3 * 60 * 1000, // 3 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }); +}; diff --git a/lib/auth/auth-client.ts b/lib/auth/auth-client.ts index 6eb529b9..577a2d0b 100644 --- a/lib/auth/auth-client.ts +++ b/lib/auth/auth-client.ts @@ -1,9 +1,7 @@ +import { apiKeyClient } from "@better-auth/api-key/client"; +import { sentinelClient } from "@better-auth/infra/client"; import { passkeyClient } from "@better-auth/passkey/client"; -import { - adminClient, - apiKeyClient, - magicLinkClient, -} from "better-auth/client/plugins"; +import { adminClient, magicLinkClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ @@ -13,7 +11,13 @@ export const authClient = createAuthClient({ : process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://inbound.new", - plugins: [adminClient(), apiKeyClient(), magicLinkClient(), passkeyClient()], + plugins: [ + adminClient(), + apiKeyClient(), + magicLinkClient(), + passkeyClient(), + sentinelClient(), + ], }); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/lib/auth/auth.ts b/lib/auth/auth.ts index d0c01fc5..f41a412f 100644 --- a/lib/auth/auth.ts +++ b/lib/auth/auth.ts @@ -1,13 +1,16 @@ +import { apiKey } from "@better-auth/api-key"; +import { dash } from "@better-auth/infra"; import { passkey } from "@better-auth/passkey"; import { render } from "@react-email/components"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { createAuthMiddleware } from "better-auth/api"; -import { admin, apiKey, magicLink, oAuthProxy } from "better-auth/plugins"; +import { admin, magicLink, oAuthProxy } from "better-auth/plugins"; import { and, eq } from "drizzle-orm"; import Inbound from "inboundemail"; import MagicLinkEmail from "@/emails/magic-link-email"; +import { extractDomainFromEmail } from "@/lib/utils/email-utils"; import { db } from "../db/index"; import * as schema from "../db/schema"; @@ -73,7 +76,7 @@ const inbound = new Inbound({ * Check if an email domain is blocked from signing up */ async function isBlockedEmailDomain(email: string): Promise { - const domain = email.split("@")[1]?.toLowerCase(); + const domain = extractDomainFromEmail(email); if (!domain) return false; if (BLOCKED_SIGNUP_DOMAINS.includes(domain)) { @@ -203,6 +206,7 @@ export const auth = betterAuth({ } }, }), + dash(), ], hooks: { before: createAuthMiddleware(async (ctx) => { diff --git a/lib/auth/v2-auth.ts b/lib/auth/v2-auth.ts index f1a50a99..3dcf08b5 100644 --- a/lib/auth/v2-auth.ts +++ b/lib/auth/v2-auth.ts @@ -1,201 +1,207 @@ /** * V2 API Authentication Utility - * + * * Provides unified authentication for v2 API routes supporting both: * 1. Session-based authentication (for web app users) * 2. API key authentication (for programmatic access) - * + * * Following the API management rules, this utility checks session first, * then falls back to API key authentication if no session is found. */ -import { NextRequest } from 'next/server' -import { auth } from '@/lib/auth/auth' -import { headers } from 'next/headers' +import { headers } from "next/headers"; +import type { NextRequest } from "next/server"; +import { auth } from "@/lib/auth/auth"; export interface AuthenticationResult { - success: boolean - user?: { - id: string - email: string - name: string | null - } - authType?: 'session' | 'api_key' - error?: string + success: boolean; + user?: { + id: string; + email: string; + name: string | null; + }; + authType?: "session" | "api_key"; + error?: string; } /** * Unified authentication for v2 API routes * Checks session first, then API key if no session found */ -export async function authenticateV2Request(request: NextRequest): Promise { - try { - // Step 1: Try session-based authentication first - const sessionResult = await trySessionAuth() - if (sessionResult.success) { - return { - ...sessionResult, - authType: 'session' - } - } - - // Step 2: If no session, try API key authentication - const apiKeyResult = await tryApiKeyAuth(request) - if (apiKeyResult.success) { - return { - ...apiKeyResult, - authType: 'api_key' - } - } - - // Step 3: Both authentication methods failed - return { - success: false, - error: 'Authentication required. Please provide a valid session or API key.' - } - } catch (error) { - console.error('V2 API authentication error:', error) - return { - success: false, - error: 'Internal authentication error' - } - } +export async function authenticateV2Request( + request: NextRequest, +): Promise { + try { + // Step 1: Try session-based authentication first + const sessionResult = await trySessionAuth(); + if (sessionResult.success) { + return { + ...sessionResult, + authType: "session", + }; + } + + // Step 2: If no session, try API key authentication + const apiKeyResult = await tryApiKeyAuth(request); + if (apiKeyResult.success) { + return { + ...apiKeyResult, + authType: "api_key", + }; + } + + // Step 3: Both authentication methods failed + return { + success: false, + error: + "Authentication required. Please provide a valid session or API key.", + }; + } catch (error) { + console.error("V2 API authentication error:", error); + return { + success: false, + error: "Internal authentication error", + }; + } } /** * Try session-based authentication */ async function trySessionAuth(): Promise { - try { - const session = await auth.api.getSession({ - headers: await headers() - }) - - if (!session?.user?.id) { - return { - success: false, - error: 'No valid session found' - } - } - - return { - success: true, - user: { - id: session.user.id, - email: session.user.email || '', - name: session.user.name || null - } - } - } catch (error) { - return { - success: false, - error: 'Session validation failed' - } - } + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.user?.id) { + return { + success: false, + error: "No valid session found", + }; + } + + return { + success: true, + user: { + id: session.user.id, + email: session.user.email || "", + name: session.user.name || null, + }, + }; + } catch (error) { + return { + success: false, + error: "Session validation failed", + }; + } } /** * Try API key authentication */ -async function tryApiKeyAuth(request: NextRequest): Promise { - try { - // Get the Authorization header - const authHeader = request.headers.get('Authorization') - - if (!authHeader) { - return { - success: false, - error: 'No Authorization header found' - } - } - - // Extract the API key (support both "Bearer " and just "") - let apiKey: string - if (authHeader.startsWith('Bearer ')) { - apiKey = authHeader.substring(7) - } else { - apiKey = authHeader - } - - if (!apiKey) { - return { - success: false, - error: 'Invalid Authorization header format' - } - } - - // Verify the API key using Better Auth - const { valid, error, key } = await auth.api.verifyApiKey({ - body: { - key: apiKey - } - }) - - if (!valid || error || !key) { - return { - success: false, - error: error?.message || 'Invalid API key' - } - } - - // Check if the API key is enabled - if (!key.enabled) { - return { - success: false, - error: 'API key is disabled' - } - } - - // Check if the API key has expired - if (key.expiresAt && new Date(key.expiresAt) < new Date()) { - return { - success: false, - error: 'API key has expired' - } - } - - return { - success: true, - user: { - id: key.userId, - email: key.userId, // We don't have email from API key, use userId as fallback - name: null // We don't have name from API key - } - } - } catch (error) { - console.error('API key authentication error:', error) - return { - success: false, - error: 'API key validation failed' - } - } +async function tryApiKeyAuth( + request: NextRequest, +): Promise { + try { + // Get the Authorization header + const authHeader = request.headers.get("Authorization"); + + if (!authHeader) { + return { + success: false, + error: "No Authorization header found", + }; + } + + // Extract the API key (support both "Bearer " and just "") + let apiKey: string; + if (authHeader.startsWith("Bearer ")) { + apiKey = authHeader.substring(7); + } else { + apiKey = authHeader; + } + + if (!apiKey) { + return { + success: false, + error: "Invalid Authorization header format", + }; + } + + // Verify the API key using Better Auth + const { valid, error, key } = await auth.api.verifyApiKey({ + body: { + key: apiKey, + }, + }); + + if (!valid || error || !key) { + return { + success: false, + error: (error?.message as string) || "Invalid API key", + }; + } + + // Check if the API key is enabled + if (!key.enabled) { + return { + success: false, + error: "API key is disabled", + }; + } + + // Check if the API key has expired + if (key.expiresAt && new Date(key.expiresAt) < new Date()) { + return { + success: false, + error: "API key has expired", + }; + } + + return { + success: true, + user: { + id: key.referenceId, + email: key.referenceId, // We don't have email from API key, use referenceId as fallback + name: null, // We don't have name from API key + }, + }; + } catch (error) { + console.error("API key authentication error:", error); + return { + success: false, + error: "API key validation failed", + }; + } } /** * Get authentication error response with proper status code */ export function getAuthErrorResponse(authResult: AuthenticationResult): { - error: string - details?: string - status: number + error: string; + details?: string; + status: number; } { - if (authResult.error?.includes('expired')) { - return { - error: authResult.error, - status: 401 - } - } - - if (authResult.error?.includes('disabled')) { - return { - error: authResult.error, - status: 403 - } - } - - return { - error: authResult.error || 'Authentication failed', - details: 'Please provide a valid session or API key in the Authorization header', - status: 401 - } -} \ No newline at end of file + if (authResult.error?.includes("expired")) { + return { + error: authResult.error, + status: 401, + }; + } + + if (authResult.error?.includes("disabled")) { + return { + error: authResult.error, + status: 403, + }; + } + + return { + error: authResult.error || "Authentication failed", + details: + "Please provide a valid session or API key in the Authorization header", + status: 401, + }; +} diff --git a/lib/aws-ses/ses-client.ts b/lib/aws-ses/ses-client.ts new file mode 100644 index 00000000..d326a308 --- /dev/null +++ b/lib/aws-ses/ses-client.ts @@ -0,0 +1,48 @@ +/** + * Shared SES v2 client factory + * + * Provides a cached SESv2Client instance so every module that sends email + * doesn't repeat the credential-loading boilerplate. + */ + +import { SESv2Client } from "@aws-sdk/client-sesv2"; + +const awsRegion = process.env.AWS_REGION || "us-east-2"; +const awsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; +const awsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + +let cachedClient: SESv2Client | null = null; + +/** + * Returns the shared SESv2Client, or `null` if credentials are missing. + */ +export function getSesClient(): SESv2Client | null { + if (cachedClient) return cachedClient; + + if (!awsAccessKeyId || !awsSecretAccessKey) { + return null; + } + + cachedClient = new SESv2Client({ + region: awsRegion, + credentials: { + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + }, + }); + + return cachedClient; +} + +/** + * Returns the shared SESv2Client, throwing if credentials are missing. + */ +export function requireSesClient(): SESv2Client { + const client = getSesClient(); + if (!client) { + throw new Error( + "AWS SES credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY missing)", + ); + } + return client; +} diff --git a/lib/email-management/agent-email-helper.test.ts b/lib/email-management/agent-email-helper.test.ts new file mode 100644 index 00000000..62ffc6d4 --- /dev/null +++ b/lib/email-management/agent-email-helper.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { + isAgentEmail, + canUserSendFromEmail, +} from "@/lib/email-management/agent-email-helper"; + +describe("isAgentEmail", () => { + it("returns true for exact agent@inbnd.dev", () => { + expect(isAgentEmail("agent@inbnd.dev")).toBe(true); + }); + + it("is case insensitive", () => { + expect(isAgentEmail("Agent@INBND.DEV")).toBe(true); + }); + + it("handles Name format", () => { + expect(isAgentEmail("Inbound Agent ")).toBe(true); + }); + + it("returns false for non-agent address", () => { + expect(isAgentEmail("user@example.com")).toBe(false); + }); + + it("returns false for similar but different address", () => { + expect(isAgentEmail("agent@inbnd.com")).toBe(false); + }); +}); + +describe("canUserSendFromEmail", () => { + it("identifies agent email", () => { + const result = canUserSendFromEmail("agent@inbnd.dev"); + expect(result.isAgentEmail).toBe(true); + expect(result.domain).toBe("inbnd.dev"); + }); + + it("identifies custom domain", () => { + const result = canUserSendFromEmail("user@custom.com"); + expect(result.isAgentEmail).toBe(false); + expect(result.domain).toBe("custom.com"); + }); + + it("handles Name format", () => { + const result = canUserSendFromEmail("My Name "); + expect(result.isAgentEmail).toBe(false); + expect(result.domain).toBe("mydomain.io"); + }); +}); diff --git a/lib/email-management/agent-email-helper.ts b/lib/email-management/agent-email-helper.ts index d3bc0a81..2fc8250a 100644 --- a/lib/email-management/agent-email-helper.ts +++ b/lib/email-management/agent-email-helper.ts @@ -3,55 +3,18 @@ * This email can be used by any user for sending emails through the v2 APIs */ -/** - * Check if an email address is the special agent@inbnd.dev address - */ -export function isAgentEmail(email: string): boolean { - // Extract just the email address part, removing any name formatting - const emailMatch = email.match(/<([^>]+)>/) || [null, email] - const cleanEmail = emailMatch[1] || email - - return cleanEmail.toLowerCase() === 'agent@inbnd.dev' -} +import { extractDomainFromEmail, extractEmailAddress } from "@/lib/utils/email-utils"; -/** - * Extract domain from email address - */ -export function extractDomain(email: string): string { - // Extract just the email address part, removing any name formatting - const emailMatch = email.match(/<([^>]+)>/) || [null, email] - const cleanEmail = emailMatch[1] || email - - const parts = cleanEmail.split('@') - return parts.length === 2 ? parts[1].toLowerCase() : '' -} +// Re-export shared utilities so existing callers don't break +export { extractEmailAddress, extractEmailName } from "@/lib/utils/email-utils"; +export { extractDomainFromEmail as extractDomain } from "@/lib/utils/email-utils"; /** - * Extract email address from formatted email (removes name part) - */ -export function extractEmailAddress(email: string): string { - // Handle "Name " format - const emailMatch = email.match(/<([^>]+)>/) - if (emailMatch) { - return emailMatch[1] - } - - // Handle plain "email@domain.com" format - return email -} - -/** - * Extract name from formatted email (removes email part) + * Check if an email address is the special agent@inbnd.dev address */ -export function extractEmailName(email: string): string | null { - // Handle "Name " format - const nameMatch = email.match(/^(.+?)\s*<[^>]+>$/) - if (nameMatch) { - return nameMatch[1].trim().replace(/^["']|["']$/g, '') // Remove quotes if present - } - - // No name part found - return null +export function isAgentEmail(email: string): boolean { + const cleanEmail = extractEmailAddress(email); + return cleanEmail.toLowerCase() === "agent@inbnd.dev"; } /** @@ -60,12 +23,15 @@ export function extractEmailName(email: string): string | null { * 1. The email is agent@inbnd.dev (allowed for all users) * 2. The user owns the domain (checked separately in the API) */ -export function canUserSendFromEmail(email: string): { isAgentEmail: boolean; domain: string } { - const domain = extractDomain(email) - const isAgent = isAgentEmail(email) - - return { - isAgentEmail: isAgent, - domain - } +export function canUserSendFromEmail(email: string): { + isAgentEmail: boolean; + domain: string; +} { + const domain = extractDomainFromEmail(email); + const isAgent = isAgentEmail(email); + + return { + isAgentEmail: isAgent, + domain, + }; } diff --git a/lib/email-management/delivery-event-tracker.test.ts b/lib/email-management/delivery-event-tracker.test.ts new file mode 100644 index 00000000..1aaac36d --- /dev/null +++ b/lib/email-management/delivery-event-tracker.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "bun:test"; +import { getBounceSubType } from "@/lib/email-management/delivery-event-tracker"; + +describe("getBounceSubType", () => { + it("returns UNKNOWN when statusCode is undefined", () => { + expect(getBounceSubType(undefined, undefined)).toBe("unknown"); + }); + + it("maps 5.1.1 → user_unknown", () => { + expect(getBounceSubType("5.1.1", undefined)).toBe("user_unknown"); + }); + + it("maps 5.1.2 → bad_destination", () => { + expect(getBounceSubType("5.1.2", undefined)).toBe("bad_destination"); + }); + + it("maps 5.2.1 → mailbox_disabled", () => { + expect(getBounceSubType("5.2.1", undefined)).toBe("mailbox_disabled"); + }); + + it("maps 5.2.2 → mailbox_full", () => { + expect(getBounceSubType("5.2.2", undefined)).toBe("mailbox_full"); + }); + + it("maps 5.3.4 → message_too_large", () => { + expect(getBounceSubType("5.3.4", undefined)).toBe("message_too_large"); + }); + + it("maps 5.4.4 → invalid_domain", () => { + expect(getBounceSubType("5.4.4", undefined)).toBe("invalid_domain"); + }); + + it("maps 5.7.1 → policy_rejection", () => { + expect(getBounceSubType("5.7.1", undefined)).toBe("policy_rejection"); + }); + + it("maps 5.6.1 → content_rejected", () => { + expect(getBounceSubType("5.6.1", undefined)).toBe("content_rejected"); + }); + + it("maps 4.2.2 → mailbox_full", () => { + expect(getBounceSubType("4.2.2", undefined)).toBe("mailbox_full"); + }); + + it("maps 4.4.4 → dns_failure", () => { + expect(getBounceSubType("4.4.4", undefined)).toBe("dns_failure"); + }); + + it("maps 4.4.7 → delivery_timeout", () => { + expect(getBounceSubType("4.4.7", undefined)).toBe("delivery_timeout"); + }); + + it("maps 4.4.1 → connection_failed", () => { + expect(getBounceSubType("4.4.1", undefined)).toBe("connection_failed"); + }); + + it("overrides with suppression_list when diagnostic mentions it", () => { + expect( + getBounceSubType( + "5.1.1", + "550 Address is on the suppression list for this account", + ), + ).toBe("suppression_list"); + }); + + it("returns general_failure for unrecognized status codes", () => { + expect(getBounceSubType("5.9.9", undefined)).toBe("general_failure"); + }); +}); diff --git a/lib/email-management/delivery-event-tracker.ts b/lib/email-management/delivery-event-tracker.ts index b3bc3fdb..81c9c49b 100644 --- a/lib/email-management/delivery-event-tracker.ts +++ b/lib/email-management/delivery-event-tracker.ts @@ -22,6 +22,7 @@ import { emailDeliveryEvents, type NewEmailDeliveryEvent, } from "@/lib/db/schema"; +import { extractDomainFromEmail } from "@/lib/utils/email-utils"; import { dispatchEmailBouncedEvent } from "@/lib/svix/event-dispatcher"; import { getDsnSourceInfo, parseDsn } from "./dsn-parser"; @@ -58,7 +59,7 @@ export interface RecordDeliveryEventResult { /** * Determine the bounce sub-type from status code */ -function getBounceSubType( +export function getBounceSubType( statusCode: string | undefined, diagnosticCode: string | undefined, ): BounceSubType { @@ -90,15 +91,6 @@ function getBounceSubType( return statusMap[statusCode] || BOUNCE_SUB_TYPES.GENERAL_FAILURE; } -/** - * Extract domain from email address - */ -function extractDomain(email: string): string | undefined { - const atIndex = email.indexOf("@"); - if (atIndex === -1) return undefined; - return email.substring(atIndex + 1).toLowerCase(); -} - /** * Record a delivery event from a DSN */ @@ -145,7 +137,7 @@ export async function recordDeliveryEventFromDsn( } const eventId = `evt_${nanoid()}`; - const failedRecipientDomain = extractDomain(failedRecipient); + const failedRecipientDomain = extractDomainFromEmail(failedRecipient) || undefined; // Prepare the event record const eventRecord: NewEmailDeliveryEvent = { diff --git a/lib/email-management/dsn-parser.test.ts b/lib/email-management/dsn-parser.test.ts index f96c27ee..02beafee 100644 --- a/lib/email-management/dsn-parser.test.ts +++ b/lib/email-management/dsn-parser.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { parseDsn } from "@/lib/email-management/dsn-parser"; +import { + isDsn, + parseDsn, + quickIsDsnCheck, +} from "@/lib/email-management/dsn-parser"; const SES_MESSAGE_ID = "010f019ae693fb1b-50675262-d740-487c-97b8-e6de49d2e104-000000"; @@ -50,4 +54,52 @@ describe("dsn-parser", () => { `${SES_MESSAGE_ID}@us-east-2.amazonses.com`, ); }); + + it("classifies 5.x.x as permanent failure (hard bounce)", async () => { + const dsn = await parseDsn(buildDsnRawContentWithLfOnly()); + expect(dsn.statusClass).toBe("5"); + expect(dsn.bounceType).toBe("hard"); + }); + + it("extracts diagnostic code", async () => { + const dsn = await parseDsn(buildDsnRawContentWithLfOnly()); + expect(dsn.deliveryStatus?.diagnosticCode).toContain("550 5.1.1"); + }); +}); + +describe("isDsn", () => { + it("returns true for content with delivery-status indicators", () => { + expect(isDsn(buildDsnRawContentWithLfOnly())).toBe(true); + }); + + it("returns true when headers indicate delivery-status", () => { + expect( + isDsn("plain body", { + "content-type": { + value: "multipart/report", + params: { "report-type": "delivery-status" }, + }, + }), + ).toBe(true); + }); + + it("returns false for regular email content", () => { + expect(isDsn("Hello, this is a normal email.")).toBe(false); + }); +}); + +describe("quickIsDsnCheck", () => { + it("extracts recipient and status from DSN content", () => { + const result = quickIsDsnCheck(buildDsnRawContentWithLfOnly()); + expect(result.isDsn).toBe(true); + expect(result.finalRecipient).toBe("bounce-target@example.com"); + expect(result.status).toBe("5.1.1"); + expect(result.diagnosticCode).toContain("550 5.1.1"); + }); + + it("returns isDsn false for non-DSN content", () => { + const result = quickIsDsnCheck("Just a normal email."); + expect(result.isDsn).toBe(false); + expect(result.finalRecipient).toBeUndefined(); + }); }); diff --git a/lib/email-management/email-blocking.ts b/lib/email-management/email-blocking.ts index 7c12b717..48eee4d9 100644 --- a/lib/email-management/email-blocking.ts +++ b/lib/email-management/email-blocking.ts @@ -2,13 +2,7 @@ import { db } from '@/lib/db' import { blockedEmails, emailDomains, emailAddresses } from '@/lib/db/schema' import { eq, and } from 'drizzle-orm' import { nanoid } from 'nanoid' - -/** - * Extract domain from email address - */ -function extractDomain(email: string): string { - return email.split('@')[1]?.toLowerCase() || '' -} +import { extractDomainFromEmail, extractEmailAddress, isValidEmail } from '@/lib/utils/email-utils' /** * Check if an email address is already blocked @@ -43,10 +37,7 @@ export async function checkRecipientsAgainstBlocklist( // Normalize all email addresses const normalizedRecipients = recipients.map(email => { - // Extract email from "Name " format if needed - const match = email.match(/<([^>]+)>/) - const extracted = match ? match[1] : email - return extracted.toLowerCase().trim() + return extractEmailAddress(email).toLowerCase().trim() }) // Query all blocked addresses at once @@ -82,8 +73,7 @@ export async function blockEmail( ): Promise<{ success: boolean; error?: string; message?: string }> { try { // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ - if (!emailRegex.test(emailAddress)) { + if (!isValidEmail(emailAddress)) { return { success: false, error: 'Invalid email format' @@ -91,7 +81,7 @@ export async function blockEmail( } const normalizedEmail = emailAddress.toLowerCase() - const domain = extractDomain(normalizedEmail) + const domain = extractDomainFromEmail(normalizedEmail) if (!domain) { return { diff --git a/lib/email-management/email-forwarder.ts b/lib/email-management/email-forwarder.ts index 247e96f4..67c7ca31 100644 --- a/lib/email-management/email-forwarder.ts +++ b/lib/email-management/email-forwarder.ts @@ -1,4 +1,5 @@ -import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2' +import { type SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2' +import { requireSesClient } from '@/lib/aws-ses/ses-client' import type { ParsedEmailData } from './email-parser' import { generateEmailBannerHTML } from '@/components/email-banner' @@ -6,9 +7,7 @@ export class EmailForwarder { private sesClient: SESv2Client constructor() { - this.sesClient = new SESv2Client({ - region: process.env.AWS_REGION || 'us-east-2' - }) + this.sesClient = requireSesClient() } async forwardEmail( diff --git a/lib/email-management/email-parser.test.ts b/lib/email-management/email-parser.test.ts new file mode 100644 index 00000000..0506bf2e --- /dev/null +++ b/lib/email-management/email-parser.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test"; +import { + sanitizeHtml, + extractEmailDomain, + formatEmailAddress, + extractEmailAddress, + extractEmailAddresses, +} from "@/lib/email-management/email-parser"; + +describe("sanitizeHtml", () => { + it("removes script tags", () => { + const html = '
Safe
'; + const result = sanitizeHtml(html); + expect(result).not.toContain(" { + const html = '
Click
'; + const result = sanitizeHtml(html); + expect(result).not.toContain("onclick"); + }); + + it("removes single-quoted event handlers", () => { + const html = "
Hover
"; + const result = sanitizeHtml(html); + expect(result).not.toContain("onmouseover"); + }); + + it("removes javascript: URLs", () => { + const html = 'Click'; + const result = sanitizeHtml(html); + expect(result).not.toContain("javascript:"); + }); + + it("preserves data:image URLs", () => { + const html = + 'inline image'; + const result = sanitizeHtml(html); + expect(result).toContain("data:image/png"); + }); + + it("returns empty string for empty input", () => { + expect(sanitizeHtml("")).toBe(""); + }); +}); + +describe("extractEmailDomain", () => { + it("extracts domain from plain email", () => { + expect(extractEmailDomain("user@example.com")).toBe("example.com"); + }); + + it("extracts domain from angle-bracket email", () => { + expect(extractEmailDomain("")).toBe("example.com"); + }); + + it("returns empty string for no @ sign", () => { + expect(extractEmailDomain("nodomain")).toBe(""); + }); +}); + +describe("formatEmailAddress", () => { + it("parses Name format", () => { + const result = formatEmailAddress("John Doe "); + expect(result.name).toBe("John Doe"); + expect(result.address).toBe("john@example.com"); + }); + + it("parses quoted name format", () => { + const result = formatEmailAddress('"Jane Smith" '); + expect(result.name).toBe("Jane Smith"); + expect(result.address).toBe("jane@example.com"); + }); + + it("handles plain email", () => { + const result = formatEmailAddress("user@example.com"); + expect(result.name).toBe(""); + expect(result.address).toBe("user@example.com"); + }); +}); + +describe("extractEmailAddress (mailparser version)", () => { + it("returns unknown for null", () => { + expect(extractEmailAddress(null)).toBe("unknown"); + }); + + it("returns string input as-is", () => { + expect(extractEmailAddress("user@example.com")).toBe("user@example.com"); + }); + + it("extracts text from address object", () => { + expect(extractEmailAddress({ text: "John " })).toBe( + "John ", + ); + }); + + it("extracts from array of address objects", () => { + expect( + extractEmailAddress([{ text: "first@example.com" }]), + ).toBe("first@example.com"); + }); + + it("falls back to address field", () => { + expect(extractEmailAddress({ address: "addr@example.com" })).toBe( + "addr@example.com", + ); + }); + + it("falls back to name field", () => { + expect(extractEmailAddress({ name: "John" })).toBe("John"); + }); + + it("returns unknown for empty object", () => { + expect(extractEmailAddress({})).toBe("unknown"); + }); +}); + +describe("extractEmailAddresses", () => { + it("returns empty array for null", () => { + expect(extractEmailAddresses(null)).toEqual([]); + }); + + it("wraps string in array", () => { + expect(extractEmailAddresses("user@example.com")).toEqual([ + "user@example.com", + ]); + }); + + it("extracts from array of address objects", () => { + const result = extractEmailAddresses([ + { text: "a@example.com" }, + { address: "b@example.com" }, + ]); + expect(result).toEqual(["a@example.com", "b@example.com"]); + }); + + it("extracts from object with text field", () => { + expect(extractEmailAddresses({ text: "user@example.com" })).toEqual([ + "user@example.com", + ]); + }); +}); diff --git a/lib/email-management/email-router.ts b/lib/email-management/email-router.ts index 09b3530c..aeee31d3 100644 --- a/lib/email-management/email-router.ts +++ b/lib/email-management/email-router.ts @@ -23,12 +23,12 @@ import { evaluateGuardRules } from "../guard/rule-matcher"; import { checkRecipientsAgainstBlocklist } from "./email-blocking"; import { EmailForwarder } from "./email-forwarder"; import type { ParsedEmailData } from "./email-parser"; -import { sanitizeHtml } from "./email-parser"; import { EmailThreader, type ThreadingResult } from "./email-threader"; import { triggerEmailAction } from "./webhook-trigger"; - -// Maximum webhook payload size (5MB safety margin) -const MAX_WEBHOOK_PAYLOAD_SIZE = 1_000_000; +import { + constructWebhookPayload, + ensurePayloadSize, +} from "./webhook-payload"; /** * Main email routing function - routes emails to appropriate endpoints @@ -797,52 +797,13 @@ async function handleWebhookEndpoint( downloadUrl: `${baseUrl}/api/e2/attachments/${emailData.structuredId}/${encodeURIComponent(att.filename || "attachment")}`, })) || []; - // Create enhanced parsedData with download URLs - const enhancedParsedData = { - ...parsedEmailData, - attachments: attachmentsWithUrls, - }; - - // Create webhook payload with the exact structure expected - const webhookPayload = { - event: "email.received", - timestamp: new Date().toISOString(), - email: { - id: emailData.structuredId, // Use structured email ID for v2 API compatibility - messageId: emailData.messageId, - from: emailData.fromData ? JSON.parse(emailData.fromData) : null, - to: emailData.toData ? JSON.parse(emailData.toData) : null, - recipient: emailData.recipient, - subject: emailData.subject, - receivedAt: emailData.date, - - // Threading information - threadId: emailData.threadId || null, - threadPosition: emailData.threadPosition || null, - - // Full ParsedEmailData structure with download URLs - parsedData: enhancedParsedData, - - // Cleaned content for backward compatibility - cleanedContent: { - html: parsedEmailData.htmlBody - ? sanitizeHtml(parsedEmailData.htmlBody) - : null, - text: parsedEmailData.textBody || null, - hasHtml: !!parsedEmailData.htmlBody, - hasText: !!parsedEmailData.textBody, - attachments: attachmentsWithUrls, // Include download URLs in cleaned content too - headers: parsedEmailData.headers || {}, - }, - }, - endpoint: { - id: endpoint.id, - name: endpoint.name, - type: endpoint.type, - }, - }; - - const payloadString = JSON.stringify(webhookPayload); + // Build webhook payload + const webhookPayload = constructWebhookPayload( + emailData, + parsedEmailData, + attachmentsWithUrls, + endpoint, + ); // Prepare headers const headers: HeadersInit = { @@ -858,76 +819,8 @@ async function handleWebhookEndpoint( }; // Check payload size and strip fields if necessary - let finalPayload = webhookPayload; - let finalPayloadString = payloadString; - const strippedFields: string[] = []; - - if (payloadString.length > MAX_WEBHOOK_PAYLOAD_SIZE) { - console.warn( - `⚠️ handleWebhookEndpoint - Webhook payload too large (${payloadString.length} bytes), stripping attachment bodies from raw field`, - ); - - // Try stripping attachment bodies from raw field first - if (enhancedParsedData.raw) { - // Remove base64-encoded attachment bodies while preserving MIME structure and headers - // This regex finds ALL base64 content from header until next MIME boundary - const cleanedRaw = enhancedParsedData.raw.replace( - /Content-Transfer-Encoding:\s*base64\s*[\r\n]+[\r\n]+([\s\S]+?)(?=\r?\n--|\r?\n\r?\nContent-|$)/gi, - "Content-Transfer-Encoding: base64\r\n\r\n[binary attachment data removed - use Attachments API]\r\n", - ); - - const payloadWithCleanedRaw = { - ...webhookPayload, - email: { - ...webhookPayload.email, - parsedData: { - ...enhancedParsedData, - raw: cleanedRaw, - }, - }, - }; - const payloadStringWithCleanedRaw = JSON.stringify( - payloadWithCleanedRaw, - ); - - if (payloadStringWithCleanedRaw.length <= MAX_WEBHOOK_PAYLOAD_SIZE) { - finalPayload = payloadWithCleanedRaw; - finalPayloadString = payloadStringWithCleanedRaw; - strippedFields.push("raw (attachment bodies removed)"); - console.log( - `✅ handleWebhookEndpoint - Removed attachment bodies from raw field, new size: ${payloadStringWithCleanedRaw.length} bytes`, - ); - } else { - // Still too large, also strip headers - const payloadWithCleanedRawAndNoHeaders = { - ...payloadWithCleanedRaw, - email: { - ...payloadWithCleanedRaw.email, - parsedData: { - ...enhancedParsedData, - raw: cleanedRaw, - headers: {}, - }, - }, - }; - const payloadStringWithCleanedRawAndNoHeaders = JSON.stringify( - payloadWithCleanedRawAndNoHeaders, - ); - finalPayload = payloadWithCleanedRawAndNoHeaders; - finalPayloadString = payloadStringWithCleanedRawAndNoHeaders; - strippedFields.push("raw (attachment bodies removed)", "headers"); - console.warn( - `⚠️ handleWebhookEndpoint - Also removed headers, final size: ${payloadStringWithCleanedRawAndNoHeaders.length} bytes`, - ); - } - } - - if (strippedFields.length > 0) { - console.log( - `📋 handleWebhookEndpoint - Cleaned payload for ${endpoint.name}: ${strippedFields.join(", ")}`, - ); - } - } + const { payloadString: finalPayloadString, strippedFields } = + ensurePayloadSize(webhookPayload); // Send the webhook const startTime = Date.now(); diff --git a/lib/email-management/email-thread-parser.test.ts b/lib/email-management/email-thread-parser.test.ts new file mode 100644 index 00000000..bb3a764d --- /dev/null +++ b/lib/email-management/email-thread-parser.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "bun:test"; +import { + parseTextEmailContent, + parseHtmlEmailContent, + parseEmailContent, + splitIntoMessages, +} from "@/lib/email-management/email-thread-parser"; + +describe("parseTextEmailContent", () => { + it("returns empty content for empty input", () => { + const result = parseTextEmailContent(""); + expect(result.newContent).toBe(""); + expect(result.quotedContent).toBe(""); + expect(result.hasQuotedContent).toBe(false); + expect(result.quoteLevels).toBe(0); + }); + + it("returns all content as new when no quotes present", () => { + const result = parseTextEmailContent("Hello, this is a plain email."); + expect(result.newContent).toBe("Hello, this is a plain email."); + expect(result.hasQuotedContent).toBe(false); + }); + + it("separates new content from Gmail-style attribution", () => { + const content = [ + "Thanks for the update!", + "", + "On Mon, 27 Jan 2025, John Doe wrote:", + "> Original message here", + ].join("\n"); + + const result = parseTextEmailContent(content); + expect(result.newContent).toBe("Thanks for the update!"); + expect(result.hasQuotedContent).toBe(true); + expect(result.quoteLevels).toBeGreaterThanOrEqual(1); + }); + + it("separates content from Outlook-style attribution", () => { + const content = [ + "Got it, thanks.", + "", + "----- Original Message -----", + "From: sender@example.com", + "Subject: Test", + ].join("\n"); + + const result = parseTextEmailContent(content); + expect(result.newContent).toBe("Got it, thanks."); + expect(result.hasQuotedContent).toBe(true); + }); + + it("detects > quote prefixes", () => { + const content = [ + "My reply", + "", + "> Previous message", + ">> Even older message", + ].join("\n"); + + const result = parseTextEmailContent(content); + expect(result.newContent).toBe("My reply"); + expect(result.hasQuotedContent).toBe(true); + expect(result.quoteLevels).toBeGreaterThanOrEqual(2); + }); + + it("detects mobile footers", () => { + const content = ["Short reply.", "", "Sent from my iPhone"].join("\n"); + + const result = parseTextEmailContent(content); + expect(result.newContent).toBe("Short reply."); + expect(result.hasQuotedContent).toBe(true); + }); + + it("handles Apple Mail forward format", () => { + const content = [ + "FYI see below.", + "", + "Begin forwarded message:", + "", + "From: someone@example.com", + ].join("\n"); + + const result = parseTextEmailContent(content); + expect(result.newContent).toBe("FYI see below."); + expect(result.hasQuotedContent).toBe(true); + }); +}); + +describe("parseHtmlEmailContent", () => { + it("returns empty content for empty input", () => { + const result = parseHtmlEmailContent(""); + expect(result.newContent).toBe(""); + expect(result.hasQuotedContent).toBe(false); + }); + + it("detects gmail_quote div", () => { + const html = + '
My reply
Quoted
'; + const result = parseHtmlEmailContent(html); + expect(result.newContent).toBe("
My reply
"); + expect(result.hasQuotedContent).toBe(true); + }); + + it("detects blockquote elements", () => { + const html = + "

New content

Old quoted content
"; + const result = parseHtmlEmailContent(html); + expect(result.newContent).toBe("

New content

"); + expect(result.hasQuotedContent).toBe(true); + }); + + it("detects border-left styled divs", () => { + const html = + '
Reply
Quoted
'; + const result = parseHtmlEmailContent(html); + expect(result.newContent).toBe("
Reply
"); + expect(result.hasQuotedContent).toBe(true); + }); + + it("counts nested blockquote levels", () => { + const html = + '
Reply
Level 1
Level 2
'; + const result = parseHtmlEmailContent(html); + expect(result.hasQuotedContent).toBe(true); + expect(result.quoteLevels).toBeGreaterThanOrEqual(2); + }); + + it("returns all content as new when no quotes", () => { + const html = "

Just a simple email.

"; + const result = parseHtmlEmailContent(html); + expect(result.newContent).toBe("

Just a simple email.

"); + expect(result.hasQuotedContent).toBe(false); + }); +}); + +describe("parseEmailContent", () => { + it("detects HTML and delegates to HTML parser", () => { + const html = "
Hello
Quoted
"; + const result = parseEmailContent(html); + expect(result.hasQuotedContent).toBe(true); + }); + + it("detects plain text and delegates to text parser", () => { + const text = "Reply\n\nOn Mon, Jan 27, 2025, user wrote:\n> old"; + const result = parseEmailContent(text); + expect(result.hasQuotedContent).toBe(true); + }); + + it("handles empty string", () => { + const result = parseEmailContent(""); + expect(result.newContent).toBe(""); + expect(result.hasQuotedContent).toBe(false); + }); + + it("detects HTML entities as HTML", () => { + const content = "Hello & World"; + const result = parseEmailContent(content); + // Should be treated as HTML-like due to entity + expect(result.newContent).toBeTruthy(); + }); +}); + +describe("splitIntoMessages", () => { + it("returns empty array for empty input", () => { + expect(splitIntoMessages("")).toEqual([]); + }); + + it("returns single message for plain text", () => { + const result = splitIntoMessages("Just a plain message."); + expect(result).toHaveLength(1); + expect(result[0].content).toBe("Just a plain message."); + expect(result[0].isForwarded).toBe(false); + }); + + it("splits on attribution lines", () => { + const content = [ + "My reply", + "", + "On Mon, 27 Jan 2025, John wrote:", + "Original message here", + ].join("\n"); + + const result = splitIntoMessages(content); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].content).toBe("My reply"); + }); + + it("detects forwarded messages", () => { + const content = [ + "FYI", + "", + "---------- Forwarded message ----------", + "See below for details", + ].join("\n"); + + const result = splitIntoMessages(content); + expect(result.some((msg) => msg.isForwarded)).toBe(true); + }); +}); diff --git a/lib/email-management/sending-spike-detector.test.ts b/lib/email-management/sending-spike-detector.test.ts new file mode 100644 index 00000000..71620182 --- /dev/null +++ b/lib/email-management/sending-spike-detector.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "bun:test"; +import { + getUserAgeInDays, + getSeverity, + SPIKE_DETECTION_CONFIG, + type AwsReputationSnapshot, +} from "@/lib/email-management/sending-spike-detector"; + +describe("getUserAgeInDays", () => { + it("computes age from a Date object", () => { + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const age = getUserAgeInDays(twoDaysAgo); + // Should be approximately 2 + expect(age).toBeGreaterThanOrEqual(1.9); + expect(age).toBeLessThanOrEqual(2.1); + }); + + it("computes age from an ISO string", () => { + const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + const age = getUserAgeInDays(fiveDaysAgo.toISOString()); + expect(age).toBeGreaterThanOrEqual(4.9); + expect(age).toBeLessThanOrEqual(5.1); + }); + + it("returns 0 for future date", () => { + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000); + expect(getUserAgeInDays(tomorrow)).toBe(0); + }); + + it("returns 0 for current time", () => { + const now = new Date(); + const age = getUserAgeInDays(now); + expect(age).toBeGreaterThanOrEqual(0); + expect(age).toBeLessThan(0.01); + }); +}); + +describe("getSeverity", () => { + const noReputation = null; + + it("returns critical when 24h volume exceeds critical threshold", () => { + expect( + getSeverity( + 0, + 0, + SPIKE_DETECTION_CONFIG.ABSOLUTE_24H_CRITICAL, + noReputation, + ), + ).toBe("critical"); + }); + + it("returns critical when 1h volume exceeds critical threshold", () => { + expect( + getSeverity( + 0, + SPIKE_DETECTION_CONFIG.ABSOLUTE_1H_CRITICAL, + 0, + noReputation, + ), + ).toBe("critical"); + }); + + it("returns critical when reputation is at risk", () => { + const atRiskReputation = { isAtRisk: true } as AwsReputationSnapshot; + expect(getSeverity(0, 0, 0, atRiskReputation)).toBe("critical"); + }); + + it("returns high when 24h volume exceeds high threshold", () => { + expect( + getSeverity( + 0, + 0, + SPIKE_DETECTION_CONFIG.ABSOLUTE_24H_HIGH, + noReputation, + ), + ).toBe("high"); + }); + + it("returns high when 1h volume exceeds high threshold", () => { + expect( + getSeverity( + 0, + SPIKE_DETECTION_CONFIG.ABSOLUTE_1H_HIGH, + 0, + noReputation, + ), + ).toBe("high"); + }); + + it("returns medium for moderate volumes", () => { + expect(getSeverity(50, 100, 200, noReputation)).toBe("medium"); + }); + + it("returns medium when reputation is warning but not at risk", () => { + const warningReputation = { + isAtRisk: false, + isWarning: true, + } as AwsReputationSnapshot; + expect(getSeverity(50, 100, 200, warningReputation)).toBe("medium"); + }); +}); diff --git a/lib/email-management/sending-spike-detector.ts b/lib/email-management/sending-spike-detector.ts index bd898f7d..8d1868bf 100644 --- a/lib/email-management/sending-spike-detector.ts +++ b/lib/email-management/sending-spike-detector.ts @@ -7,7 +7,7 @@ import { redis } from "@/lib/redis" const SLACK_ADMIN_WEBHOOK_URL = process.env.SLACK_ADMIN_WEBHOOK_URL -const SPIKE_DETECTION_CONFIG = { +export const SPIKE_DETECTION_CONFIG = { HISTORICAL_DAYS: 14, SPIKE_THRESHOLD_MULTIPLIER: 8, MIN_HISTORICAL_DAILY_AVERAGE: 50, @@ -32,7 +32,7 @@ const SPIKE_AWS_REPUTATION_CACHE_KEY = "spike-alert:aws-reputation-cache" type AlertSeverity = "medium" | "high" | "critical" -type AwsReputationSnapshot = { +export type AwsReputationSnapshot = { latestBounceRatePercent: number latestComplaintRatePercent: number latestRejectRatePercent: number @@ -106,7 +106,7 @@ async function getUserInfo( return result[0] || null } -function getUserAgeInDays(createdAt: Date | string): number { +export function getUserAgeInDays(createdAt: Date | string): number { const created = createdAt instanceof Date ? createdAt : new Date(createdAt) return Math.max(0, (Date.now() - created.getTime()) / (1000 * 60 * 60 * 24)) } @@ -186,7 +186,7 @@ async function getAwsReputationSnapshot(): Promise } } -function getSeverity( +export function getSeverity( current15m: number, current1h: number, current24h: number, diff --git a/lib/email-management/warmup-limits.test.ts b/lib/email-management/warmup-limits.test.ts new file mode 100644 index 00000000..5c412b5a --- /dev/null +++ b/lib/email-management/warmup-limits.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { getDailyLimitForAge } from "@/lib/email-management/warmup-limits"; + +describe("getDailyLimitForAge", () => { + it("day 1 → 20", () => { + expect(getDailyLimitForAge(1)).toBe(20); + }); + + it("day 2 → 40", () => { + expect(getDailyLimitForAge(2)).toBe(40); + }); + + it("day 3 → 75", () => { + expect(getDailyLimitForAge(3)).toBe(75); + }); + + it("day 4 falls into day-5 bucket → 150", () => { + expect(getDailyLimitForAge(4)).toBe(150); + }); + + it("day 5 → 150", () => { + expect(getDailyLimitForAge(5)).toBe(150); + }); + + it("day 6 falls into day-7 bucket → 300", () => { + expect(getDailyLimitForAge(6)).toBe(300); + }); + + it("day 7 → 300", () => { + expect(getDailyLimitForAge(7)).toBe(300); + }); + + it("day 8 falls into day-10 bucket → 500", () => { + expect(getDailyLimitForAge(8)).toBe(500); + }); + + it("day 10 → 500", () => { + expect(getDailyLimitForAge(10)).toBe(500); + }); + + it("day 11 falls into day-14 bucket → 1000", () => { + expect(getDailyLimitForAge(11)).toBe(1000); + }); + + it("day 14 → 1000", () => { + expect(getDailyLimitForAge(14)).toBe(1000); + }); + + it("day 15 (past warmup) → null", () => { + expect(getDailyLimitForAge(15)).toBeNull(); + }); + + it("day 100 (well past warmup) → null", () => { + expect(getDailyLimitForAge(100)).toBeNull(); + }); +}); diff --git a/lib/email-management/warmup-limits.ts b/lib/email-management/warmup-limits.ts index 514589a9..b00eb1fd 100644 --- a/lib/email-management/warmup-limits.ts +++ b/lib/email-management/warmup-limits.ts @@ -43,7 +43,7 @@ const DAILY_LIMITS: Record = { /** * Get the daily limit for a given account age */ -function getDailyLimitForAge(accountAgeInDays: number): number | null { +export function getDailyLimitForAge(accountAgeInDays: number): number | null { // After warmup period, return null (unlimited - rely on billing limits) if (accountAgeInDays > WARMUP_PERIOD_DAYS) { return null diff --git a/lib/email-management/webhook-payload.ts b/lib/email-management/webhook-payload.ts new file mode 100644 index 00000000..3a63c969 --- /dev/null +++ b/lib/email-management/webhook-payload.ts @@ -0,0 +1,150 @@ +/** + * Webhook payload construction and size management + * + * Extracted from email-router.ts to keep handleWebhookEndpoint focused on + * orchestration (delivery record management, HTTP request, logging). + */ + +import type { Endpoint } from "@/features/endpoints/types"; +import type { ParsedEmailData } from "./email-parser"; +import { sanitizeHtml } from "./email-parser"; + +// Maximum webhook payload size (1 MB safety margin) +export const MAX_WEBHOOK_PAYLOAD_SIZE = 1_000_000; + +interface EmailData { + structuredId: string; + messageId: string | null; + fromData: string | null; + toData: string | null; + recipient: string | null; + subject: string | null; + date: Date | null; + threadId: string | null; + threadPosition: number | null; +} + +/** + * Build the webhook payload from structured email data. + */ +export function constructWebhookPayload( + emailData: EmailData, + parsedEmailData: ParsedEmailData, + attachmentsWithUrls: Array>, + endpoint: Pick, +) { + const enhancedParsedData = { + ...parsedEmailData, + attachments: attachmentsWithUrls, + }; + + return { + event: "email.received" as const, + timestamp: new Date().toISOString(), + email: { + id: emailData.structuredId, + messageId: emailData.messageId, + from: emailData.fromData ? JSON.parse(emailData.fromData) : null, + to: emailData.toData ? JSON.parse(emailData.toData) : null, + recipient: emailData.recipient, + subject: emailData.subject, + receivedAt: emailData.date, + + threadId: emailData.threadId || null, + threadPosition: emailData.threadPosition || null, + + parsedData: enhancedParsedData, + + cleanedContent: { + html: parsedEmailData.htmlBody + ? sanitizeHtml(parsedEmailData.htmlBody) + : null, + text: parsedEmailData.textBody || null, + hasHtml: !!parsedEmailData.htmlBody, + hasText: !!parsedEmailData.textBody, + attachments: attachmentsWithUrls, + headers: parsedEmailData.headers || {}, + }, + }, + endpoint: { + id: endpoint.id, + name: endpoint.name, + type: endpoint.type, + }, + }; +} + +/** + * Ensure the serialised payload fits within `maxSize` bytes. + * + * Strategy (applied in order until the payload fits): + * 1. Strip base64-encoded attachment bodies from the `raw` field. + * 2. Also strip the `headers` object from `parsedData`. + * + * Returns the (possibly reduced) payload string and a list of field names + * that were stripped. + */ +export function ensurePayloadSize( + webhookPayload: ReturnType, + maxSize: number = MAX_WEBHOOK_PAYLOAD_SIZE, +): { payloadString: string; strippedFields: string[] } { + const payloadString = JSON.stringify(webhookPayload); + const strippedFields: string[] = []; + + if (payloadString.length <= maxSize) { + return { payloadString, strippedFields }; + } + + console.warn( + `⚠️ Webhook payload too large (${payloadString.length} bytes), stripping attachment bodies from raw field`, + ); + + const rawField = webhookPayload.email.parsedData.raw; + if (!rawField) { + return { payloadString, strippedFields }; + } + + // Remove base64-encoded attachment bodies while preserving MIME structure + const cleanedRaw = rawField.replace( + /Content-Transfer-Encoding:\s*base64\s*[\r\n]+[\r\n]+([\s\S]+?)(?=\r?\n--|\r?\n\r?\nContent-|$)/gi, + "Content-Transfer-Encoding: base64\r\n\r\n[binary attachment data removed - use Attachments API]\r\n", + ); + + const payloadWithCleanedRaw = { + ...webhookPayload, + email: { + ...webhookPayload.email, + parsedData: { + ...webhookPayload.email.parsedData, + raw: cleanedRaw, + }, + }, + }; + const payloadStringWithCleanedRaw = JSON.stringify(payloadWithCleanedRaw); + + if (payloadStringWithCleanedRaw.length <= maxSize) { + strippedFields.push("raw (attachment bodies removed)"); + console.log( + `✅ Removed attachment bodies from raw field, new size: ${payloadStringWithCleanedRaw.length} bytes`, + ); + return { payloadString: payloadStringWithCleanedRaw, strippedFields }; + } + + // Still too large — also strip headers + const payloadWithNoHeaders = { + ...payloadWithCleanedRaw, + email: { + ...payloadWithCleanedRaw.email, + parsedData: { + ...payloadWithCleanedRaw.email.parsedData, + headers: {}, + }, + }, + }; + const finalString = JSON.stringify(payloadWithNoHeaders); + strippedFields.push("raw (attachment bodies removed)", "headers"); + console.warn( + `⚠️ Also removed headers, final size: ${finalString.length} bytes`, + ); + return { payloadString: finalString, strippedFields }; +} diff --git a/lib/guard/rule-matcher.test.ts b/lib/guard/rule-matcher.test.ts new file mode 100644 index 00000000..bf863573 --- /dev/null +++ b/lib/guard/rule-matcher.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test"; +import { + checkStringCriteria, + checkEmailCriteria, +} from "@/lib/guard/rule-matcher"; + +describe("checkStringCriteria", () => { + describe("OR operator", () => { + it("matches when any value is found", () => { + expect( + checkStringCriteria("hello world", ["hello", "goodbye"], "OR"), + ).toBe(true); + }); + + it("does not match when no values found", () => { + expect(checkStringCriteria("hello world", ["foo", "bar"], "OR")).toBe( + false, + ); + }); + }); + + describe("AND operator", () => { + it("matches when all values are found", () => { + expect( + checkStringCriteria("hello world foo", ["hello", "world"], "AND"), + ).toBe(true); + }); + + it("does not match when some values are missing", () => { + expect( + checkStringCriteria("hello world", ["hello", "missing"], "AND"), + ).toBe(false); + }); + }); + + it("lowercases pattern values for matching", () => { + // The function lowercases values but expects content to already be lowercased + expect(checkStringCriteria("hello world", ["HELLO"], "OR")).toBe(true); + }); +}); + +describe("checkEmailCriteria", () => { + describe("exact match", () => { + it("matches exact email (OR)", () => { + expect( + checkEmailCriteria( + ["user@example.com"], + ["user@example.com"], + "OR", + ), + ).toBe(true); + }); + + it("does not match different email", () => { + expect( + checkEmailCriteria( + ["user@example.com"], + ["other@example.com"], + "OR", + ), + ).toBe(false); + }); + }); + + describe("wildcard patterns", () => { + it("matches *@domain.com pattern", () => { + expect( + checkEmailCriteria( + ["anyone@example.com"], + ["*@example.com"], + "OR", + ), + ).toBe(true); + }); + + it("does not match wrong domain with wildcard", () => { + expect( + checkEmailCriteria( + ["anyone@other.com"], + ["*@example.com"], + "OR", + ), + ).toBe(false); + }); + }); + + describe("OR operator", () => { + it("matches if any pattern matches", () => { + expect( + checkEmailCriteria( + ["user@example.com"], + ["nope@nope.com", "*@example.com"], + "OR", + ), + ).toBe(true); + }); + }); + + describe("AND operator", () => { + it("matches when all patterns match", () => { + expect( + checkEmailCriteria( + ["user@example.com", "admin@example.com"], + ["user@example.com", "*@example.com"], + "AND", + ), + ).toBe(true); + }); + + it("does not match when a pattern has no match", () => { + expect( + checkEmailCriteria( + ["user@example.com"], + ["user@example.com", "admin@other.com"], + "AND", + ), + ).toBe(false); + }); + }); + + it("lowercases pattern for matching", () => { + // The function lowercases patterns but expects email addresses to already be lowercased + expect( + checkEmailCriteria( + ["user@example.com"], + ["USER@EXAMPLE.COM"], + "OR", + ), + ).toBe(true); + }); +}); diff --git a/lib/guard/rule-matcher.ts b/lib/guard/rule-matcher.ts index 3180722d..b5e43b76 100644 --- a/lib/guard/rule-matcher.ts +++ b/lib/guard/rule-matcher.ts @@ -222,7 +222,7 @@ async function checkExplicitRule( /** * Check string-based criteria (subject, hasWords) */ -function checkStringCriteria( +export function checkStringCriteria( content: string, values: string[], operator: 'OR' | 'AND' @@ -237,7 +237,7 @@ function checkStringCriteria( /** * Check email-based criteria (from) with wildcard support */ -function checkEmailCriteria( +export function checkEmailCriteria( emailAddresses: string[], patterns: string[], operator: 'OR' | 'AND' diff --git a/lib/ses-monitoring/rate-tracker.ts b/lib/ses-monitoring/rate-tracker.ts index 0d34e65b..d01ed493 100644 --- a/lib/ses-monitoring/rate-tracker.ts +++ b/lib/ses-monitoring/rate-tracker.ts @@ -11,6 +11,7 @@ import { and, count, eq, gte } from "drizzle-orm"; import { nanoid } from "nanoid"; import { db } from "@/lib/db"; import { emailDeliveryEvents, sentEmails, sesTenants } from "@/lib/db/schema"; +import { extractDomainFromEmail } from "@/lib/utils/email-utils"; // Rate thresholds for tenant alerting and automatic suspension export const RATE_THRESHOLDS = { @@ -93,7 +94,7 @@ export async function storeSESEvent(params: { bounceSubType: params.bounceSubType, diagnosticCode: params.diagnosticCode, failedRecipient: params.recipient, - failedRecipientDomain: params.recipient.split("@")[1] || null, + failedRecipientDomain: extractDomainFromEmail(params.recipient) || null, originalMessageId: params.messageId, userId: tenant?.userId || null, tenantId: tenant?.id || null, diff --git a/lib/utils/attachment-utils.test.ts b/lib/utils/attachment-utils.test.ts new file mode 100644 index 00000000..9cce2593 --- /dev/null +++ b/lib/utils/attachment-utils.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import { + inferContentType, + normalizeAttachmentContentType, + normalizeAttachments, +} from "@/lib/utils/attachment-utils"; + +describe("inferContentType", () => { + it.each([ + ["file.pdf", "application/pdf"], + ["file.jpg", "image/jpeg"], + ["file.jpeg", "image/jpeg"], + ["file.png", "image/png"], + ["file.gif", "image/gif"], + ["file.txt", "text/plain"], + ["file.html", "text/html"], + ["file.json", "application/json"], + ["file.zip", "application/zip"], + ["file.doc", "application/msword"], + [ + "file.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ], + ["file.xls", "application/vnd.ms-excel"], + [ + "file.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ], + ])("infers %s → %s", (filename: string, expected: string) => { + expect(inferContentType(filename)).toBe(expected); + }); + + it("returns application/octet-stream for unknown extension", () => { + expect(inferContentType("file.xyz")).toBe("application/octet-stream"); + }); + + it("returns application/octet-stream for no filename", () => { + expect(inferContentType(undefined)).toBe("application/octet-stream"); + }); + + it("returns application/octet-stream for filename without extension", () => { + expect(inferContentType("README")).toBe("application/octet-stream"); + }); +}); + +describe("normalizeAttachmentContentType", () => { + it("infers contentType when missing", () => { + const result = normalizeAttachmentContentType({ filename: "doc.pdf" }); + expect(result.contentType).toBe("application/pdf"); + }); + + it("preserves existing contentType", () => { + const result = normalizeAttachmentContentType({ + filename: "doc.pdf", + contentType: "custom/type", + }); + expect(result.contentType).toBe("custom/type"); + }); + + it("maps snake_case content_type to camelCase", () => { + const result = normalizeAttachmentContentType({ + filename: "img.png", + content_type: "image/png", + }); + expect(result.contentType).toBe("image/png"); + }); + + it("prefers contentType over content_type", () => { + const result = normalizeAttachmentContentType({ + filename: "x.txt", + contentType: "text/plain", + content_type: "wrong/type", + }); + expect(result.contentType).toBe("text/plain"); + }); +}); + +describe("normalizeAttachments", () => { + it("normalizes an array of attachments", () => { + const result = normalizeAttachments([ + { filename: "a.pdf" }, + { filename: "b.png", contentType: "image/png" }, + { filename: "c.doc", content_type: "application/msword" }, + ]); + expect(result).toHaveLength(3); + expect(result[0].contentType).toBe("application/pdf"); + expect(result[1].contentType).toBe("image/png"); + expect(result[2].contentType).toBe("application/msword"); + }); + + it("handles empty array", () => { + expect(normalizeAttachments([])).toEqual([]); + }); +}); diff --git a/lib/utils/attachment-utils.ts b/lib/utils/attachment-utils.ts new file mode 100644 index 00000000..49f27639 --- /dev/null +++ b/lib/utils/attachment-utils.ts @@ -0,0 +1,82 @@ +/** + * Attachment normalization utilities + * + * Ensures attachments have a valid contentType by inferring from filename extension. + * Handles snake_case `content_type` → camelCase `contentType` mapping. + */ + +interface RawAttachment { + filename?: string; + contentType?: string; + content_type?: string; + [key: string]: unknown; +} + +const EXTENSION_CONTENT_TYPES: Record = { + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + txt: "text/plain", + html: "text/html", + json: "application/json", + zip: "application/zip", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", +}; + +/** + * Infer content type from a filename extension. + * Returns `application/octet-stream` when the extension is unknown or missing. + */ +export function inferContentType(filename: string | undefined): string { + if (!filename) return "application/octet-stream"; + const ext = filename.toLowerCase().split(".").pop(); + return (ext && EXTENSION_CONTENT_TYPES[ext]) || "application/octet-stream"; +} + +/** + * Normalize a single attachment: ensure `contentType` is set. + */ +export function normalizeAttachmentContentType( + att: T, + index?: number, +): T & { contentType: string } { + if (!att.contentType && !att.content_type) { + if (index !== undefined) { + console.log( + `⚠️ Attachment ${index + 1} missing contentType, using fallback`, + ); + } + return { + ...att, + contentType: inferContentType(att.filename), + }; + } + + return { + ...att, + contentType: (att.contentType || att.content_type) as string, + }; +} + +/** + * Normalize an array of attachments. + * Overloaded: when called with untyped data (e.g. JSON.parse output), + * returns the same permissive type so callers aren't broken. + */ +// biome-ignore lint/suspicious/noExplicitAny: JSON.parse callers pass any[] +export function normalizeAttachments(rawAttachments: any[]): any[]; +export function normalizeAttachments( + rawAttachments: T[], +): Array; +export function normalizeAttachments( + rawAttachments: RawAttachment[], +): Array { + return rawAttachments.map((att, index) => + normalizeAttachmentContentType(att, index), + ); +} diff --git a/lib/utils/email-utils.test.ts b/lib/utils/email-utils.test.ts new file mode 100644 index 00000000..4eb878a1 --- /dev/null +++ b/lib/utils/email-utils.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { + extractDomainFromEmail, + extractEmailAddress, + extractEmailName, + isValidEmail, +} from "@/lib/utils/email-utils"; + +describe("isValidEmail", () => { + it("accepts a plain email", () => { + expect(isValidEmail("user@example.com")).toBe(true); + }); + + it("accepts subdomains", () => { + expect(isValidEmail("user@mail.example.com")).toBe(true); + }); + + it("rejects empty string", () => { + expect(isValidEmail("")).toBe(false); + }); + + it("rejects missing @", () => { + expect(isValidEmail("userexample.com")).toBe(false); + }); + + it("rejects spaces", () => { + expect(isValidEmail("user @example.com")).toBe(false); + }); + + it("rejects missing domain", () => { + expect(isValidEmail("user@")).toBe(false); + }); + + it("rejects missing local part", () => { + expect(isValidEmail("@example.com")).toBe(false); + }); +}); + +describe("extractDomainFromEmail", () => { + it("extracts domain from a plain email", () => { + expect(extractDomainFromEmail("user@Example.COM")).toBe("example.com"); + }); + + it("extracts domain from Name format", () => { + expect(extractDomainFromEmail("John Doe ")).toBe( + "domain.org", + ); + }); + + it("returns empty string when no @ present", () => { + expect(extractDomainFromEmail("nodomain")).toBe(""); + }); + + it("returns empty string for empty input", () => { + expect(extractDomainFromEmail("")).toBe(""); + }); +}); + +describe("extractEmailAddress", () => { + it("extracts email from Name format", () => { + expect(extractEmailAddress("John ")).toBe( + "john@example.com", + ); + }); + + it("returns plain email unchanged", () => { + expect(extractEmailAddress("user@example.com")).toBe("user@example.com"); + }); + + it("handles quoted name with angle brackets", () => { + expect(extractEmailAddress('"John Doe" ')).toBe("jd@x.com"); + }); +}); + +describe("extractEmailName", () => { + it("extracts unquoted name", () => { + expect(extractEmailName("John Doe ")).toBe("John Doe"); + }); + + it("strips surrounding quotes from name", () => { + expect(extractEmailName('"John Doe" ')).toBe("John Doe"); + }); + + it("strips surrounding single quotes", () => { + expect(extractEmailName("'Jane' ")).toBe("Jane"); + }); + + it("returns null for a plain email", () => { + expect(extractEmailName("user@example.com")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(extractEmailName("")).toBeNull(); + }); +}); diff --git a/lib/utils/email-utils.ts b/lib/utils/email-utils.ts new file mode 100644 index 00000000..9de925e3 --- /dev/null +++ b/lib/utils/email-utils.ts @@ -0,0 +1,57 @@ +/** + * Shared email utilities + * + * Consolidates common email operations that were duplicated across the codebase: + * - Email validation + * - Domain extraction from email addresses + * - Email address extraction from "Name " format + * - Name extraction from "Name " format + */ + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Validate an email address format + */ +export function isValidEmail(email: string): boolean { + return EMAIL_REGEX.test(email); +} + +/** + * Extract domain from email address, handling "Name " format. + * Returns lowercase domain, or empty string if no domain found. + */ +export function extractDomainFromEmail(email: string): string { + // Fast path: check for angle brackets only if present + const angleMatch = email.indexOf("<"); + const source = + angleMatch !== -1 + ? email.slice(angleMatch + 1, email.indexOf(">", angleMatch)) + : email; + const atIndex = source.lastIndexOf("@"); + return atIndex !== -1 ? source.slice(atIndex + 1).toLowerCase() : ""; +} + +/** + * Extract email address from formatted email (removes name part). + * Handles "Name " and plain "email@domain.com" formats. + */ +export function extractEmailAddress(email: string): string { + const emailMatch = email.match(/<([^>]+)>/); + if (emailMatch) { + return emailMatch[1]; + } + return email; +} + +/** + * Extract name from formatted email (removes email part). + * Returns null if no name part found. + */ +export function extractEmailName(email: string): string | null { + const nameMatch = email.match(/^(.+?)\s*<[^>]+>$/); + if (nameMatch) { + return nameMatch[1].trim().replace(/^["']|["']$/g, ""); + } + return null; +} diff --git a/package.json b/package.json index d160e979..90e565bf 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,9 @@ "@aws-sdk/client-ses": "^3.817.0", "@aws-sdk/client-sesv2": "^3.883.0", "@aws-sdk/client-sns": "^3.940.0", - "@better-auth/passkey": "^1.4.5", + "@better-auth/infra": "^0.1.8", + "@better-auth/api-key": "^1.5.0", + "@better-auth/passkey": "^1.5.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", @@ -110,7 +112,7 @@ "async_hooks": "^1.0.0", "atmn": "^0.0.22", "autumn-js": "0.1.48", - "better-auth": "^1.4.4", + "better-auth": "^1.5.0", "bun-types": "^1.2.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -184,6 +186,7 @@ }, "overrides": { "@types/react": "19.2.7", - "@types/react-dom": "19.2.3" + "@types/react-dom": "19.2.3", + "@better-auth/core": "1.5.0" } }