Compare commits

..

No commits in common. "b300293a72a36896c0ad8ea43fe6639f5ba9dac1" and "3b540371cf04547851091495517b3c68ac234182" have entirely different histories.

192 changed files with 826 additions and 40798 deletions

View file

@ -1,15 +0,0 @@
node_modules
**/node_modules
**/dist
.env
.env.*
!.env.example
.git
.cursor
*.db
*.db-journal
apps/api/src/generated
coverage
docs
**/*.md
!README.md

View file

@ -1,24 +1,11 @@
# Copy to .env on your server: cp .env.example .env
# Generate secrets:
# openssl rand -hex 32 # ENCRYPTION_KEY (64 hex chars — NEVER change after first deploy)
# openssl rand -hex 24 # API_KEY
DATABASE_URL="file:./prisma/dev.db"
ENCRYPTION_KEY="<64-char-hex>"
# Persistent SQLite (Docker Compose sets this automatically)
DATABASE_URL="file:/data/warmbox.db"
# Required — encrypts mailbox passwords in the database
ENCRYPTION_KEY=""
# Required when exposed to the internet — dashboard login uses this
API_KEY=""
# Public URL users and OAuth providers see (no trailing slash)
PUBLIC_BASE_URL="https://warmbox.jonnyn.com"
PORT=3000
NODE_ENV=production
# AI content generation (OpenRouter recommended)
AI_API_KEY=""
# Free AI via OpenRouter — sign up at https://openrouter.ai/keys
AI_API_KEY="sk-or-v1-..."
AI_BASE_URL="https://openrouter.ai/api/v1"
AI_MODEL="openrouter/free"
API_KEY=""
PORT=3000
NODE_ENV=development

View file

@ -1,32 +0,0 @@
# Woodpecker CI — Forgejo at https://git.jonnyn.com/
# Push to main → verify build → deploy on home server.
#
# Server prerequisites: see docs/deploy-ci-cd.md
when:
- event: push
branch: main
steps:
verify:
image: node:22-bookworm-slim
environment:
DATABASE_URL: file:/tmp/warmbox-ci.db
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
NODE_ENV: test
commands:
- npm ci
- npx prisma generate
- npm run build
- npm test
deploy:
image: docker:27-cli
depends_on: [verify]
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /srv/docker-configs/warmbox:/deploy
commands:
- apk add --no-cache git bash
- chmod +x /deploy/scripts/deploy.sh
- DEPLOY_DIR=/deploy DEPLOY_BRANCH=main bash /deploy/scripts/deploy.sh

View file

@ -1,48 +0,0 @@
# Warmbox — single-container production image (API + dashboard + cron jobs)
FROM node:22-bookworm-slim AS base
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
RUN npm ci
FROM deps AS build
COPY . .
ENV DATABASE_URL=file:/tmp/build.db
RUN npx prisma generate
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN useradd --create-home --uid 10001 warmbox
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/package.json ./apps/api/package.json
COPY --from=build /app/apps/web/dist ./apps/web/dist
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/prisma.config.ts ./prisma.config.ts
COPY scripts/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh \
&& mkdir -p /data \
&& chown -R warmbox:warmbox /app /data
USER warmbox
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,43 +0,0 @@
{
"name": "@warmbox/api",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "tsx --test src/services/*.test.ts src/lib/*.test.ts",
"test:integration": "tsx --test --test-concurrency=1 src/test/integration/*.integration.test.ts"
},
"dependencies": {
"@prisma/adapter-better-sqlite3": "^7.8.0",
"@prisma/client": "^7.8.0",
"@warmbox/shared": "*",
"better-sqlite3": "^11.10.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^4.21.2",
"imapflow": "^1.0.189",
"luxon": "^3.7.2",
"mailparser": "^3.7.3",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.1",
"openai": "^4.104.0",
"pino": "^9.6.0",
"zod": "^3.25.28"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/luxon": "^3.7.2",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.15.21",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"prisma": "^7.8.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
}

View file

@ -1,69 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser'
export { Prisma }
export * as $Enums from './enums'
export * from './enums';
/**
* Model Workspace
*
*/
export type Workspace = Prisma.WorkspaceModel
/**
* Model SendJob
*
*/
export type SendJob = Prisma.SendJobModel
/**
* Model JobRunLog
*
*/
export type JobRunLog = Prisma.JobRunLogModel
/**
* Model Inbox
*
*/
export type Inbox = Prisma.InboxModel
/**
* Model WarmupCampaign
*
*/
export type WarmupCampaign = Prisma.WarmupCampaignModel
/**
* Model CampaignSatellite
*
*/
export type CampaignSatellite = Prisma.CampaignSatelliteModel
/**
* Model DailySchedule
*
*/
export type DailySchedule = Prisma.DailyScheduleModel
/**
* Model DailyStat
*
*/
export type DailyStat = Prisma.DailyStatModel
/**
* Model WarmupLog
*
*/
export type WarmupLog = Prisma.WarmupLogModel
/**
* Model Setting
*
*/
export type Setting = Prisma.SettingModel

View file

@ -1,91 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import * as $Class from "./internal/class"
import * as Prisma from "./internal/prismaNamespace"
export * as $Enums from './enums'
export * from "./enums"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Workspaces
* const workspaces = await prisma.workspace.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model Workspace
*
*/
export type Workspace = Prisma.WorkspaceModel
/**
* Model SendJob
*
*/
export type SendJob = Prisma.SendJobModel
/**
* Model JobRunLog
*
*/
export type JobRunLog = Prisma.JobRunLogModel
/**
* Model Inbox
*
*/
export type Inbox = Prisma.InboxModel
/**
* Model WarmupCampaign
*
*/
export type WarmupCampaign = Prisma.WarmupCampaignModel
/**
* Model CampaignSatellite
*
*/
export type CampaignSatellite = Prisma.CampaignSatelliteModel
/**
* Model DailySchedule
*
*/
export type DailySchedule = Prisma.DailyScheduleModel
/**
* Model DailyStat
*
*/
export type DailyStat = Prisma.DailyStatModel
/**
* Model WarmupLog
*
*/
export type WarmupLog = Prisma.WarmupLogModel
/**
* Model Setting
*
*/
export type Setting = Prisma.SettingModel

View file

@ -1,708 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums"
import type * as Prisma from "./internal/prismaNamespace"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type EnumSendJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.SendJobStatus | Prisma.EnumSendJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SendJobStatus[]
notIn?: $Enums.SendJobStatus[]
not?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel> | $Enums.SendJobStatus
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type EnumSendJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SendJobStatus | Prisma.EnumSendJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SendJobStatus[]
notIn?: $Enums.SendJobStatus[]
not?: Prisma.NestedEnumSendJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.SendJobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type EnumMailboxAuthTypeFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxAuthType | Prisma.EnumMailboxAuthTypeFieldRefInput<$PrismaModel>
in?: $Enums.MailboxAuthType[]
notIn?: $Enums.MailboxAuthType[]
not?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel> | $Enums.MailboxAuthType
}
export type EnumMailboxRoleFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxRole | Prisma.EnumMailboxRoleFieldRefInput<$PrismaModel>
in?: $Enums.MailboxRole[]
notIn?: $Enums.MailboxRole[]
not?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel> | $Enums.MailboxRole
}
export type EnumMailboxProviderFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxProvider | Prisma.EnumMailboxProviderFieldRefInput<$PrismaModel>
in?: $Enums.MailboxProvider[]
notIn?: $Enums.MailboxProvider[]
not?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel> | $Enums.MailboxProvider
}
export type EnumInboxStatusFilter<$PrismaModel = never> = {
equals?: $Enums.InboxStatus | Prisma.EnumInboxStatusFieldRefInput<$PrismaModel>
in?: $Enums.InboxStatus[]
notIn?: $Enums.InboxStatus[]
not?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel> | $Enums.InboxStatus
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type EnumMailboxAuthTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxAuthType | Prisma.EnumMailboxAuthTypeFieldRefInput<$PrismaModel>
in?: $Enums.MailboxAuthType[]
notIn?: $Enums.MailboxAuthType[]
not?: Prisma.NestedEnumMailboxAuthTypeWithAggregatesFilter<$PrismaModel> | $Enums.MailboxAuthType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel>
}
export type EnumMailboxRoleWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxRole | Prisma.EnumMailboxRoleFieldRefInput<$PrismaModel>
in?: $Enums.MailboxRole[]
notIn?: $Enums.MailboxRole[]
not?: Prisma.NestedEnumMailboxRoleWithAggregatesFilter<$PrismaModel> | $Enums.MailboxRole
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel>
}
export type EnumMailboxProviderWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxProvider | Prisma.EnumMailboxProviderFieldRefInput<$PrismaModel>
in?: $Enums.MailboxProvider[]
notIn?: $Enums.MailboxProvider[]
not?: Prisma.NestedEnumMailboxProviderWithAggregatesFilter<$PrismaModel> | $Enums.MailboxProvider
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel>
}
export type EnumInboxStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.InboxStatus | Prisma.EnumInboxStatusFieldRefInput<$PrismaModel>
in?: $Enums.InboxStatus[]
notIn?: $Enums.InboxStatus[]
not?: Prisma.NestedEnumInboxStatusWithAggregatesFilter<$PrismaModel> | $Enums.InboxStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel>
}
export type EnumCampaignRecipeFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignRecipe | Prisma.EnumCampaignRecipeFieldRefInput<$PrismaModel>
in?: $Enums.CampaignRecipe[]
notIn?: $Enums.CampaignRecipe[]
not?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel> | $Enums.CampaignRecipe
}
export type EnumCampaignStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel>
in?: $Enums.CampaignStatus[]
notIn?: $Enums.CampaignStatus[]
not?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel> | $Enums.CampaignStatus
}
export type EnumCampaignStatusNullableFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.CampaignStatus[] | null
notIn?: $Enums.CampaignStatus[] | null
not?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel> | $Enums.CampaignStatus | null
}
export type EnumCampaignRecipeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignRecipe | Prisma.EnumCampaignRecipeFieldRefInput<$PrismaModel>
in?: $Enums.CampaignRecipe[]
notIn?: $Enums.CampaignRecipe[]
not?: Prisma.NestedEnumCampaignRecipeWithAggregatesFilter<$PrismaModel> | $Enums.CampaignRecipe
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel>
}
export type EnumCampaignStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel>
in?: $Enums.CampaignStatus[]
notIn?: $Enums.CampaignStatus[]
not?: Prisma.NestedEnumCampaignStatusWithAggregatesFilter<$PrismaModel> | $Enums.CampaignStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel>
}
export type EnumCampaignStatusNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.CampaignStatus[] | null
notIn?: $Enums.CampaignStatus[] | null
not?: Prisma.NestedEnumCampaignStatusNullableWithAggregatesFilter<$PrismaModel> | $Enums.CampaignStatus | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel>
}
export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type EnumWarmupStatusFilter<$PrismaModel = never> = {
equals?: $Enums.WarmupStatus | Prisma.EnumWarmupStatusFieldRefInput<$PrismaModel>
in?: $Enums.WarmupStatus[]
notIn?: $Enums.WarmupStatus[]
not?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel> | $Enums.WarmupStatus
}
export type EnumWarmupStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.WarmupStatus | Prisma.EnumWarmupStatusFieldRefInput<$PrismaModel>
in?: $Enums.WarmupStatus[]
notIn?: $Enums.WarmupStatus[]
not?: Prisma.NestedEnumWarmupStatusWithAggregatesFilter<$PrismaModel> | $Enums.WarmupStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[]
notIn?: string[]
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[]
notIn?: Date[] | string[]
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedEnumSendJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.SendJobStatus | Prisma.EnumSendJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SendJobStatus[]
notIn?: $Enums.SendJobStatus[]
not?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel> | $Enums.SendJobStatus
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedEnumSendJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.SendJobStatus | Prisma.EnumSendJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.SendJobStatus[]
notIn?: $Enums.SendJobStatus[]
not?: Prisma.NestedEnumSendJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.SendJobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumSendJobStatusFilter<$PrismaModel>
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | null
notIn?: Date[] | string[] | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedBoolFilter<$PrismaModel>
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedEnumMailboxAuthTypeFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxAuthType | Prisma.EnumMailboxAuthTypeFieldRefInput<$PrismaModel>
in?: $Enums.MailboxAuthType[]
notIn?: $Enums.MailboxAuthType[]
not?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel> | $Enums.MailboxAuthType
}
export type NestedEnumMailboxRoleFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxRole | Prisma.EnumMailboxRoleFieldRefInput<$PrismaModel>
in?: $Enums.MailboxRole[]
notIn?: $Enums.MailboxRole[]
not?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel> | $Enums.MailboxRole
}
export type NestedEnumMailboxProviderFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxProvider | Prisma.EnumMailboxProviderFieldRefInput<$PrismaModel>
in?: $Enums.MailboxProvider[]
notIn?: $Enums.MailboxProvider[]
not?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel> | $Enums.MailboxProvider
}
export type NestedEnumInboxStatusFilter<$PrismaModel = never> = {
equals?: $Enums.InboxStatus | Prisma.EnumInboxStatusFieldRefInput<$PrismaModel>
in?: $Enums.InboxStatus[]
notIn?: $Enums.InboxStatus[]
not?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel> | $Enums.InboxStatus
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[]
notIn?: number[]
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedEnumMailboxAuthTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxAuthType | Prisma.EnumMailboxAuthTypeFieldRefInput<$PrismaModel>
in?: $Enums.MailboxAuthType[]
notIn?: $Enums.MailboxAuthType[]
not?: Prisma.NestedEnumMailboxAuthTypeWithAggregatesFilter<$PrismaModel> | $Enums.MailboxAuthType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxAuthTypeFilter<$PrismaModel>
}
export type NestedEnumMailboxRoleWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxRole | Prisma.EnumMailboxRoleFieldRefInput<$PrismaModel>
in?: $Enums.MailboxRole[]
notIn?: $Enums.MailboxRole[]
not?: Prisma.NestedEnumMailboxRoleWithAggregatesFilter<$PrismaModel> | $Enums.MailboxRole
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxRoleFilter<$PrismaModel>
}
export type NestedEnumMailboxProviderWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MailboxProvider | Prisma.EnumMailboxProviderFieldRefInput<$PrismaModel>
in?: $Enums.MailboxProvider[]
notIn?: $Enums.MailboxProvider[]
not?: Prisma.NestedEnumMailboxProviderWithAggregatesFilter<$PrismaModel> | $Enums.MailboxProvider
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel>
_max?: Prisma.NestedEnumMailboxProviderFilter<$PrismaModel>
}
export type NestedEnumInboxStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.InboxStatus | Prisma.EnumInboxStatusFieldRefInput<$PrismaModel>
in?: $Enums.InboxStatus[]
notIn?: $Enums.InboxStatus[]
not?: Prisma.NestedEnumInboxStatusWithAggregatesFilter<$PrismaModel> | $Enums.InboxStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumInboxStatusFilter<$PrismaModel>
}
export type NestedEnumCampaignRecipeFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignRecipe | Prisma.EnumCampaignRecipeFieldRefInput<$PrismaModel>
in?: $Enums.CampaignRecipe[]
notIn?: $Enums.CampaignRecipe[]
not?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel> | $Enums.CampaignRecipe
}
export type NestedEnumCampaignStatusFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel>
in?: $Enums.CampaignStatus[]
notIn?: $Enums.CampaignStatus[]
not?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel> | $Enums.CampaignStatus
}
export type NestedEnumCampaignStatusNullableFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.CampaignStatus[] | null
notIn?: $Enums.CampaignStatus[] | null
not?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel> | $Enums.CampaignStatus | null
}
export type NestedEnumCampaignRecipeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignRecipe | Prisma.EnumCampaignRecipeFieldRefInput<$PrismaModel>
in?: $Enums.CampaignRecipe[]
notIn?: $Enums.CampaignRecipe[]
not?: Prisma.NestedEnumCampaignRecipeWithAggregatesFilter<$PrismaModel> | $Enums.CampaignRecipe
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignRecipeFilter<$PrismaModel>
}
export type NestedEnumCampaignStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel>
in?: $Enums.CampaignStatus[]
notIn?: $Enums.CampaignStatus[]
not?: Prisma.NestedEnumCampaignStatusWithAggregatesFilter<$PrismaModel> | $Enums.CampaignStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignStatusFilter<$PrismaModel>
}
export type NestedEnumCampaignStatusNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.CampaignStatus | Prisma.EnumCampaignStatusFieldRefInput<$PrismaModel> | null
in?: $Enums.CampaignStatus[] | null
notIn?: $Enums.CampaignStatus[] | null
not?: Prisma.NestedEnumCampaignStatusNullableWithAggregatesFilter<$PrismaModel> | $Enums.CampaignStatus | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumCampaignStatusNullableFilter<$PrismaModel>
}
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | null
notIn?: number[] | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type NestedEnumWarmupStatusFilter<$PrismaModel = never> = {
equals?: $Enums.WarmupStatus | Prisma.EnumWarmupStatusFieldRefInput<$PrismaModel>
in?: $Enums.WarmupStatus[]
notIn?: $Enums.WarmupStatus[]
not?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel> | $Enums.WarmupStatus
}
export type NestedEnumWarmupStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.WarmupStatus | Prisma.EnumWarmupStatusFieldRefInput<$PrismaModel>
in?: $Enums.WarmupStatus[]
notIn?: $Enums.WarmupStatus[]
not?: Prisma.NestedEnumWarmupStatusWithAggregatesFilter<$PrismaModel> | $Enums.WarmupStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumWarmupStatusFilter<$PrismaModel>
}

View file

@ -1,89 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
export const InboxStatus = {
active: 'active',
paused: 'paused',
error: 'error'
} as const
export type InboxStatus = (typeof InboxStatus)[keyof typeof InboxStatus]
export const WarmupStatus = {
sent: 'sent',
rescued_from_spam: 'rescued_from_spam',
engaged_no_reply: 'engaged_no_reply',
replied: 'replied',
complete: 'complete'
} as const
export type WarmupStatus = (typeof WarmupStatus)[keyof typeof WarmupStatus]
export const MailboxRole = {
unassigned: 'unassigned',
primary: 'primary',
satellite: 'satellite'
} as const
export type MailboxRole = (typeof MailboxRole)[keyof typeof MailboxRole]
export const MailboxProvider = {
google_workspace: 'google_workspace',
gmail: 'gmail',
yahoo: 'yahoo',
outlook: 'outlook',
custom: 'custom'
} as const
export type MailboxProvider = (typeof MailboxProvider)[keyof typeof MailboxProvider]
export const MailboxAuthType = {
password: 'password',
microsoft_oauth: 'microsoft_oauth'
} as const
export type MailboxAuthType = (typeof MailboxAuthType)[keyof typeof MailboxAuthType]
export const CampaignRecipe = {
grow: 'grow',
flat: 'flat',
randomized: 'randomized',
custom: 'custom'
} as const
export type CampaignRecipe = (typeof CampaignRecipe)[keyof typeof CampaignRecipe]
export const CampaignStatus = {
draft: 'draft',
active: 'active',
paused: 'paused',
maintenance: 'maintenance',
completed: 'completed',
failed: 'failed'
} as const
export type CampaignStatus = (typeof CampaignStatus)[keyof typeof CampaignStatus]
export const SendJobStatus = {
pending: 'pending',
processing: 'processing',
completed: 'completed',
failed: 'failed'
} as const
export type SendJobStatus = (typeof SendJobStatus)[keyof typeof SendJobStatus]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,271 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models'
export type * from './prismaNamespace'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
Workspace: 'Workspace',
SendJob: 'SendJob',
JobRunLog: 'JobRunLog',
Inbox: 'Inbox',
WarmupCampaign: 'WarmupCampaign',
CampaignSatellite: 'CampaignSatellite',
DailySchedule: 'DailySchedule',
DailyStat: 'DailyStat',
WarmupLog: 'WarmupLog',
Setting: 'Setting'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const WorkspaceScalarFieldEnum = {
id: 'id',
name: 'name',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type WorkspaceScalarFieldEnum = (typeof WorkspaceScalarFieldEnum)[keyof typeof WorkspaceScalarFieldEnum]
export const SendJobScalarFieldEnum = {
id: 'id',
campaignId: 'campaignId',
scheduledAt: 'scheduledAt',
status: 'status',
idempotencyKey: 'idempotencyKey',
processedAt: 'processedAt',
errorMessage: 'errorMessage',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SendJobScalarFieldEnum = (typeof SendJobScalarFieldEnum)[keyof typeof SendJobScalarFieldEnum]
export const JobRunLogScalarFieldEnum = {
id: 'id',
jobName: 'jobName',
ranAt: 'ranAt',
success: 'success',
message: 'message'
} as const
export type JobRunLogScalarFieldEnum = (typeof JobRunLogScalarFieldEnum)[keyof typeof JobRunLogScalarFieldEnum]
export const InboxScalarFieldEnum = {
id: 'id',
workspaceId: 'workspaceId',
email: 'email',
smtpHost: 'smtpHost',
smtpPort: 'smtpPort',
imapHost: 'imapHost',
imapPort: 'imapPort',
username: 'username',
password: 'password',
authType: 'authType',
oauthRefreshToken: 'oauthRefreshToken',
role: 'role',
provider: 'provider',
status: 'status',
dailyLimit: 'dailyLimit',
currentRamp: 'currentRamp',
industry: 'industry',
senderName: 'senderName',
companyName: 'companyName',
lastError: 'lastError',
errorAt: 'errorAt',
consecutiveFailures: 'consecutiveFailures',
spamFolderPath: 'spamFolderPath',
lastSpamRescuedAt: 'lastSpamRescuedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type InboxScalarFieldEnum = (typeof InboxScalarFieldEnum)[keyof typeof InboxScalarFieldEnum]
export const WarmupCampaignScalarFieldEnum = {
id: 'id',
workspaceId: 'workspaceId',
primaryMailboxId: 'primaryMailboxId',
name: 'name',
recipe: 'recipe',
status: 'status',
durationDays: 'durationDays',
minEmailsPerDay: 'minEmailsPerDay',
maxEmailsPerDay: 'maxEmailsPerDay',
replyRatePercent: 'replyRatePercent',
maintenanceVolumePercent: 'maintenanceVolumePercent',
timezone: 'timezone',
sendWindowStart: 'sendWindowStart',
sendWindowEnd: 'sendWindowEnd',
customSchedule: 'customSchedule',
startedAt: 'startedAt',
endsAt: 'endsAt',
currentDay: 'currentDay',
pausedFromStatus: 'pausedFromStatus',
lastSendAt: 'lastSendAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type WarmupCampaignScalarFieldEnum = (typeof WarmupCampaignScalarFieldEnum)[keyof typeof WarmupCampaignScalarFieldEnum]
export const CampaignSatelliteScalarFieldEnum = {
id: 'id',
campaignId: 'campaignId',
satelliteMailboxId: 'satelliteMailboxId',
weight: 'weight',
active: 'active',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type CampaignSatelliteScalarFieldEnum = (typeof CampaignSatelliteScalarFieldEnum)[keyof typeof CampaignSatelliteScalarFieldEnum]
export const DailyScheduleScalarFieldEnum = {
id: 'id',
campaignId: 'campaignId',
dayIndex: 'dayIndex',
date: 'date',
plannedSends: 'plannedSends',
plannedReplies: 'plannedReplies',
actualSends: 'actualSends',
actualReplies: 'actualReplies',
spamDetected: 'spamDetected',
spamRescued: 'spamRescued',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type DailyScheduleScalarFieldEnum = (typeof DailyScheduleScalarFieldEnum)[keyof typeof DailyScheduleScalarFieldEnum]
export const DailyStatScalarFieldEnum = {
id: 'id',
campaignId: 'campaignId',
date: 'date',
dayIndex: 'dayIndex',
plannedSends: 'plannedSends',
plannedReplies: 'plannedReplies',
actualSends: 'actualSends',
actualReplies: 'actualReplies',
spamDetected: 'spamDetected',
spamRescued: 'spamRescued',
replyRateActual: 'replyRateActual',
inboxPlacementRate: 'inboxPlacementRate',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type DailyStatScalarFieldEnum = (typeof DailyStatScalarFieldEnum)[keyof typeof DailyStatScalarFieldEnum]
export const WarmupLogScalarFieldEnum = {
id: 'id',
campaignId: 'campaignId',
senderId: 'senderId',
receiverId: 'receiverId',
messageId: 'messageId',
inReplyTo: 'inReplyTo',
subject: 'subject',
body: 'body',
status: 'status',
placement: 'placement',
rescuedFromSpam: 'rescuedFromSpam',
validationResult: 'validationResult',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type WarmupLogScalarFieldEnum = (typeof WarmupLogScalarFieldEnum)[keyof typeof WarmupLogScalarFieldEnum]
export const SettingScalarFieldEnum = {
id: 'id',
key: 'key',
value: 'value',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type SettingScalarFieldEnum = (typeof SettingScalarFieldEnum)[keyof typeof SettingScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]

View file

@ -1,21 +0,0 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/Workspace'
export type * from './models/SendJob'
export type * from './models/JobRunLog'
export type * from './models/Inbox'
export type * from './models/WarmupCampaign'
export type * from './models/CampaignSatellite'
export type * from './models/DailySchedule'
export type * from './models/DailyStat'
export type * from './models/WarmupLog'
export type * from './models/Setting'
export type * from './commonInputTypes'

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,82 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import cors from 'cors';
import express from 'express';
import { config } from './config';
import { errorHandler } from './middleware/errorHandler';
import { apiKeyAuth } from './middleware/apiKeyAuth';
import inboxesRouter from './routes/inboxes.routes';
import campaignsRouter from './routes/campaigns.routes';
import recipesRouter from './routes/recipes.routes';
import aiRouter from './routes/ai.routes';
import logsRouter from './routes/logs.routes';
import settingsRouter from './routes/settings.routes';
import jobsRouter from './routes/jobs.routes';
import integrationsRouter, {
googleOAuthCallback,
microsoftOAuthCallback,
} from './routes/integrations.routes';
import statsRouter, { workspaceRouter } from './routes/stats.routes';
import { startScheduler } from './jobs/scheduler';
import { logger } from './lib/logger';
const app = express();
if (config.NODE_ENV === 'development') {
app.use(
cors({
origin: ['http://localhost:5173', 'http://127.0.0.1:5173'],
credentials: true,
}),
);
}
app.use(express.json());
app.get('/api/integrations/google/callback', googleOAuthCallback);
app.get('/api/integrations/microsoft/callback', microsoftOAuthCallback);
app.use('/api', apiKeyAuth);
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/inboxes', inboxesRouter);
app.use('/api/mailboxes', inboxesRouter);
app.use('/api/campaigns', campaignsRouter);
app.use('/api/campaigns', statsRouter);
app.use('/api/workspace', workspaceRouter);
app.use('/api/recipes', recipesRouter);
app.use('/api/ai', aiRouter);
app.use('/api/logs', logsRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/integrations', integrationsRouter);
const webDist = path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDist)) {
app.use(express.static(webDist));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api') || req.path === '/health') {
next();
return;
}
res.sendFile(path.join(webDist, 'index.html'));
});
}
app.use(errorHandler);
async function main(): Promise<void> {
await startScheduler();
app.listen(config.PORT, () => {
logger.info({ port: config.PORT, webDist: fs.existsSync(webDist) }, 'Warmbox server started');
});
}
main().catch((err) => {
logger.error({ err }, 'Failed to start server');
process.exit(1);
});

View file

@ -1,246 +0,0 @@
import type { CampaignSatellite, Inbox } from '../generated/prisma/client';
import { CampaignStatus, InboxStatus, WarmupStatus, prisma } from '../lib/prisma';
import { withInboxGuard } from '../lib/inboxGuard';
import { logger } from '../lib/logger';
import { isAllowedWarmupSendPair } from '../lib/mailbox-topology';
import { isWithinSendWindow } from '../lib/send-window';
import * as aiService from '../services/ai.service';
import { syncCampaignDay } from '../services/campaign.service';
import * as emailService from '../services/email.service';
import {
claimDueSendJob,
completeSendJob,
enqueueSendJob,
reclaimStaleSendJobs,
} from '../services/job-queue.service';
const MIN_JITTER_MS = 45 * 60 * 1000;
type SatelliteWithMailbox = CampaignSatellite & { satelliteMailbox: Inbox };
function pickWeightedSatellite(satellites: SatelliteWithMailbox[]): Inbox {
const totalWeight = satellites.reduce((sum, sat) => sum + sat.weight, 0);
let remaining = Math.random() * totalWeight;
for (const satellite of satellites) {
remaining -= satellite.weight;
if (remaining <= 0) {
return satellite.satelliteMailbox;
}
}
return satellites[satellites.length - 1]!.satelliteMailbox;
}
function hasElapsedMinJitter(lastSendAt: Date | null): boolean {
if (!lastSendAt) return true;
return Date.now() - lastSendAt.getTime() >= MIN_JITTER_MS;
}
async function executeCampaignSend(
campaignId: string,
jobId: string | null,
): Promise<void> {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id: campaignId },
include: {
primaryMailbox: true,
satellites: {
where: { active: true },
include: { satelliteMailbox: true },
},
},
});
if (!campaign) return;
const activeSatellites = campaign.satellites.filter(
(sat) => sat.satelliteMailbox.status === InboxStatus.active,
);
if (activeSatellites.length === 0) {
logger.warn({ campaignId }, 'Campaign has no active satellites');
if (jobId) await completeSendJob(jobId, false, 'No active satellites');
return;
}
const schedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: {
campaignId: campaign.id,
dayIndex: campaign.currentDay,
},
},
});
if (!schedule || schedule.actualSends >= schedule.plannedSends) {
if (jobId) await completeSendJob(jobId, true, 'Daily send quota reached');
return;
}
const satellite = pickWeightedSatellite(activeSatellites);
const primary = campaign.primaryMailbox;
if (primary.status === InboxStatus.paused) {
logger.warn({ campaignId }, 'Primary mailbox is paused — skipping send');
if (jobId) await completeSendJob(jobId, false, 'Primary mailbox paused');
return;
}
if (primary.status === InboxStatus.error) {
logger.warn(
{ campaignId, primary: primary.email },
'Primary mailbox in error — proceeding with satellite send (IMAP not required)',
);
}
if (!isAllowedWarmupSendPair(satellite, primary)) {
logger.warn(
{ campaignId, satellite: satellite.email, primary: primary.email },
'Campaign dispatcher skipped invalid warmup pair',
);
if (jobId) await completeSendJob(jobId, false, 'Invalid mailbox roles for warmup send');
return;
}
try {
const { subject, body, validationResult } =
await aiService.generateInitialEmailWithValidation(primary.industry, {
senderName: satellite.senderName,
companyName: satellite.companyName,
});
await withInboxGuard(satellite, async () => {
const { messageId } = await emailService.sendMail(satellite, {
to: primary.email,
subject,
body,
});
await prisma.$transaction(async (tx) => {
const current = await tx.dailySchedule.findUnique({
where: { id: schedule.id },
});
if (!current || current.actualSends >= current.plannedSends) {
return;
}
await tx.warmupLog.create({
data: {
campaignId: campaign.id,
senderId: satellite.id,
receiverId: primary.id,
messageId,
subject,
body,
status: WarmupStatus.sent,
validationResult: JSON.stringify(validationResult),
},
});
await tx.dailySchedule.update({
where: { id: schedule.id },
data: { actualSends: { increment: 1 } },
});
await tx.warmupCampaign.update({
where: { id: campaign.id },
data: { lastSendAt: new Date() },
});
});
logger.info(
{
campaignId: campaign.id,
satellite: satellite.email,
primary: primary.email,
messageId,
},
'Campaign warmup email dispatched',
);
});
if (jobId) await completeSendJob(jobId, true);
} catch (err) {
const message = err instanceof Error ? err.message : 'Campaign send failed';
if (jobId) await completeSendJob(jobId, false, message);
throw err;
}
}
export async function runCampaignDispatcherJob(): Promise<string> {
logger.info('Campaign dispatcher job started');
await reclaimStaleSendJobs();
const campaigns = await prisma.warmupCampaign.findMany({
where: {
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
},
include: {
primaryMailbox: true,
satellites: {
where: { active: true },
include: { satelliteMailbox: true },
},
},
});
if (campaigns.length === 0) {
logger.info('Campaign dispatcher skipped: no active campaigns');
return 'No active campaigns';
}
let processed = 0;
for (const campaign of campaigns) {
try {
await syncCampaignDay(campaign.id);
const refreshed = await prisma.warmupCampaign.findUnique({
where: { id: campaign.id },
});
if (!refreshed) continue;
if (!hasElapsedMinJitter(refreshed.lastSendAt)) {
logger.debug({ campaignId: campaign.id }, 'Campaign within jitter window');
continue;
}
if (
!isWithinSendWindow(
refreshed.timezone,
refreshed.sendWindowStart,
refreshed.sendWindowEnd,
)
) {
logger.debug({ campaignId: campaign.id }, 'Campaign outside send window');
continue;
}
const schedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: {
campaignId: campaign.id,
dayIndex: refreshed.currentDay,
},
},
});
if (!schedule || schedule.actualSends >= schedule.plannedSends) {
continue;
}
await enqueueSendJob(campaign.id, new Date());
const job = await claimDueSendJob(campaign.id);
if (!job) continue;
await executeCampaignSend(campaign.id, job.id);
processed += 1;
} catch (err) {
logger.error({ err, campaignId: campaign.id }, 'Campaign dispatcher failed');
}
}
logger.info('Campaign dispatcher job finished');
return `Processed ${processed} send job(s)`;
}

View file

@ -1,24 +0,0 @@
import { CampaignStatus, prisma } from '../lib/prisma';
import { logger } from '../lib/logger';
import { syncCampaignDay } from '../services/campaign.service';
export async function runCampaignRolloverJob(): Promise<void> {
logger.info('Campaign rollover job started');
const campaigns = await prisma.warmupCampaign.findMany({
where: {
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
},
select: { id: true },
});
for (const campaign of campaigns) {
try {
await syncCampaignDay(campaign.id);
} catch (err) {
logger.error({ err, campaignId: campaign.id }, 'Campaign rollover failed');
}
}
logger.info('Campaign rollover job finished');
}

View file

@ -1,17 +0,0 @@
import { logger } from '../lib/logger';
import { logJobRun } from '../services/job-queue.service';
export async function runLoggedJob(
jobName: string,
fn: () => Promise<string | void>,
): Promise<void> {
try {
const message = await fn();
await logJobRun(jobName, true, message ?? undefined);
} catch (err) {
const message = err instanceof Error ? err.message : 'Job failed';
await logJobRun(jobName, false, message);
logger.error({ err, jobName }, 'Background job failed');
throw err;
}
}

View file

@ -1,489 +0,0 @@
import { simpleParser } from 'mailparser';
import { ImapFlow } from 'imapflow';
import type { Inbox, WarmupCampaign, WarmupLog } from '../generated/prisma/client';
import { CampaignStatus, InboxStatus, MailboxRole, WarmupStatus } from '../lib/prisma';
import { prisma } from '../lib/prisma';
import { withInboxGuard } from '../lib/inboxGuard';
import { logger } from '../lib/logger';
import {
isAllowedWarmupReplyPair,
isPrimaryMailbox,
isSatelliteMailbox,
} from '../lib/mailbox-topology';
import * as aiService from '../services/ai.service';
import { hasAnyCampaign } from '../services/campaign.service';
import * as emailService from '../services/email.service';
import { withImapClient } from '../services/imap.service';
import { resolveSpamFolder } from '../services/spam-folder.service';
import {
buildShouldReplyInput,
getTodayReplyStats,
shouldReply,
} from '../services/reply-rate.service';
import { checkCampaignSpamThresholds } from '../services/spam-threshold.service';
type PendingLog = WarmupLog & { sender: Inbox };
type CampaignContext = {
campaign: Pick<WarmupCampaign, 'id' | 'replyRatePercent' | 'primaryMailboxId' | 'currentDay'>;
schedule: { plannedReplies: number; actualReplies: number };
};
function normalizeMessageId(id: string | undefined | null): string | null {
if (!id) return null;
return id.replace(/^<|>$/g, '').trim();
}
function extractFromAddress(fromHeader: string | undefined): string | null {
if (!fromHeader) return null;
const match = fromHeader.match(/<([^>]+)>/) ?? fromHeader.match(/([\w.+-]+@[\w.-]+)/);
return match?.[1]?.toLowerCase() ?? null;
}
async function findMatchingLog(
logs: PendingLog[],
messageId: string | null,
fromEmail: string | null,
subject: string | undefined,
): Promise<PendingLog | undefined> {
if (messageId) {
const normalized = normalizeMessageId(messageId);
const byId = logs.find(
(l) => l.messageId && normalizeMessageId(l.messageId) === normalized,
);
if (byId) return byId;
}
if (fromEmail && subject) {
return logs.find(
(l) =>
l.sender.email.toLowerCase() === fromEmail &&
l.subject.trim() === subject.replace(/^Re:\s*/i, '').trim(),
);
}
return undefined;
}
async function incrementActualReplies(campaignId: string, currentDay: number): Promise<void> {
await prisma.dailySchedule.updateMany({
where: { campaignId, dayIndex: currentDay },
data: { actualReplies: { increment: 1 } },
});
}
async function incrementSpamCounters(
campaignId: string,
currentDay: number,
rescued: boolean,
): Promise<void> {
await prisma.dailySchedule.updateMany({
where: { campaignId, dayIndex: currentDay },
data: {
spamDetected: { increment: 1 },
...(rescued ? { spamRescued: { increment: 1 } } : {}),
},
});
}
async function markEngagedNoReply(logId: string, placement: 'inbox' | 'spam'): Promise<void> {
await prisma.warmupLog.update({
where: { id: logId },
data: {
status: WarmupStatus.engaged_no_reply,
placement,
rescuedFromSpam: placement === 'spam',
},
});
await prisma.warmupLog.update({
where: { id: logId },
data: { status: WarmupStatus.complete },
});
}
async function processMessage(
receiver: Inbox,
uid: number,
client: ImapFlow,
pendingLogs: PendingLog[],
options?: {
campaignContext?: CampaignContext;
placement?: 'inbox' | 'spam';
},
): Promise<void> {
const msg = await client.fetchOne(
String(uid),
{
envelope: true,
source: true,
headers: ['message-id', 'from', 'subject'],
uid: true,
},
{ uid: true },
);
if (!msg || !msg.source) return;
const parsed = await simpleParser(msg.source);
const fromEmail = extractFromAddress(parsed.from?.text ?? msg.envelope?.from?.[0]?.address);
const messageId = parsed.messageId ?? msg.envelope?.messageId ?? null;
const subject = parsed.subject ?? msg.envelope?.subject ?? '';
const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject);
if (!log) return;
if (!isAllowedWarmupReplyPair(receiver, log.sender)) {
logger.warn(
{
receiver: receiver.email,
receiverRole: receiver.role,
sender: log.sender.email,
senderRole: log.sender.role,
logId: log.id,
},
'Skipping warmup reply: only primary may reply to satellite mail',
);
return;
}
const placement = options?.placement ?? 'inbox';
await client.messageFlagsAdd(String(uid), ['\\Seen', '\\Flagged'], { uid: true });
try {
await client.messageFlagsAdd(String(uid), ['\\Important'], { uid: true, useLabels: true });
} catch {
logger.debug({ inboxId: receiver.id }, 'Important flag not supported');
}
if (placement === 'inbox') {
await prisma.warmupLog.update({
where: { id: log.id },
data: { placement: 'inbox' },
});
}
if (options?.campaignContext) {
const { campaign, schedule } = options.campaignContext;
const statsToday = await getTodayReplyStats(campaign.id, campaign.primaryMailboxId);
const replyInput = buildShouldReplyInput(campaign, schedule);
const willReply = shouldReply(replyInput, statsToday);
if (!willReply) {
await markEngagedNoReply(log.id, placement);
logger.info(
{ receiver: receiver.email, sender: log.sender.email, logId: log.id },
'Warmup engaged without reply',
);
const idx = pendingLogs.findIndex((l) => l.id === log.id);
if (idx >= 0) pendingLogs.splice(idx, 1);
return;
}
}
const incomingBody = parsed.text ?? parsed.html?.toString() ?? '';
const { subject: replySubject, body: replyBody } = await aiService.generateReply(
receiver.industry,
incomingBody,
log.subject,
{ senderName: receiver.senderName, companyName: receiver.companyName },
);
const originalMessageId = log.messageId ?? messageId ?? undefined;
await withInboxGuard(receiver, async () => {
const { messageId: replyMessageId } = await emailService.sendMail(receiver, {
to: log.sender.email,
subject: replySubject,
body: replyBody,
inReplyTo: originalMessageId,
references: originalMessageId,
});
await prisma.warmupLog.create({
data: {
campaignId: options?.campaignContext?.campaign.id ?? log.campaignId,
senderId: receiver.id,
receiverId: log.senderId,
messageId: replyMessageId,
inReplyTo: originalMessageId,
subject: replySubject,
body: replyBody,
status: WarmupStatus.replied,
},
});
await prisma.warmupLog.update({
where: { id: log.id },
data: { status: WarmupStatus.replied, inReplyTo: replyMessageId, placement },
});
await prisma.warmupLog.update({
where: { id: log.id },
data: { status: WarmupStatus.complete },
});
if (options?.campaignContext) {
await incrementActualReplies(
options.campaignContext.campaign.id,
options.campaignContext.campaign.currentDay,
);
options.campaignContext.schedule.actualReplies += 1;
}
logger.info(
{ receiver: receiver.email, sender: log.sender.email, logId: log.id },
'Warmup reply sent',
);
});
const idx = pendingLogs.findIndex((l) => l.id === log.id);
if (idx >= 0) pendingLogs.splice(idx, 1);
}
async function rescueUidFromSpam(
receiver: Inbox,
client: ImapFlow,
uid: number,
pendingLogs: PendingLog[],
campaignContext?: CampaignContext,
): Promise<boolean> {
const msg = await client.fetchOne(
String(uid),
{
envelope: true,
source: true,
headers: ['message-id', 'from', 'subject'],
uid: true,
},
{ uid: true },
);
if (!msg || !msg.source) return false;
const parsed = await simpleParser(msg.source);
const fromEmail = extractFromAddress(parsed.from?.text ?? msg.envelope?.from?.[0]?.address);
const messageId = parsed.messageId ?? msg.envelope?.messageId ?? null;
const subject = parsed.subject ?? msg.envelope?.subject ?? '';
const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject);
if (!log) return false;
await client.messageMove([uid], 'INBOX', { uid: true });
await prisma.warmupLog.update({
where: { id: log.id },
data: {
status: WarmupStatus.rescued_from_spam,
rescuedFromSpam: true,
placement: 'spam',
},
});
log.status = WarmupStatus.rescued_from_spam;
if (campaignContext) {
await incrementSpamCounters(
campaignContext.campaign.id,
campaignContext.campaign.currentDay,
true,
);
}
await prisma.inbox.update({
where: { id: receiver.id },
data: { lastSpamRescuedAt: new Date() },
});
logger.info(
{ receiver: receiver.email, sender: log.sender.email, logId: log.id, uid },
'Rescued email from spam (per-UID match)',
);
return true;
}
async function rescueFromSpam(
receiver: Inbox,
client: ImapFlow,
pendingLogs: PendingLog[],
campaignContext?: CampaignContext,
): Promise<void> {
const spamFolder = await resolveSpamFolder(client, receiver);
if (!spamFolder) return;
await client.mailboxOpen(spamFolder);
const senderEmails = [...new Set(pendingLogs.map((l) => l.sender.email))];
for (const senderEmail of senderEmails) {
const uids = await client.search({ seen: false, from: senderEmail }, { uid: true });
if (!uids || uids.length === 0) continue;
for (const uid of uids) {
try {
await rescueUidFromSpam(receiver, client, uid, pendingLogs, campaignContext);
} catch (err) {
logger.error({ err, receiverId: receiver.id, uid }, 'Per-UID spam rescue failed');
}
}
}
}
async function processInboxMessages(
receiver: Inbox,
pendingLogs: PendingLog[],
campaignContext?: CampaignContext,
): Promise<void> {
if (pendingLogs.length === 0) return;
await withInboxGuard(receiver, async () => {
await withImapClient(receiver, async (client) => {
await rescueFromSpam(receiver, client, pendingLogs, campaignContext);
await client.mailboxOpen('INBOX');
const senderEmails: string[] = [
...new Set(pendingLogs.map((l: PendingLog) => l.sender.email)),
];
for (const senderEmail of senderEmails) {
const uids = await client.search({ seen: false, from: senderEmail }, { uid: true });
if (!uids || uids.length === 0) continue;
for (const uid of uids) {
try {
const log = pendingLogs.find(
(l) => l.sender.email.toLowerCase() === senderEmail.toLowerCase(),
);
const placement =
log?.status === WarmupStatus.rescued_from_spam ? 'spam' : 'inbox';
await processMessage(receiver, uid, client, pendingLogs, {
campaignContext,
placement,
});
} catch (err) {
logger.error({ err, receiverId: receiver.id, uid }, 'Failed to process message');
}
}
}
});
});
}
async function processCampaignPrimary(receiver: Inbox): Promise<void> {
const campaign = await prisma.warmupCampaign.findFirst({
where: {
primaryMailboxId: receiver.id,
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
},
include: {
satellites: {
where: { active: true },
include: { satelliteMailbox: true },
},
},
});
if (!campaign) return;
const schedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: { campaignId: campaign.id, dayIndex: campaign.currentDay },
},
});
if (!schedule) return;
const allowedEmails = new Set(
campaign.satellites.map((sat) => sat.satelliteMailbox.email.toLowerCase()),
);
const pendingLogs = (await prisma.warmupLog.findMany({
where: {
campaignId: campaign.id,
receiverId: receiver.id,
status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] },
},
include: { sender: true },
})) as PendingLog[];
const filtered = pendingLogs.filter(
(log) =>
allowedEmails.has(log.sender.email.toLowerCase()) && isSatelliteMailbox(log.sender),
);
const campaignContext: CampaignContext = {
campaign: {
id: campaign.id,
replyRatePercent: campaign.replyRatePercent,
primaryMailboxId: campaign.primaryMailboxId,
currentDay: campaign.currentDay,
},
schedule: {
plannedReplies: schedule.plannedReplies,
actualReplies: schedule.actualReplies,
},
};
await processInboxMessages(receiver, filtered, campaignContext);
}
async function processLegacyInbox(receiver: Inbox): Promise<void> {
if (!isPrimaryMailbox(receiver)) {
return;
}
const pendingLogs = (await prisma.warmupLog.findMany({
where: {
campaignId: null,
receiverId: receiver.id,
status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] },
sender: { role: MailboxRole.satellite },
},
include: { sender: true },
})) as PendingLog[];
await processInboxMessages(receiver, pendingLogs);
}
export async function runRescuerJob(): Promise<void> {
logger.info('Rescuer job started');
if (await hasAnyCampaign()) {
const campaigns = await prisma.warmupCampaign.findMany({
where: {
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
},
select: { primaryMailboxId: true },
});
const primaryIds = [...new Set(campaigns.map((c) => c.primaryMailboxId))];
for (const primaryMailboxId of primaryIds) {
const inbox = await prisma.inbox.findUnique({ where: { id: primaryMailboxId } });
if (!inbox || inbox.status === InboxStatus.paused) continue;
try {
await processCampaignPrimary(inbox);
} catch (err) {
logger.error({ err, inboxId: inbox.id }, 'Campaign rescuer failed for primary');
}
}
} else {
const primaries = await prisma.inbox.findMany({
where: { status: InboxStatus.active, role: MailboxRole.primary },
});
for (const inbox of primaries) {
try {
await processLegacyInbox(inbox);
} catch (err) {
logger.error({ err, inboxId: inbox.id }, 'Rescuer failed for inbox');
}
}
}
const paused = await checkCampaignSpamThresholds();
if (paused > 0) {
logger.warn({ paused }, 'Auto-paused campaigns after rescuer spam check');
}
logger.info('Rescuer job finished');
}

View file

@ -1,50 +0,0 @@
import { DateTime } from 'luxon';
import { logger } from '../lib/logger';
import { prisma } from '../lib/prisma';
import { finalizeDailyStatForSchedule } from '../services/stats.service';
import { checkCampaignSpamThresholds } from '../services/spam-threshold.service';
export async function runStatsRollupJob(): Promise<string> {
logger.info('Stats rollup job started');
const campaigns = await prisma.warmupCampaign.findMany({
where: { currentDay: { gt: 0 } },
include: {
dailySchedules: { orderBy: { dayIndex: 'asc' } },
},
});
let finalized = 0;
for (const campaign of campaigns) {
try {
const localNow = DateTime.now().setZone(campaign.timezone);
const hour = localNow.hour;
if (hour !== 0) continue;
const yesterday = localNow.minus({ days: 1 }).startOf('day');
const schedule = campaign.dailySchedules.find((row) => {
if (!row.date) return false;
const rowDay = DateTime.fromJSDate(row.date).setZone(campaign.timezone).startOf('day');
return rowDay.toMillis() === yesterday.toMillis();
});
if (!schedule) continue;
await finalizeDailyStatForSchedule(campaign, schedule);
finalized += 1;
logger.info(
{ campaignId: campaign.id, dayIndex: schedule.dayIndex },
'Finalized daily stat rollup',
);
} catch (err) {
logger.error({ err, campaignId: campaign.id }, 'Stats rollup failed');
}
}
const paused = await checkCampaignSpamThresholds();
logger.info('Stats rollup job finished');
return `Finalized ${finalized} day(s); auto-paused ${paused} campaign(s)`;
}

View file

@ -1,70 +0,0 @@
import { promises as dns } from 'node:dns';
export type DnsCheckResult = {
spf: 'pass' | 'warn' | 'fail';
dkim: 'pass' | 'warn' | 'fail';
dmarc: 'pass' | 'warn' | 'fail';
details: string[];
};
function extractDomain(email: string): string {
const parts = email.split('@');
return parts[1]?.toLowerCase() ?? email.toLowerCase();
}
async function resolveTxtRecords(host: string): Promise<string[]> {
try {
const records = await dns.resolveTxt(host);
return records.map((chunks) => chunks.join(''));
} catch {
return [];
}
}
export async function checkDomainDns(domain: string): Promise<DnsCheckResult> {
const details: string[] = [];
let spf: DnsCheckResult['spf'] = 'warn';
let dkim: DnsCheckResult['spf'] = 'warn';
let dmarc: DnsCheckResult['spf'] = 'warn';
const rootTxt = await resolveTxtRecords(domain);
const spfRecord = rootTxt.find((r) => r.toLowerCase().startsWith('v=spf1'));
if (spfRecord) {
spf = 'pass';
details.push(`SPF found: ${spfRecord.slice(0, 80)}`);
} else {
spf = 'fail';
details.push('No SPF TXT record found');
}
const dmarcTxt = await resolveTxtRecords(`_dmarc.${domain}`);
const dmarcRecord = dmarcTxt.find((r) => r.toLowerCase().startsWith('v=dmarc1'));
if (dmarcRecord) {
dmarc = 'pass';
details.push(`DMARC found: ${dmarcRecord.slice(0, 80)}`);
} else {
dmarc = 'warn';
details.push('No DMARC record found at _dmarc');
}
const dkimSelectors = ['google', 'selector1', 'selector2', 'default'];
let dkimFound = false;
for (const selector of dkimSelectors) {
const dkimTxt = await resolveTxtRecords(`${selector}._domainkey.${domain}`);
if (dkimTxt.some((r) => r.includes('v=DKIM1') || r.includes('k=rsa'))) {
dkimFound = true;
details.push(`DKIM found for selector ${selector}`);
break;
}
}
dkim = dkimFound ? 'pass' : 'warn';
if (!dkimFound) {
details.push('No DKIM record found for common selectors');
}
return { spf, dkim, dmarc, details };
}
export async function checkMailboxDns(email: string): Promise<DnsCheckResult> {
return checkDomainDns(extractDomain(email));
}

View file

@ -1,47 +0,0 @@
export type DomainAgeResult = {
domain: string;
registeredAt: string | null;
ageDays: number | null;
source: 'rdap' | 'unavailable';
};
type RdapEvent = { eventAction?: string; eventDate?: string };
async function fetchRdap(domain: string): Promise<DomainAgeResult> {
const response = await fetch(`https://rdap.org/domain/${encodeURIComponent(domain)}`, {
headers: { Accept: 'application/rdap+json' },
});
if (!response.ok) {
return { domain, registeredAt: null, ageDays: null, source: 'unavailable' };
}
const data = (await response.json()) as { events?: RdapEvent[] };
const registration = data.events?.find((event) => event.eventAction === 'registration');
if (!registration?.eventDate) {
return { domain, registeredAt: null, ageDays: null, source: 'unavailable' };
}
const registeredAt = new Date(registration.eventDate);
if (Number.isNaN(registeredAt.getTime())) {
return { domain, registeredAt: null, ageDays: null, source: 'unavailable' };
}
const ageDays = Math.floor((Date.now() - registeredAt.getTime()) / (1000 * 60 * 60 * 24));
return {
domain,
registeredAt: registeredAt.toISOString(),
ageDays,
source: 'rdap',
};
}
export async function checkDomainAge(emailOrDomain: string): Promise<DomainAgeResult> {
const domain = emailOrDomain.includes('@')
? emailOrDomain.split('@')[1]!.toLowerCase()
: emailOrDomain.toLowerCase();
return fetchRdap(domain);
}

View file

@ -1,68 +0,0 @@
import type { Inbox } from '../lib/prisma';
import { InboxStatus } from '../lib/prisma';
import { prisma } from '../lib/prisma';
import { logger } from './logger';
/** Consecutive failed operations before marking a mailbox as error. */
export const MAILBOX_FAILURE_THRESHOLD = 3;
export async function withInboxGuard<T>(
inbox: Inbox,
fn: () => Promise<T>,
): Promise<T> {
try {
const result = await fn();
await prisma.inbox.update({
where: { id: inbox.id },
data: {
consecutiveFailures: 0,
...(inbox.status === InboxStatus.error
? {
status: InboxStatus.active,
lastError: null,
errorAt: null,
}
: {}),
},
});
return result;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
const updated = await prisma.inbox.update({
where: { id: inbox.id },
data: {
consecutiveFailures: { increment: 1 },
lastError: message,
errorAt: new Date(),
},
select: { consecutiveFailures: true },
});
if (updated.consecutiveFailures >= MAILBOX_FAILURE_THRESHOLD) {
await prisma.inbox.update({
where: { id: inbox.id },
data: { status: InboxStatus.error },
});
logger.error(
{ inboxId: inbox.id, email: inbox.email, consecutiveFailures: updated.consecutiveFailures, message },
'Mailbox marked error after consecutive failures',
);
} else {
logger.warn(
{ inboxId: inbox.id, email: inbox.email, consecutiveFailures: updated.consecutiveFailures, message },
'Mailbox operation failed (still active)',
);
}
throw err;
}
}
export function stripPassword<T extends { password?: string; oauthRefreshToken?: string | null }>(
inbox: T,
): Omit<T, 'password' | 'oauthRefreshToken'> {
const { password: _, oauthRefreshToken: __, ...rest } = inbox;
return rest;
}

View file

@ -1,64 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MailboxRole } from './prisma';
import {
isAllowedWarmupReplyPair,
isAllowedWarmupSendPair,
} from './mailbox-topology';
describe('mailbox-topology', () => {
it('allows satellite → primary sends only', () => {
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.primary },
),
true,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.satellite },
),
false,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.primary },
{ role: MailboxRole.primary },
),
false,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.unassigned },
{ role: MailboxRole.primary },
),
false,
);
});
it('allows primary → satellite replies only', () => {
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.primary },
{ role: MailboxRole.satellite },
),
true,
);
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.satellite },
),
false,
);
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.primary },
),
false,
);
});
});

View file

@ -1,22 +0,0 @@
import type { Inbox } from '../generated/prisma/client';
import { MailboxRole } from './prisma';
type RoleLike = Pick<Inbox, 'role'>;
export function isSatelliteMailbox(mailbox: RoleLike): boolean {
return mailbox.role === MailboxRole.satellite;
}
export function isPrimaryMailbox(mailbox: RoleLike): boolean {
return mailbox.role === MailboxRole.primary;
}
/** Warmup sends are satellite → primary only. */
export function isAllowedWarmupSendPair(sender: RoleLike, receiver: RoleLike): boolean {
return isSatelliteMailbox(sender) && isPrimaryMailbox(receiver);
}
/** Warmup replies are primary → satellite only. */
export function isAllowedWarmupReplyPair(responder: RoleLike, originalSender: RoleLike): boolean {
return isPrimaryMailbox(responder) && isSatelliteMailbox(originalSender);
}

View file

@ -1,53 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { isRetryableConnectionError, withRetry } from './retry';
describe('retry', () => {
it('treats timeouts as retryable', () => {
assert.equal(isRetryableConnectionError(new Error('Connection timeout')), true);
assert.equal(isRetryableConnectionError(new Error('ETIMEDOUT')), true);
});
it('treats auth failures as non-retryable', () => {
assert.equal(isRetryableConnectionError(new Error('Invalid credentials')), false);
assert.equal(isRetryableConnectionError(new Error('Authentication failed')), false);
});
it('returns on first success', async () => {
let calls = 0;
const result = await withRetry(async () => {
calls += 1;
return 'ok';
});
assert.equal(result, 'ok');
assert.equal(calls, 1);
});
it('retries transient errors then succeeds', async () => {
let calls = 0;
const result = await withRetry(
async () => {
calls += 1;
if (calls === 1) {
throw new Error('ETIMEDOUT');
}
return 'ok';
},
{ baseDelayMs: 1 },
);
assert.equal(result, 'ok');
assert.equal(calls, 2);
});
it('does not retry auth errors', async () => {
let calls = 0;
await assert.rejects(
withRetry(async () => {
calls += 1;
throw new Error('Invalid credentials');
}),
/Invalid credentials/,
);
assert.equal(calls, 1);
});
});

View file

@ -1,98 +0,0 @@
import { logger } from './logger';
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_BASE_DELAY_MS = 1_000;
const NON_RETRYABLE_PATTERNS = [
'invalid credentials',
'authentication failed',
'authenticationfailed',
'invalid login',
'username and password not accepted',
'oauth',
'not configured',
'validation failed',
'content validation',
];
const RETRYABLE_PATTERNS = [
'timeout',
'etimedout',
'econnreset',
'econnrefused',
'enotfound',
'socket',
'connection lost',
'connection closed',
'too many',
'rate limit',
'temporarily',
'try again',
'service unavailable',
'unavailable',
'503',
'421',
'454',
'4.7.0',
'system error',
'network',
'closed',
'broken pipe',
'eai_again',
];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function isRetryableConnectionError(err: unknown): boolean {
const message = errorMessage(err).toLowerCase();
if (NON_RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern))) {
return false;
}
return RETRYABLE_PATTERNS.some((pattern) => message.includes(pattern));
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function withRetry<T>(
fn: () => Promise<T>,
options?: {
maxAttempts?: number;
baseDelayMs?: number;
label?: string;
},
): Promise<T> {
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const baseDelayMs = options?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
const label = options?.label ?? 'operation';
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const retryable = isRetryableConnectionError(err);
const isLastAttempt = attempt === maxAttempts;
if (!retryable || isLastAttempt) {
throw err;
}
const delayMs = baseDelayMs * 2 ** (attempt - 1);
logger.warn(
{ attempt, maxAttempts, delayMs, label, err: errorMessage(err) },
'Retrying after transient connection failure',
);
await sleep(delayMs);
}
}
throw lastError;
}

View file

@ -1,56 +0,0 @@
import { DateTime } from 'luxon';
function parseTimeParts(time: string): { hour: number; minute: number } {
const [hourStr, minuteStr] = time.split(':');
const hour = Number(hourStr);
const minute = Number(minuteStr ?? 0);
if (
Number.isNaN(hour) ||
Number.isNaN(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
throw new Error(`Invalid time format: ${time}`);
}
return { hour, minute };
}
export function isWithinSendWindow(
timezone: string,
sendWindowStart: string,
sendWindowEnd: string,
now: DateTime = DateTime.now(),
): boolean {
const local = now.setZone(timezone);
if (!local.isValid) {
return false;
}
const startParts = parseTimeParts(sendWindowStart);
const endParts = parseTimeParts(sendWindowEnd);
const start = local.set({
hour: startParts.hour,
minute: startParts.minute,
second: 0,
millisecond: 0,
});
const end = local.set({
hour: endParts.hour,
minute: endParts.minute,
second: 59,
millisecond: 999,
});
if (end < start) {
return local >= start || local <= end;
}
return local >= start && local <= end;
}
export function calendarDayInTimezone(timezone: string, date: Date = new Date()): DateTime {
return DateTime.fromJSDate(date).setZone(timezone).startOf('day');
}

View file

@ -1,75 +0,0 @@
import { z } from 'zod';
import { CampaignRecipe, CampaignStatus, MailboxRole } from '../lib/prisma';
export const createCampaignSchema = z.object({
workspaceId: z.string().optional(),
primaryMailboxId: z.string().min(1),
name: z.string().min(1),
recipe: z.nativeEnum(CampaignRecipe).default(CampaignRecipe.grow),
durationDays: z.coerce.number().int().min(14).max(45).default(30),
minEmailsPerDay: z.coerce.number().int().min(1).default(1),
maxEmailsPerDay: z.coerce.number().int().max(50).default(40),
replyRatePercent: z.coerce.number().int().min(10).max(45).default(30),
maintenanceVolumePercent: z.coerce.number().int().min(1).max(100).default(20),
timezone: z.string().default('America/Vancouver'),
sendWindowStart: z.string().default('08:00'),
sendWindowEnd: z.string().default('18:00'),
customSchedule: z
.array(
z.object({
day: z.coerce.number().int().min(1),
sends: z.coerce.number().int().min(1),
}),
)
.optional(),
satelliteMailboxIds: z.array(z.string()).optional(),
});
export const updateCampaignSchema = z.object({
name: z.string().min(1).optional(),
recipe: z.nativeEnum(CampaignRecipe).optional(),
durationDays: z.coerce.number().int().min(14).max(45).optional(),
minEmailsPerDay: z.coerce.number().int().min(1).optional(),
maxEmailsPerDay: z.coerce.number().int().max(50).optional(),
replyRatePercent: z.coerce.number().int().min(10).max(45).optional(),
maintenanceVolumePercent: z.coerce.number().int().min(1).max(100).optional(),
timezone: z.string().optional(),
sendWindowStart: z.string().optional(),
sendWindowEnd: z.string().optional(),
customSchedule: z
.array(
z.object({
day: z.coerce.number().int().min(1),
sends: z.coerce.number().int().min(1),
}),
)
.optional(),
});
export const assignSatelliteSchema = z.object({
mailboxId: z.string().min(1),
weight: z.coerce.number().int().positive().default(1),
});
export const recipePreviewSchema = z.object({
recipe: z.nativeEnum(CampaignRecipe).default(CampaignRecipe.grow),
durationDays: z.coerce.number().int().min(14).max(45).default(30),
minEmailsPerDay: z.coerce.number().int().min(1).default(1),
maxEmailsPerDay: z.coerce.number().int().max(50).default(40),
replyRatePercent: z.coerce.number().int().min(10).max(45).default(30),
customSchedule: z
.array(
z.object({
day: z.coerce.number().int().min(1),
sends: z.coerce.number().int().min(1),
}),
)
.optional(),
});
export const campaignListQuerySchema = z.object({
status: z.nativeEnum(CampaignStatus).optional(),
workspaceId: z.string().optional(),
});
export const mailboxRoleSchema = z.nativeEnum(MailboxRole);

View file

@ -1,50 +0,0 @@
import { MailboxProvider } from '../lib/prisma';
export type ProviderPreset = {
smtpHost: string;
smtpPort: number;
imapHost: string;
imapPort: number;
};
export const PROVIDER_PRESETS: Record<MailboxProvider, ProviderPreset | null> = {
google_workspace: {
smtpHost: 'smtp.gmail.com',
smtpPort: 587,
imapHost: 'imap.gmail.com',
imapPort: 993,
},
gmail: {
smtpHost: 'smtp.gmail.com',
smtpPort: 587,
imapHost: 'imap.gmail.com',
imapPort: 993,
},
yahoo: {
smtpHost: 'smtp.mail.yahoo.com',
smtpPort: 587,
imapHost: 'imap.mail.yahoo.com',
imapPort: 993,
},
outlook: {
smtpHost: 'smtp-mail.outlook.com',
smtpPort: 587,
imapHost: 'outlook.office365.com',
imapPort: 993,
},
custom: null,
};
export function applyProviderPreset(
provider: MailboxProvider,
data: Partial<ProviderPreset>,
): Partial<ProviderPreset> {
const preset = PROVIDER_PRESETS[provider];
if (!preset) return data;
return {
smtpHost: data.smtpHost ?? preset.smtpHost,
smtpPort: data.smtpPort ?? preset.smtpPort,
imapHost: data.imapHost ?? preset.imapHost,
imapPort: data.imapPort ?? preset.imapPort,
};
}

View file

@ -1,2 +0,0 @@
export const DEFAULT_WORKSPACE_NAME = 'default';
export const DEFAULT_WORKSPACE_ID = 'ws_default';

View file

@ -1,46 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as aiService from '../services/ai.service';
import { ContentValidationError } from '../services/content-validator.service';
import { AppError } from '../middleware/errorHandler';
const router = Router();
const testGenerateSchema = z.object({
industry: z.string().default('SaaS'),
senderName: z.string().optional(),
companyName: z.string().optional(),
type: z.enum(['initial', 'reply']).default('initial'),
incomingBody: z.string().optional(),
threadSubject: z.string().optional(),
});
router.post('/test-generate', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = testGenerateSchema.parse(req.body);
const profile = {
senderName: data.senderName,
companyName: data.companyName,
};
const email =
data.type === 'reply'
? await aiService.generateReply(
data.industry,
data.incomingBody ?? 'Thanks for reaching out about the project timeline.',
data.threadSubject ?? 'Project follow-up',
profile,
)
: await aiService.generateInitialEmail(data.industry, profile);
res.json(email);
} catch (err) {
if (err instanceof ContentValidationError) {
next(new AppError(422, err.message));
return;
}
next(err);
}
});
export default router;

View file

@ -1,183 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { stripPassword } from '../lib/inboxGuard';
import { AppError } from '../middleware/errorHandler';
import {
assignSatelliteSchema,
campaignListQuerySchema,
createCampaignSchema,
updateCampaignSchema,
} from '../models/campaign.schemas';
import {
assignSatellite,
CampaignError,
createCampaign,
deleteCampaign,
getCampaignById,
listCampaigns,
listSatellites,
pauseCampaign,
resumeCampaign,
startCampaign,
unassignSatellite,
updateCampaign,
} from '../services/campaign.service';
const router = Router();
function handleCampaignError(err: unknown, next: NextFunction) {
if (err instanceof CampaignError) {
next(new AppError(err.statusCode, err.message));
return;
}
next(err);
}
function sanitizeCampaign(campaign: Awaited<ReturnType<typeof getCampaignById>>) {
return {
...campaign,
primaryMailbox: stripPassword(campaign.primaryMailbox),
satellites: campaign.satellites.map((s) => ({
...s,
satelliteMailbox: stripPassword(s.satelliteMailbox),
})),
};
}
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = createCampaignSchema.parse(req.body);
const campaign = await createCampaign(data);
res.status(201).json(sanitizeCampaign(campaign));
} catch (err) {
handleCampaignError(err, next);
}
});
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const filters = campaignListQuerySchema.parse(req.query);
const campaigns = await listCampaigns(filters);
res.json(
campaigns.map((c) => ({
...c,
primaryMailbox: stripPassword(c.primaryMailbox),
satellites: c.satellites.map((s) => ({
...s,
satelliteMailbox: stripPassword(s.satelliteMailbox),
})),
})),
);
} catch (err) {
next(err);
}
});
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await getCampaignById(req.params.id);
res.json(sanitizeCampaign(campaign));
} catch (err) {
handleCampaignError(err, next);
}
});
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = updateCampaignSchema.parse(req.body);
const campaign = await updateCampaign(req.params.id, data);
res.json({
...campaign,
primaryMailbox: stripPassword(campaign.primaryMailbox),
satellites: campaign.satellites.map((s) => ({
...s,
satelliteMailbox: stripPassword(s.satelliteMailbox),
})),
});
} catch (err) {
handleCampaignError(err, next);
}
});
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await deleteCampaign(req.params.id);
res.status(204).send();
} catch (err) {
handleCampaignError(err, next);
}
});
router.post('/:id/start', async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await startCampaign(req.params.id);
res.json({
...campaign,
primaryMailbox: stripPassword(campaign.primaryMailbox),
satellites: campaign.satellites.map((s) => ({
...s,
satelliteMailbox: stripPassword(s.satelliteMailbox),
})),
});
} catch (err) {
handleCampaignError(err, next);
}
});
router.post('/:id/pause', async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await pauseCampaign(req.params.id);
res.json(sanitizeCampaign(campaign));
} catch (err) {
handleCampaignError(err, next);
}
});
router.post('/:id/resume', async (req: Request, res: Response, next: NextFunction) => {
try {
const campaign = await resumeCampaign(req.params.id);
res.json(sanitizeCampaign(campaign));
} catch (err) {
handleCampaignError(err, next);
}
});
router.get('/:id/satellites', async (req: Request, res: Response, next: NextFunction) => {
try {
const satellites = await listSatellites(req.params.id);
res.json(
satellites.map((s) => ({
...s,
satelliteMailbox: stripPassword(s.satelliteMailbox),
})),
);
} catch (err) {
handleCampaignError(err, next);
}
});
router.post('/:id/satellites', async (req: Request, res: Response, next: NextFunction) => {
try {
const { mailboxId, weight } = assignSatelliteSchema.parse(req.body);
const link = await assignSatellite(req.params.id, mailboxId, weight);
res.status(201).json({
...link,
satelliteMailbox: stripPassword(link.satelliteMailbox),
});
} catch (err) {
handleCampaignError(err, next);
}
});
router.delete(
'/:id/satellites/:mailboxId',
async (req: Request, res: Response, next: NextFunction) => {
try {
await unassignSatellite(req.params.id, req.params.mailboxId);
res.status(204).send();
} catch (err) {
handleCampaignError(err, next);
}
},
);
export default router;

View file

@ -1,373 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { InboxStatus, MailboxAuthType, MailboxProvider, MailboxRole } from '../lib/prisma';
import { prisma } from '../lib/prisma';
import { encrypt } from '../services/crypto.service';
import { testConnection } from '../services/email.service';
import { isMicrosoftConsumerEmail } from '../services/microsoft-oauth.service';
import { stripPassword } from '../lib/inboxGuard';
import { AppError } from '../middleware/errorHandler';
import { applyProviderPreset, PROVIDER_PRESETS } from '../models/provider-presets';
import { checkMailboxDns } from '../lib/dns-check';
import { resolveWorkspaceId } from '../services/workspace.service';
const router = Router();
const createInboxSchema = z.object({
email: z.string().email(),
smtpHost: z.string().min(1).optional(),
smtpPort: z.coerce.number().int().positive().optional(),
imapHost: z.string().min(1).optional(),
imapPort: z.coerce.number().int().positive().optional(),
username: z.string().min(1),
password: z.string().optional(),
authType: z.nativeEnum(MailboxAuthType).optional(),
oauthRefreshToken: z.string().optional(),
workspaceId: z.string().optional(),
role: z.nativeEnum(MailboxRole).optional(),
provider: z.nativeEnum(MailboxProvider).default(MailboxProvider.custom),
dailyLimit: z.coerce.number().int().positive().optional(),
currentRamp: z.coerce.number().int().positive().optional(),
industry: z.string().min(1).optional(),
senderName: z.string().optional(),
companyName: z.string().optional(),
status: z.nativeEnum(InboxStatus).optional(),
});
const updateInboxSchema = z.object({
email: z.string().email().optional(),
smtpHost: z.string().min(1).optional(),
smtpPort: z.coerce.number().int().positive().optional(),
imapHost: z.string().min(1).optional(),
imapPort: z.coerce.number().int().positive().optional(),
username: z.string().min(1).optional(),
password: z.string().optional(),
authType: z.nativeEnum(MailboxAuthType).optional(),
oauthRefreshToken: z.string().optional(),
role: z.nativeEnum(MailboxRole).optional(),
provider: z.nativeEnum(MailboxProvider).optional(),
dailyLimit: z.coerce.number().int().positive().optional(),
currentRamp: z.coerce.number().int().positive().optional(),
industry: z.string().min(1).optional(),
senderName: z.string().optional(),
companyName: z.string().optional(),
status: z.nativeEnum(InboxStatus).optional(),
});
const listQuerySchema = z.object({
role: z.nativeEnum(MailboxRole).optional(),
workspaceId: z.string().optional(),
});
router.get('/provider-presets', (_req: Request, res: Response) => {
res.json(PROVIDER_PRESETS);
});
function resolveAuthType(
data: z.infer<typeof createInboxSchema>,
provider: MailboxProvider,
): MailboxAuthType {
if (data.authType) return data.authType;
if (provider === MailboxProvider.outlook && data.oauthRefreshToken) {
return MailboxAuthType.microsoft_oauth;
}
return MailboxAuthType.password;
}
async function validateRoleChange(inboxId: string, newRole: MailboxRole): Promise<void> {
const inbox = await prisma.inbox.findUnique({ where: { id: inboxId } });
if (!inbox) throw new AppError(404, 'Inbox not found');
if (newRole === inbox.role) return;
if (newRole === MailboxRole.primary) {
throw new AppError(400, 'Set a mailbox as primary by creating or editing a campaign');
}
if (inbox.role === MailboxRole.primary) {
const activeCampaign = await prisma.warmupCampaign.findFirst({
where: {
primaryMailboxId: inboxId,
status: { in: ['active', 'maintenance', 'paused'] },
},
});
if (activeCampaign) {
throw new AppError(400, 'Cannot change role while this mailbox is a campaign primary');
}
}
if (newRole === MailboxRole.unassigned && inbox.role === MailboxRole.satellite) {
const assignments = await prisma.campaignSatellite.count({
where: { satelliteMailboxId: inboxId },
});
if (assignments > 0) {
throw new AppError(
400,
'Remove this mailbox from all campaigns before setting role to unassigned',
);
}
}
}
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = createInboxSchema.parse(req.body);
const workspaceId = await resolveWorkspaceId(data.workspaceId);
const provider = data.provider ?? MailboxProvider.custom;
const authType = resolveAuthType(data, provider);
if (authType === MailboxAuthType.password && !data.password) {
throw new AppError(400, 'Password is required for password authentication');
}
if (authType === MailboxAuthType.microsoft_oauth && !data.oauthRefreshToken) {
throw new AppError(
400,
'Microsoft OAuth is required for @live.com/@outlook.com accounts. Use Connect Microsoft.',
);
}
const presetFields = applyProviderPreset(provider, {
smtpHost: data.smtpHost,
smtpPort: data.smtpPort,
imapHost: data.imapHost,
imapPort: data.imapPort,
});
if (!presetFields.smtpHost || !presetFields.imapHost || !presetFields.smtpPort || !presetFields.imapPort) {
throw new AppError(400, 'smtpHost, smtpPort, imapHost, and imapPort are required for custom provider');
}
if (
authType === MailboxAuthType.password &&
provider === MailboxProvider.outlook &&
isMicrosoftConsumerEmail(data.email)
) {
throw new AppError(
400,
'Microsoft consumer accounts (@live.com, @outlook.com) require OAuth. Use Connect Microsoft instead of an app password.',
);
}
const candidate = {
id: 'test',
workspaceId,
email: data.email,
smtpHost: presetFields.smtpHost,
smtpPort: presetFields.smtpPort,
imapHost: presetFields.imapHost,
imapPort: presetFields.imapPort,
username: data.username,
password: encrypt(data.password ?? 'oauth-placeholder'),
authType,
oauthRefreshToken: data.oauthRefreshToken ? encrypt(data.oauthRefreshToken) : null,
role: data.role ?? MailboxRole.unassigned,
provider,
status: InboxStatus.active,
dailyLimit: data.dailyLimit ?? 40,
currentRamp: data.currentRamp ?? 5,
industry: data.industry ?? 'SaaS',
senderName: data.senderName ?? null,
companyName: data.companyName ?? null,
lastError: null,
errorAt: null,
consecutiveFailures: 0,
spamFolderPath: null,
lastSpamRescuedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const connection = await testConnection(candidate);
if (!connection.smtp.ok || !connection.imap.ok) {
const errors = [
!connection.smtp.ok ? `SMTP: ${connection.smtp.error}` : null,
!connection.imap.ok ? `IMAP: ${connection.imap.error}` : null,
].filter(Boolean);
throw new AppError(400, `Connection test failed. ${errors.join(' ')}`);
}
const inbox = await prisma.inbox.create({
data: {
workspaceId,
email: data.email,
smtpHost: presetFields.smtpHost,
smtpPort: presetFields.smtpPort,
imapHost: presetFields.imapHost,
imapPort: presetFields.imapPort,
username: data.username,
password: encrypt(data.password ?? 'oauth-placeholder'),
authType,
oauthRefreshToken: data.oauthRefreshToken ? encrypt(data.oauthRefreshToken) : null,
role: data.role ?? MailboxRole.unassigned,
provider,
dailyLimit: data.dailyLimit ?? 40,
currentRamp: data.currentRamp ?? 5,
industry: data.industry ?? 'SaaS',
senderName: data.senderName,
companyName: data.companyName,
status: InboxStatus.active,
},
});
res.status(201).json(stripPassword(inbox));
} catch (err) {
next(err);
}
});
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { role, workspaceId: wsId } = listQuerySchema.parse(req.query);
const workspaceId = wsId ? await resolveWorkspaceId(wsId) : undefined;
const inboxes = await prisma.inbox.findMany({
where: {
...(role ? { role } : {}),
...(workspaceId ? { workspaceId } : {}),
},
orderBy: { createdAt: 'desc' },
});
res.json(inboxes.map(stripPassword));
} catch (err) {
next(err);
}
});
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } });
if (!inbox) {
throw new AppError(404, 'Inbox not found');
}
res.json(stripPassword(inbox));
} catch (err) {
next(err);
}
});
router.patch('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = updateInboxSchema.parse(req.body);
const { password, provider, oauthRefreshToken, role, authType, ...rest } = data;
if (role) {
await validateRoleChange(req.params.id, role);
}
let connectionUpdates = {};
if (provider) {
connectionUpdates = applyProviderPreset(provider, {
smtpHost: data.smtpHost,
smtpPort: data.smtpPort,
imapHost: data.imapHost,
imapPort: data.imapPort,
});
}
const existing = await prisma.inbox.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Inbox not found');
const nextAuthType = authType ?? existing.authType;
const nextPassword = password ? encrypt(password) : existing.password;
const nextOAuth = oauthRefreshToken ? encrypt(oauthRefreshToken) : existing.oauthRefreshToken;
if (password || oauthRefreshToken || authType || provider) {
const candidate = {
...existing,
...rest,
...(provider ? { provider } : {}),
...connectionUpdates,
password: nextPassword,
authType: nextAuthType,
oauthRefreshToken: nextOAuth,
};
const connection = await testConnection(candidate);
if (!connection.smtp.ok || !connection.imap.ok) {
const errors = [
!connection.smtp.ok ? `SMTP: ${connection.smtp.error}` : null,
!connection.imap.ok ? `IMAP: ${connection.imap.error}` : null,
].filter(Boolean);
throw new AppError(400, `Connection test failed. ${errors.join(' ')}`);
}
}
const inbox = await prisma.inbox.update({
where: { id: req.params.id },
data: {
...rest,
...(role ? { role } : {}),
...(provider ? { provider } : {}),
...(authType ? { authType } : {}),
...connectionUpdates,
...(password ? { password: encrypt(password) } : {}),
...(oauthRefreshToken ? { oauthRefreshToken: encrypt(oauthRefreshToken) } : {}),
...(rest.status === InboxStatus.active
? { lastError: null, errorAt: null, consecutiveFailures: 0 }
: {}),
},
});
res.json(stripPassword(inbox));
} catch (err) {
next(err);
}
});
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await prisma.inbox.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
});
router.post('/:id/dns-check', async (req: Request, res: Response, next: NextFunction) => {
try {
const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } });
if (!inbox) {
throw new AppError(404, 'Inbox not found');
}
const result = await checkMailboxDns(inbox.email);
res.json(result);
} catch (err) {
next(err);
}
});
router.post('/:id/test', async (req: Request, res: Response, next: NextFunction) => {
try {
const inbox = await prisma.inbox.findUnique({ where: { id: req.params.id } });
if (!inbox) {
throw new AppError(404, 'Inbox not found');
}
const result = await testConnection(inbox);
const ok = result.smtp.ok && result.imap.ok;
if (!ok) {
await prisma.inbox.update({
where: { id: inbox.id },
data: {
status: InboxStatus.error,
lastError: result.smtp.error ?? result.imap.error ?? 'Connection test failed',
errorAt: new Date(),
},
});
} else {
await prisma.inbox.update({
where: { id: inbox.id },
data: {
status: InboxStatus.active,
lastError: null,
errorAt: null,
consecutiveFailures: 0,
},
});
}
res.json({ ok, ...result });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,159 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { AppError } from '../middleware/errorHandler';
import {
buildGoogleAuthUrl,
exchangeGoogleAuthCode,
getGoogleOAuthSettings,
getPostmasterStatus,
} from '../services/postmaster.service';
import {
buildMicrosoftAuthUrl,
exchangeMicrosoftAuthCode,
getMicrosoftOAuthSettings,
} from '../services/microsoft-oauth.service';
import { upsertSetting } from '../services/settings.service';
const router = Router();
const googleSettingsSchema = z.object({
google_oauth_client_id: z.string().min(1),
google_oauth_client_secret: z.string().min(1),
});
router.get('/google/status', async (_req: Request, res: Response, next: NextFunction) => {
try {
const status = await getPostmasterStatus();
const { clientId } = await getGoogleOAuthSettings();
res.json({
...status,
hasClientId: Boolean(clientId),
redirectUri: undefined,
});
} catch (err) {
next(err);
}
});
router.post('/google/credentials', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = googleSettingsSchema.parse(req.body);
await upsertSetting('google_oauth_client_id', data.google_oauth_client_id);
await upsertSetting('google_oauth_client_secret', data.google_oauth_client_secret);
const status = await getPostmasterStatus();
res.json(status);
} catch (err) {
next(err);
}
});
router.get('/google/auth-url', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { clientId } = await getGoogleOAuthSettings();
if (!clientId) {
throw new AppError(400, 'Configure Google OAuth client ID and secret first');
}
res.json({ url: buildGoogleAuthUrl(clientId) });
} catch (err) {
next(err);
}
});
const microsoftSettingsSchema = z.object({
microsoft_oauth_client_id: z.string().min(1),
microsoft_oauth_client_secret: z.string().min(1),
});
router.get('/microsoft/status', async (_req: Request, res: Response, next: NextFunction) => {
try {
const { clientId, clientSecret } = await getMicrosoftOAuthSettings();
res.json({
configured: Boolean(clientId && clientSecret),
hasClientId: Boolean(clientId),
});
} catch (err) {
next(err);
}
});
router.post('/microsoft/credentials', async (req: Request, res: Response, next: NextFunction) => {
try {
const data = microsoftSettingsSchema.parse(req.body);
await upsertSetting('microsoft_oauth_client_id', data.microsoft_oauth_client_id);
await upsertSetting('microsoft_oauth_client_secret', data.microsoft_oauth_client_secret);
res.json({ configured: true });
} catch (err) {
next(err);
}
});
router.get('/microsoft/auth-url', async (req: Request, res: Response, next: NextFunction) => {
try {
const { clientId } = await getMicrosoftOAuthSettings();
if (!clientId) {
throw new AppError(400, 'Configure Microsoft OAuth client ID and secret first');
}
const state = typeof req.query.state === 'string' ? req.query.state : 'mailbox';
res.json({ url: buildMicrosoftAuthUrl(clientId, state) });
} catch (err) {
next(err);
}
});
export async function microsoftOAuthCallback(req: Request, res: Response): Promise<void> {
try {
const code = typeof req.query.code === 'string' ? req.query.code : null;
if (!code) {
res.status(400).send('Missing authorization code');
return;
}
const { refreshToken } = await exchangeMicrosoftAuthCode(code);
res.send(`
<!DOCTYPE html>
<html><body style="font-family: sans-serif; padding: 2rem;">
<h1>Microsoft account connected</h1>
<p>You can close this tab and return to Warmbox.</p>
<script>
if (window.opener) {
window.opener.postMessage({
type: 'warmbox-microsoft-oauth',
refreshToken: ${JSON.stringify(refreshToken)}
}, '*');
}
</script>
</body></html>
`);
} catch (err) {
const message = err instanceof Error ? err.message : 'OAuth callback failed';
res.status(500).send(message);
}
}
export async function googleOAuthCallback(req: Request, res: Response): Promise<void> {
try {
const code = typeof req.query.code === 'string' ? req.query.code : null;
if (!code) {
res.status(400).send('Missing authorization code');
return;
}
const refreshToken = await exchangeGoogleAuthCode(code);
await upsertSetting('google_postmaster_refresh_token', refreshToken);
res.send(`
<!DOCTYPE html>
<html><body style="font-family: sans-serif; padding: 2rem;">
<h1>Google Postmaster connected</h1>
<p>Warmbox can now verify your domains against Postmaster Tools.</p>
<p>You can close this tab and return to the dashboard Settings page.</p>
</body></html>
`);
} catch (err) {
const message = err instanceof Error ? err.message : 'OAuth callback failed';
res.status(500).send(message);
}
}
export default router;

View file

@ -1,60 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { runCampaignDispatcherJob } from '../jobs/campaign-dispatcher.job';
import { runCampaignRolloverJob } from '../jobs/campaign-rollover.job';
import { runStatsRollupJob } from '../jobs/stats-rollup.job';
import { runRescuerJob } from '../jobs/rescuer.job';
import { runLoggedJob } from '../jobs/job-runner';
import { getJobStatuses } from '../services/job-queue.service';
const router = Router();
router.get('/status', async (_req: Request, res: Response, next: NextFunction) => {
try {
const jobs = await getJobStatuses();
res.json({ jobs });
} catch (err) {
next(err);
}
});
router.post('/dispatcher/run', async (_req: Request, res: Response, next: NextFunction) => {
try {
await runLoggedJob('campaign-dispatcher', runCampaignDispatcherJob);
res.json({ ok: true, job: 'campaign-dispatcher' });
} catch (err) {
next(err);
}
});
router.post('/rollover/run', async (_req: Request, res: Response, next: NextFunction) => {
try {
await runLoggedJob('campaign-rollover', async () => {
await runCampaignRolloverJob();
});
res.json({ ok: true, job: 'campaign-rollover' });
} catch (err) {
next(err);
}
});
router.post('/rescuer/run', async (_req: Request, res: Response, next: NextFunction) => {
try {
await runLoggedJob('rescuer', async () => {
await runRescuerJob();
});
res.json({ ok: true, job: 'rescuer' });
} catch (err) {
next(err);
}
});
router.post('/rollup/run', async (_req: Request, res: Response, next: NextFunction) => {
try {
await runLoggedJob('stats-rollup', runStatsRollupJob);
res.json({ ok: true, job: 'stats-rollup' });
} catch (err) {
next(err);
}
});
export default router;

View file

@ -1,60 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { CampaignRecipe } from '../lib/prisma';
import { recipePreviewSchema } from '../models/campaign.schemas';
import {
CUSTOM_RECIPE_TEMPLATE,
FLAT_RECIPE_TEMPLATE,
generateSchedule,
GROW_RECIPE_TEMPLATE,
RANDOMIZED_RECIPE_TEMPLATE,
RecipeValidationError,
validateRecipeParams,
} from '../services/recipe.service';
import { AppError } from '../middleware/errorHandler';
const router = Router();
router.post('/preview', async (req: Request, res: Response, next: NextFunction) => {
try {
const params = recipePreviewSchema.parse(req.body);
const recipeParams = {
recipe: params.recipe,
durationDays: params.durationDays,
minEmailsPerDay: params.minEmailsPerDay,
maxEmailsPerDay: params.maxEmailsPerDay,
replyRatePercent: params.replyRatePercent,
};
validateRecipeParams(recipeParams, {
customSchedule:
params.recipe === CampaignRecipe.custom ? params.customSchedule : undefined,
});
const schedule = generateSchedule(recipeParams, {
seed: 'preview',
customSchedule: params.customSchedule,
});
res.json({ schedule });
} catch (err) {
if (err instanceof RecipeValidationError) {
next(new AppError(400, err.message));
return;
}
next(err);
}
});
router.get('/templates', (_req: Request, res: Response) => {
res.json({
grow: GROW_RECIPE_TEMPLATE,
flat: FLAT_RECIPE_TEMPLATE,
randomized: RANDOMIZED_RECIPE_TEMPLATE,
custom: CUSTOM_RECIPE_TEMPLATE,
supported: [
CampaignRecipe.grow,
CampaignRecipe.flat,
CampaignRecipe.randomized,
CampaignRecipe.custom,
],
});
});
export default router;

View file

@ -1,88 +0,0 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { CampaignError } from '../services/campaign.service';
import {
getCampaignStatsDaily,
getCampaignStatsSummary,
getCampaignStatsToday,
getCampaignWarmupPlan,
getWorkspaceOverview,
StatsRange,
} from '../services/stats.service';
import { AppError } from '../middleware/errorHandler';
const router = Router();
const rangeSchema = z.enum(['7d', '30d', 'all']);
function handleError(err: unknown, next: NextFunction) {
if (err instanceof CampaignError) {
next(new AppError(err.statusCode, err.message));
return;
}
next(err);
}
router.get('/:id/stats/today', async (req: Request, res: Response, next: NextFunction) => {
try {
const stats = await getCampaignStatsToday(req.params.id);
res.json(stats);
} catch (err) {
handleError(err, next);
}
});
router.get('/:id/stats/plan', async (req: Request, res: Response, next: NextFunction) => {
try {
const plan = await getCampaignWarmupPlan(req.params.id);
res.json(plan);
} catch (err) {
handleError(err, next);
}
});
router.get('/:id/stats/daily', async (req: Request, res: Response, next: NextFunction) => {
try {
const from = typeof req.query.from === 'string' ? req.query.from : undefined;
const to = typeof req.query.to === 'string' ? req.query.to : undefined;
const stats = await getCampaignStatsDaily(req.params.id, from, to);
res.json({ data: stats });
} catch (err) {
handleError(err, next);
}
});
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
try {
const range = rangeSchema.parse(req.query.range ?? '7d') as StatsRange;
const stats = await getCampaignStatsSummary(req.params.id, range);
res.json(stats);
} catch (err) {
handleError(err, next);
}
});
router.get('/:id/compliance', async (req: Request, res: Response, next: NextFunction) => {
try {
const { getCampaignCompliance } = await import('../services/compliance.service');
const report = await getCampaignCompliance(req.params.id);
res.json(report);
} catch (err) {
handleError(err, next);
}
});
export default router;
export const workspaceRouter = Router();
workspaceRouter.get('/overview', async (req: Request, res: Response, next: NextFunction) => {
try {
const workspaceId =
typeof req.query.workspaceId === 'string' ? req.query.workspaceId : undefined;
const overview = await getWorkspaceOverview(workspaceId);
res.json(overview);
} catch (err) {
next(err);
}
});

View file

@ -1,233 +0,0 @@
import OpenAI from 'openai';
import { config } from '../config';
import { prisma } from '../lib/prisma';
import { GeneratedEmail } from '../models/types';
import { logger } from '../lib/logger';
import {
ContentValidationError,
validateEmailContent,
ValidationResult,
} from './content-validator.service';
const DEFAULT_MODEL = 'openrouter/free';
const MAX_VALIDATION_ATTEMPTS = 3;
export type MailboxProfile = {
senderName?: string | null;
companyName?: string | null;
};
async function getApiKey(): Promise<string> {
if (config.AI_API_KEY) {
return config.AI_API_KEY;
}
const setting = await prisma.setting.findUnique({
where: { key: 'ai_api_key' },
});
if (setting?.value) {
return setting.value;
}
const legacy = await prisma.setting.findUnique({
where: { key: 'openai_api_key' },
});
if (legacy?.value) {
return legacy.value;
}
throw new Error(
'AI API key not configured. Set AI_API_KEY in .env (get a free key at https://openrouter.ai/keys)',
);
}
async function getModel(): Promise<string> {
if (config.AI_MODEL) {
return config.AI_MODEL;
}
const setting = await prisma.setting.findUnique({
where: { key: 'ai_model' },
});
return setting?.value ?? DEFAULT_MODEL;
}
async function createClient(): Promise<OpenAI> {
const apiKey = await getApiKey();
return new OpenAI({
apiKey,
baseURL: config.AI_BASE_URL,
defaultHeaders: config.AI_BASE_URL.includes('openrouter')
? {
'HTTP-Referer': 'https://github.com/warmbox',
'X-Title': 'Warmbox Email Warmup',
}
: undefined,
});
}
function extractJson(content: string): GeneratedEmail {
const trimmed = content.trim();
try {
return parseEmailResponse(trimmed);
} catch {
const match = trimmed.match(/\{[\s\S]*\}/);
if (match) {
return parseEmailResponse(match[0]);
}
throw new Error('AI response was not valid JSON');
}
}
function parseEmailResponse(content: string): GeneratedEmail {
const parsed = JSON.parse(content) as { subject?: string; body?: string };
if (!parsed.subject || !parsed.body) {
throw new Error('AI response missing subject or body');
}
return { subject: parsed.subject.trim(), body: parsed.body.trim() };
}
function profileContext(profile?: MailboxProfile): string {
const parts: string[] = [];
if (profile?.senderName) parts.push(`Sender name: ${profile.senderName}`);
if (profile?.companyName) parts.push(`Company: ${profile.companyName}`);
if (parts.length === 0) {
return 'Do not use placeholder names or bracket templates. Use generic professional language without [Name] style placeholders.';
}
return `${parts.join('. ')}. Use these real values in the signature if needed. Never use bracket placeholders.`;
}
async function chatOnce(
client: OpenAI,
model: string,
messages: OpenAI.Chat.ChatCompletionMessageParam[],
): Promise<GeneratedEmail> {
const request = async (useJsonMode: boolean) => {
const response = await client.chat.completions.create({
model,
messages,
temperature: 0.8,
...(useJsonMode ? { response_format: { type: 'json_object' as const } } : {}),
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error('Empty AI response');
}
return extractJson(content);
};
try {
return await request(true);
} catch (err) {
logger.warn({ err, model }, 'AI request failed, retrying without json mode');
await new Promise((r) => setTimeout(r, 1000));
return request(false);
}
}
async function generateValidated(
buildMessages: (validationFeedback?: string) => OpenAI.Chat.ChatCompletionMessageParam[],
options: { isReply?: boolean },
): Promise<GeneratedEmail & { validationResult: ValidationResult }> {
const client = await createClient();
const model = await getModel();
let lastValidation: ValidationResult | null = null;
for (let attempt = 1; attempt <= MAX_VALIDATION_ATTEMPTS; attempt++) {
const feedback =
lastValidation && !lastValidation.valid
? `Previous attempt failed validation: ${[...lastValidation.errors, ...lastValidation.warnings].join('; ')}. Fix these issues.`
: undefined;
const email = await chatOnce(client, model, buildMessages(feedback));
const validation = validateEmailContent(email.subject, email.body, {
isReply: options.isReply,
failOnWarnings: attempt >= 2,
});
if (validation.valid) {
return { ...email, validationResult: validation };
}
lastValidation = validation;
logger.warn({ attempt, errors: validation.errors }, 'AI content validation failed');
}
throw new ContentValidationError(
'AI content failed validation after maximum attempts',
lastValidation ?? { valid: false, errors: ['unknown'], warnings: [] },
);
}
export async function generateInitialEmail(
industry: string,
profile?: MailboxProfile,
): Promise<GeneratedEmail> {
const result = await generateInitialEmailWithValidation(industry, profile);
const { validationResult: _, ...email } = result;
return email;
}
export async function generateInitialEmailWithValidation(
industry: string,
profile?: MailboxProfile,
): Promise<GeneratedEmail & { validationResult: ValidationResult }> {
return generateValidated(
(feedback) => [
{
role: 'system',
content: `You write realistic B2B professional emails for the ${industry} industry.
Respond with ONLY valid JSON: {"subject":"...","body":"..."}
The email should sound natural, business-appropriate, and avoid spam trigger words.
Body should be 80-150 words. Subject should be concise and realistic.
No URLs, no bracket placeholders, no mustache templates.
${profileContext(profile)}
${feedback ?? ''}`,
},
{
role: 'user',
content: 'Generate a new outbound business email.',
},
],
{ isReply: false },
);
}
export async function generateReply(
industry: string,
incomingBody: string,
threadSubject: string,
profile?: MailboxProfile,
): Promise<GeneratedEmail> {
const result = await generateValidated(
(feedback) => [
{
role: 'system',
content: `You write realistic B2B professional email replies for the ${industry} industry.
Respond with ONLY valid JSON: {"subject":"...","body":"..."}
Write a natural, short reply (40-100 words) that continues the conversation.
If subject does not start with "Re:", prefix it with "Re: ".
No URLs, no bracket placeholders.
${profileContext(profile)}
${feedback ?? ''}`,
},
{
role: 'user',
content: `Thread subject: ${threadSubject}\n\nIncoming message:\n${incomingBody}`,
},
],
{ isReply: true },
);
let { subject, body } = result;
if (!subject.toLowerCase().startsWith('re:')) {
subject = `Re: ${threadSubject.replace(/^Re:\s*/i, '')}`;
}
const { validationResult: _, ...email } = { subject, body, validationResult: result.validationResult };
return email;
}

View file

@ -1,654 +0,0 @@
import {
CampaignRecipe,
CampaignStatus,
MailboxRole,
WarmupCampaign,
} from '../generated/prisma/client';
import { DateTime } from 'luxon';
import { prisma } from '../lib/prisma';
import {
generateMaintenanceScheduleRow,
generateSchedule,
CustomDay,
RecipeParams,
validateRecipeParams,
} from './recipe.service';
import { resolveWorkspaceId } from './workspace.service';
export const MIN_SATELLITES = 2;
/** Statuses where a mailbox is still reserved as a campaign primary */
const PRIMARY_RESERVED_STATUSES: CampaignStatus[] = [
CampaignStatus.draft,
CampaignStatus.active,
CampaignStatus.paused,
CampaignStatus.maintenance,
];
export class CampaignError extends Error {
constructor(
message: string,
public readonly statusCode = 400,
) {
super(message);
this.name = 'CampaignError';
}
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function campaignRecipeParams(campaign: WarmupCampaign): RecipeParams {
return {
recipe: campaign.recipe,
durationDays: campaign.durationDays,
minEmailsPerDay: campaign.minEmailsPerDay,
maxEmailsPerDay: campaign.maxEmailsPerDay,
replyRatePercent: campaign.replyRatePercent,
};
}
export function parseCustomSchedule(json: string | null | undefined): CustomDay[] {
if (!json) return [];
try {
const parsed = JSON.parse(json) as CustomDay[];
if (!Array.isArray(parsed)) {
throw new CampaignError('customSchedule must be a JSON array');
}
return parsed;
} catch (err) {
if (err instanceof CampaignError) throw err;
throw new CampaignError('Invalid customSchedule JSON');
}
}
function serializeCustomSchedule(customSchedule?: CustomDay[]): string | undefined {
if (!customSchedule?.length) return undefined;
return JSON.stringify(customSchedule);
}
function validateCampaignRecipe(campaign: WarmupCampaign, customSchedule?: CustomDay[]) {
const schedule =
customSchedule ?? parseCustomSchedule(campaign.customSchedule);
validateRecipeParams(campaignRecipeParams(campaign), {
customSchedule: campaign.recipe === CampaignRecipe.custom ? schedule : undefined,
});
}
async function assertPrimaryAvailable(primaryMailboxId: string, excludeCampaignId?: string) {
const existing = await prisma.warmupCampaign.findFirst({
where: {
primaryMailboxId,
status: { in: PRIMARY_RESERVED_STATUSES },
...(excludeCampaignId ? { id: { not: excludeCampaignId } } : {}),
},
select: { id: true, name: true, status: true },
});
if (existing) {
throw new CampaignError(
`Primary mailbox is already used by campaign "${existing.name}" (${existing.status}). ` +
`Delete or complete that campaign first, or choose a different mailbox.`,
);
}
}
async function assertMailboxInWorkspace(mailboxId: string, workspaceId: string, role?: MailboxRole) {
const mailbox = await prisma.inbox.findUnique({ where: { id: mailboxId } });
if (!mailbox) {
throw new CampaignError('Mailbox not found', 404);
}
if (mailbox.workspaceId !== workspaceId) {
throw new CampaignError('Mailbox must belong to the same workspace as the campaign');
}
if (role && mailbox.role !== role && mailbox.role !== MailboxRole.unassigned) {
// Allow promoting unassigned to target role
if (!(role === MailboxRole.satellite && mailbox.role === MailboxRole.primary)) {
// primary can only be primary; satellite can be satellite or unassigned
}
}
return mailbox;
}
export async function createCampaign(input: {
workspaceId?: string;
primaryMailboxId: string;
name: string;
recipe?: CampaignRecipe;
durationDays?: number;
minEmailsPerDay?: number;
maxEmailsPerDay?: number;
replyRatePercent?: number;
maintenanceVolumePercent?: number;
timezone?: string;
sendWindowStart?: string;
sendWindowEnd?: string;
customSchedule?: CustomDay[];
satelliteMailboxIds?: string[];
}) {
const workspaceId = await resolveWorkspaceId(input.workspaceId);
const primary = await assertMailboxInWorkspace(input.primaryMailboxId, workspaceId);
await assertPrimaryAvailable(input.primaryMailboxId);
await prisma.inbox.update({
where: { id: primary.id },
data: { role: MailboxRole.primary },
});
const recipe = input.recipe ?? CampaignRecipe.grow;
const draftParams: RecipeParams = {
recipe,
durationDays: input.durationDays ?? 30,
minEmailsPerDay: input.minEmailsPerDay ?? 1,
maxEmailsPerDay: input.maxEmailsPerDay ?? 40,
replyRatePercent: input.replyRatePercent ?? 30,
};
validateRecipeParams(draftParams, {
customSchedule: recipe === CampaignRecipe.custom ? input.customSchedule : undefined,
});
const campaign = await prisma.warmupCampaign.create({
data: {
workspaceId,
primaryMailboxId: input.primaryMailboxId,
name: input.name,
recipe,
durationDays: draftParams.durationDays,
minEmailsPerDay: draftParams.minEmailsPerDay,
maxEmailsPerDay: draftParams.maxEmailsPerDay,
replyRatePercent: draftParams.replyRatePercent,
maintenanceVolumePercent: input.maintenanceVolumePercent ?? 20,
timezone: input.timezone ?? 'America/Vancouver',
sendWindowStart: input.sendWindowStart ?? '08:00',
sendWindowEnd: input.sendWindowEnd ?? '18:00',
customSchedule: serializeCustomSchedule(input.customSchedule),
status: CampaignStatus.draft,
},
include: {
primaryMailbox: true,
satellites: { include: { satelliteMailbox: true } },
},
});
if (input.satelliteMailboxIds?.length) {
for (const mailboxId of input.satelliteMailboxIds) {
await assignSatellite(campaign.id, mailboxId);
}
}
return getCampaignById(campaign.id);
}
export async function listCampaigns(filters: { status?: CampaignStatus; workspaceId?: string }) {
const workspaceId = filters.workspaceId
? await resolveWorkspaceId(filters.workspaceId)
: undefined;
return prisma.warmupCampaign.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(workspaceId ? { workspaceId } : {}),
},
include: {
primaryMailbox: true,
satellites: { include: { satelliteMailbox: true }, where: { active: true } },
_count: { select: { dailySchedules: true } },
},
orderBy: { createdAt: 'desc' },
});
}
export async function getCampaignById(id: string) {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id },
include: {
primaryMailbox: true,
satellites: {
include: { satelliteMailbox: true },
where: { active: true },
},
dailySchedules: { orderBy: { dayIndex: 'asc' } },
},
});
if (!campaign) {
throw new CampaignError('Campaign not found', 404);
}
const todaySchedule =
campaign.currentDay > 0
? campaign.dailySchedules.find((s) => s.dayIndex === campaign.currentDay)
: null;
return { ...campaign, todaySchedule };
}
export async function updateCampaign(
id: string,
data: Partial<{
name: string;
recipe: CampaignRecipe;
durationDays: number;
minEmailsPerDay: number;
maxEmailsPerDay: number;
replyRatePercent: number;
maintenanceVolumePercent: number;
timezone: string;
sendWindowStart: string;
sendWindowEnd: string;
customSchedule?: CustomDay[];
}>,
) {
const campaign = await prisma.warmupCampaign.findUnique({ where: { id } });
if (!campaign) throw new CampaignError('Campaign not found', 404);
const activeEditable = new Set([
'name',
'timezone',
'sendWindowStart',
'sendWindowEnd',
'replyRatePercent',
]);
if (campaign.status === CampaignStatus.draft) {
const merged = { ...campaign, ...data } as WarmupCampaign;
validateCampaignRecipe(merged, data.customSchedule);
const { customSchedule, ...rest } = data;
const updateData = {
...rest,
...(customSchedule !== undefined
? { customSchedule: serializeCustomSchedule(customSchedule) }
: {}),
};
const updated = await prisma.warmupCampaign.update({
where: { id },
data: updateData,
});
return getCampaignById(updated.id);
}
if (campaign.status === CampaignStatus.paused) {
const merged = { ...campaign, ...data } as WarmupCampaign;
validateCampaignRecipe(merged, data.customSchedule);
const { customSchedule, ...rest } = data;
const updated = await prisma.warmupCampaign.update({
where: { id },
data: {
...rest,
...(customSchedule !== undefined
? { customSchedule: serializeCustomSchedule(customSchedule) }
: {}),
},
});
if (campaign.currentDay > 0) {
await rebuildFutureSchedule(updated);
}
return getCampaignById(id);
}
if (
campaign.status === CampaignStatus.active ||
campaign.status === CampaignStatus.maintenance
) {
const keys = Object.keys(data) as Array<keyof typeof data>;
const blocked = keys.filter((key) => !activeEditable.has(key));
if (blocked.length > 0) {
throw new CampaignError(
`Running campaigns can only update: ${[...activeEditable].join(', ')}. Cannot change: ${blocked.join(', ')}`,
);
}
if (data.replyRatePercent !== undefined && data.replyRatePercent > 45) {
throw new CampaignError('Reply rate cannot exceed 45%');
}
const updated = await prisma.warmupCampaign.update({
where: { id },
data: {
...(data.name !== undefined ? { name: data.name } : {}),
...(data.timezone !== undefined ? { timezone: data.timezone } : {}),
...(data.sendWindowStart !== undefined ? { sendWindowStart: data.sendWindowStart } : {}),
...(data.sendWindowEnd !== undefined ? { sendWindowEnd: data.sendWindowEnd } : {}),
...(data.replyRatePercent !== undefined ? { replyRatePercent: data.replyRatePercent } : {}),
},
});
return getCampaignById(updated.id);
}
throw new CampaignError('Completed or failed campaigns cannot be updated');
}
export async function deleteCampaign(id: string) {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id },
include: { satellites: true },
});
if (!campaign) throw new CampaignError('Campaign not found', 404);
if (campaign.status !== CampaignStatus.draft) {
throw new CampaignError('Only draft campaigns can be deleted');
}
const primaryId = campaign.primaryMailboxId;
const satelliteIds = campaign.satellites.map((s) => s.satelliteMailboxId);
await prisma.campaignSatellite.deleteMany({ where: { campaignId: id } });
await prisma.dailySchedule.deleteMany({ where: { campaignId: id } });
await prisma.warmupCampaign.delete({ where: { id } });
const otherPrimaryCampaign = await prisma.warmupCampaign.findFirst({
where: {
primaryMailboxId: primaryId,
status: { in: PRIMARY_RESERVED_STATUSES },
},
});
if (!otherPrimaryCampaign) {
await prisma.inbox.update({
where: { id: primaryId },
data: { role: MailboxRole.unassigned },
});
}
for (const satelliteId of satelliteIds) {
const remaining = await prisma.campaignSatellite.count({
where: { satelliteMailboxId: satelliteId },
});
if (remaining === 0) {
await prisma.inbox.update({
where: { id: satelliteId },
data: { role: MailboxRole.unassigned },
});
}
}
}
export async function assignSatellite(campaignId: string, mailboxId: string, weight = 1) {
const campaign = await prisma.warmupCampaign.findUnique({ where: { id: campaignId } });
if (!campaign) throw new CampaignError('Campaign not found', 404);
const mailbox = await assertMailboxInWorkspace(mailboxId, campaign.workspaceId);
if (mailbox.id === campaign.primaryMailboxId) {
throw new CampaignError('Primary mailbox cannot be assigned as a satellite');
}
await prisma.inbox.update({
where: { id: mailboxId },
data: { role: MailboxRole.satellite },
});
return prisma.campaignSatellite.upsert({
where: {
campaignId_satelliteMailboxId: { campaignId, satelliteMailboxId: mailboxId },
},
create: { campaignId, satelliteMailboxId: mailboxId, weight, active: true },
update: { weight, active: true },
include: { satelliteMailbox: true },
});
}
export async function unassignSatellite(campaignId: string, mailboxId: string) {
const link = await prisma.campaignSatellite.findUnique({
where: {
campaignId_satelliteMailboxId: { campaignId, satelliteMailboxId: mailboxId },
},
});
if (!link) throw new CampaignError('Satellite assignment not found', 404);
await prisma.campaignSatellite.delete({
where: { id: link.id },
});
const remaining = await prisma.campaignSatellite.count({
where: { satelliteMailboxId: mailboxId },
});
if (remaining === 0) {
await prisma.inbox.update({
where: { id: mailboxId },
data: { role: MailboxRole.unassigned },
});
}
}
export async function listSatellites(campaignId: string) {
return prisma.campaignSatellite.findMany({
where: { campaignId, active: true },
include: { satelliteMailbox: true },
});
}
async function rebuildFutureSchedule(campaign: WarmupCampaign): Promise<void> {
if (campaign.currentDay <= 0) return;
const customSchedule = parseCustomSchedule(campaign.customSchedule);
const rows = generateSchedule(campaignRecipeParams(campaign), {
seed: campaign.id,
customSchedule,
});
const startedAt = campaign.startedAt ?? new Date();
await prisma.dailySchedule.deleteMany({
where: {
campaignId: campaign.id,
dayIndex: { gt: campaign.currentDay },
},
});
const futureRows = rows.filter((row) => row.dayIndex > campaign.currentDay);
if (futureRows.length > 0) {
await prisma.dailySchedule.createMany({
data: futureRows.map((row) => ({
campaignId: campaign.id,
dayIndex: row.dayIndex,
date: addDays(startedAt, row.dayIndex - 1),
plannedSends: row.plannedSends,
plannedReplies: row.plannedReplies,
})),
});
}
const currentRow = rows.find((row) => row.dayIndex === campaign.currentDay);
if (currentRow) {
await prisma.dailySchedule.updateMany({
where: { campaignId: campaign.id, dayIndex: campaign.currentDay },
data: {
plannedSends: currentRow.plannedSends,
plannedReplies: currentRow.plannedReplies,
},
});
}
if (campaign.startedAt) {
await prisma.warmupCampaign.update({
where: { id: campaign.id },
data: { endsAt: addDays(campaign.startedAt, campaign.durationDays) },
});
}
}
async function persistCampaignSchedule(campaign: WarmupCampaign, startedAt: Date) {
const customSchedule = parseCustomSchedule(campaign.customSchedule);
const rows = generateSchedule(campaignRecipeParams(campaign), {
seed: campaign.id,
customSchedule,
});
await prisma.dailySchedule.createMany({
data: rows.map((row) => ({
campaignId: campaign.id,
dayIndex: row.dayIndex,
date: addDays(startedAt, row.dayIndex - 1),
plannedSends: row.plannedSends,
plannedReplies: row.plannedReplies,
})),
});
}
export async function startCampaign(id: string) {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id },
include: { satellites: { where: { active: true } } },
});
if (!campaign) throw new CampaignError('Campaign not found', 404);
if (campaign.status !== CampaignStatus.draft) {
throw new CampaignError('Only draft campaigns can be started');
}
validateCampaignRecipe(campaign);
if (campaign.satellites.length < MIN_SATELLITES) {
throw new CampaignError(
`At least ${MIN_SATELLITES} satellites required to start (have ${campaign.satellites.length})`,
);
}
await assertPrimaryAvailable(campaign.primaryMailboxId, id);
const startedAt = new Date();
const endsAt = addDays(startedAt, campaign.durationDays);
await prisma.dailySchedule.deleteMany({ where: { campaignId: id } });
await persistCampaignSchedule(campaign, startedAt);
return prisma.warmupCampaign.update({
where: { id },
data: {
status: CampaignStatus.active,
startedAt,
endsAt,
currentDay: 1,
pausedFromStatus: null,
},
include: {
primaryMailbox: true,
satellites: { include: { satelliteMailbox: true }, where: { active: true } },
dailySchedules: { orderBy: { dayIndex: 'asc' } },
},
});
}
export async function pauseCampaign(id: string) {
const campaign = await prisma.warmupCampaign.findUnique({ where: { id } });
if (!campaign) throw new CampaignError('Campaign not found', 404);
if (campaign.status !== CampaignStatus.active && campaign.status !== CampaignStatus.maintenance) {
throw new CampaignError('Only active or maintenance campaigns can be paused');
}
await prisma.warmupCampaign.update({
where: { id },
data: {
status: CampaignStatus.paused,
pausedFromStatus: campaign.status,
},
});
return getCampaignById(id);
}
export async function resumeCampaign(id: string) {
const campaign = await prisma.warmupCampaign.findUnique({ where: { id } });
if (!campaign) throw new CampaignError('Campaign not found', 404);
if (campaign.status !== CampaignStatus.paused) {
throw new CampaignError('Only paused campaigns can be resumed');
}
const resumeTo = campaign.pausedFromStatus ?? CampaignStatus.active;
await prisma.warmupCampaign.update({
where: { id },
data: {
status: resumeTo,
pausedFromStatus: null,
},
});
return getCampaignById(id);
}
export async function transitionToMaintenance(campaignId: string) {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id: campaignId },
include: { dailySchedules: { orderBy: { dayIndex: 'desc' }, take: 1 } },
});
if (!campaign) throw new CampaignError('Campaign not found', 404);
if (campaign.status !== CampaignStatus.active) {
throw new CampaignError('Only active campaigns can transition to maintenance');
}
const nextDayIndex = (campaign.dailySchedules[0]?.dayIndex ?? campaign.durationDays) + 1;
const maintenanceRow = generateMaintenanceScheduleRow(
nextDayIndex,
campaign.maxEmailsPerDay,
campaign.replyRatePercent,
campaign.maintenanceVolumePercent,
);
const startedAt = campaign.startedAt ?? new Date();
await prisma.dailySchedule.create({
data: {
campaignId,
dayIndex: maintenanceRow.dayIndex,
date: addDays(startedAt, maintenanceRow.dayIndex - 1),
plannedSends: maintenanceRow.plannedSends,
plannedReplies: maintenanceRow.plannedReplies,
},
});
return prisma.warmupCampaign.update({
where: { id: campaignId },
data: {
status: CampaignStatus.maintenance,
currentDay: nextDayIndex,
},
include: {
dailySchedules: { orderBy: { dayIndex: 'desc' }, take: 1 },
},
});
}
export async function hasAnyCampaign(): Promise<boolean> {
const count = await prisma.warmupCampaign.count();
return count > 0;
}
export async function syncCampaignDay(campaignId: string): Promise<void> {
const campaign = await prisma.warmupCampaign.findUnique({ where: { id: campaignId } });
if (!campaign || campaign.currentDay === 0) return;
if (campaign.status !== CampaignStatus.active && campaign.status !== CampaignStatus.maintenance) {
return;
}
const schedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: { campaignId, dayIndex: campaign.currentDay },
},
});
if (!schedule?.date) return;
const today = DateTime.now().setZone(campaign.timezone).startOf('day');
const scheduleDay = DateTime.fromJSDate(schedule.date).setZone(campaign.timezone).startOf('day');
if (today <= scheduleDay) return;
if (campaign.currentDay >= campaign.durationDays && campaign.status === CampaignStatus.active) {
await transitionToMaintenance(campaignId);
return;
}
const nextDay = campaign.currentDay + 1;
const nextSchedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: { campaignId, dayIndex: nextDay },
},
});
if (nextSchedule) {
await prisma.warmupCampaign.update({
where: { id: campaignId },
data: { currentDay: nextDay },
});
}
}

View file

@ -1,169 +0,0 @@
import { CampaignRecipe, MailboxProvider } from '../lib/prisma';
import { prisma } from '../lib/prisma';
import { checkDomainAge } from '../lib/domain-age';
import { MIN_SATELLITES, CampaignError } from './campaign.service';
import { checkMailboxDns } from '../lib/dns-check';
import { checkDomainInPostmaster } from './postmaster.service';
export type ComplianceStatus = 'pass' | 'warn' | 'fail' | 'na';
export type ComplianceCheckItem = {
id: string;
label: string;
status: ComplianceStatus;
message: string;
actionPath?: string;
actionLabel?: string;
};
export type ComplianceReport = {
campaignId: string;
overall: ComplianceStatus;
checks: ComplianceCheckItem[];
};
function worstStatus(statuses: ComplianceStatus[]): ComplianceStatus {
if (statuses.includes('fail')) return 'fail';
if (statuses.includes('warn')) return 'warn';
return 'pass';
}
function extractDomain(email: string): string {
return email.split('@')[1]!.toLowerCase();
}
export async function getCampaignCompliance(campaignId: string): Promise<ComplianceReport> {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id: campaignId },
include: {
primaryMailbox: true,
satellites: { where: { active: true } },
},
});
if (!campaign) {
throw new CampaignError('Campaign not found', 404);
}
const checks: ComplianceCheckItem[] = [];
checks.push({
id: 'reply_rate_cap',
label: 'Reply rate ≤ 45%',
status: campaign.replyRatePercent <= 45 ? 'pass' : 'fail',
message: `Configured at ${campaign.replyRatePercent}%`,
});
const satelliteCount = campaign.satellites.length;
checks.push({
id: 'satellite_pool',
label: 'Satellite pool size',
status:
satelliteCount >= 4 ? 'pass' : satelliteCount >= MIN_SATELLITES ? 'warn' : 'fail',
message:
satelliteCount >= 4
? `${satelliteCount} satellites assigned`
: `${satelliteCount} satellites (recommend ≥4, minimum ${MIN_SATELLITES})`,
});
if (campaign.recipe === CampaignRecipe.grow) {
checks.push({
id: 'grow_duration',
label: 'Grow recipe duration',
status: campaign.durationDays >= 30 ? 'pass' : 'warn',
message:
campaign.durationDays >= 30
? `${campaign.durationDays} days (recommended ≥30 for production)`
: `${campaign.durationDays} days — shorter ramps are fine for testing; use ≥30 days for production`,
});
}
checks.push({
id: 'send_window',
label: 'Send window configured',
status:
campaign.sendWindowStart && campaign.sendWindowEnd ? 'pass' : 'warn',
message: `${campaign.sendWindowStart}${campaign.sendWindowEnd} (${campaign.timezone})`,
});
checks.push({
id: 'ai_validation',
label: 'AI content validation enabled',
status: 'pass',
message: 'Content validator active on all AI-generated emails',
});
try {
const dns = await checkMailboxDns(campaign.primaryMailbox.email);
const dnsStatus = worstStatus([dns.spf, dns.dkim, dns.dmarc]);
checks.push({
id: 'dns_auth',
label: 'SPF / DKIM / DMARC',
status: dnsStatus,
message: dns.details.join('; '),
});
} catch (err) {
checks.push({
id: 'dns_auth',
label: 'SPF / DKIM / DMARC',
status: 'warn',
message: err instanceof Error ? err.message : 'DNS lookup failed',
});
}
const domain = extractDomain(campaign.primaryMailbox.email);
try {
const age = await checkDomainAge(domain);
if (age.ageDays === null) {
checks.push({
id: 'domain_age',
label: 'Domain age ≥ 14 days',
status: 'warn',
message: `Could not determine registration date for ${domain} via RDAP`,
});
} else {
checks.push({
id: 'domain_age',
label: 'Domain age ≥ 14 days',
status: age.ageDays >= 14 ? 'pass' : 'warn',
message: `Registered ${age.ageDays} day(s) ago (${age.registeredAt?.slice(0, 10)})`,
});
}
} catch (err) {
checks.push({
id: 'domain_age',
label: 'Domain age ≥ 14 days',
status: 'warn',
message: err instanceof Error ? err.message : 'Domain age lookup failed',
});
}
const isGooglePrimary =
campaign.primaryMailbox.provider === MailboxProvider.google_workspace ||
campaign.primaryMailbox.provider === MailboxProvider.gmail;
if (isGooglePrimary) {
const postmaster = await checkDomainInPostmaster(domain);
checks.push({
id: 'postmaster_tools',
label: 'Google Postmaster Tools',
status: postmaster.status,
message: postmaster.message,
actionPath: postmaster.actionPath,
actionLabel: postmaster.connected ? 'Review in Settings' : 'Connect in Settings',
});
} else {
checks.push({
id: 'postmaster_tools',
label: 'Google Postmaster Tools',
status: 'na',
message: 'Not required for non-Google primary mailboxes',
});
}
return {
campaignId,
overall: worstStatus(checks.map((c) => c.status).filter((s) => s !== 'na')),
checks,
};
}

View file

@ -1,37 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateEmailContent } from '../services/content-validator.service';
const VALID_BODY =
'Hi there, I wanted to follow up on our conversation about streamlining operations for your team. ' +
'We have been reviewing options that could reduce manual coordination and improve response times across departments. ' +
'Would you be open to a short call next week to discuss priorities and next steps?';
describe('content-validator.service', () => {
it('accepts realistic B2B content', () => {
const result = validateEmailContent('Following up on our discussion', VALID_BODY);
assert.equal(result.valid, true);
assert.equal(result.errors.length, 0);
});
it('rejects bracket placeholders', () => {
const result = validateEmailContent('Hello', 'Dear [Director name], please review this.');
assert.equal(result.valid, false);
assert.ok(result.errors.some((e) => e.includes('placeholder')));
});
it('rejects mustache placeholders', () => {
const result = validateEmailContent('Hello', 'Dear {{company}}, thanks for your time.');
assert.equal(result.valid, false);
});
it('rejects tier-1 spam phrases', () => {
const result = validateEmailContent('Act now', VALID_BODY);
assert.equal(result.valid, false);
});
it('rejects URLs in body', () => {
const result = validateEmailContent('Hello', 'Please see https://example.com for details.');
assert.equal(result.valid, false);
});
});

View file

@ -1,108 +0,0 @@
export type ValidationResult = {
valid: boolean;
errors: string[];
warnings: string[];
};
export class ContentValidationError extends Error {
constructor(
message: string,
public readonly validation: ValidationResult,
) {
super(message);
this.name = 'ContentValidationError';
}
}
const PLACEHOLDER_PATTERNS = [
{ pattern: /\[.*?\]/i, message: 'Bracket placeholder detected' },
{ pattern: /\{\{.*?\}\}/, message: 'Mustache placeholder detected' },
{ pattern: /\[Your .*?\]/i, message: 'Template placeholder detected' },
{ pattern: /\[Director .*?\]/i, message: 'Template placeholder detected' },
{ pattern: /\[Company .*?\]/i, message: 'Template placeholder detected' },
{ pattern: /INSERT .* HERE/i, message: 'Template placeholder detected' },
{ pattern: /\bTODO\b/, message: 'TODO placeholder detected' },
{ pattern: /lorem ipsum/i, message: 'Lorem ipsum placeholder detected' },
];
const TIER1_SPAM = [
'act now',
'100% free',
'risk-free',
'free money',
'click here',
'limited time',
'winner',
'viagra',
'casino',
'lottery',
'guarantee',
];
const TIER2_SPAM = [
'urgent',
'limited',
'offer',
'discount',
'sale',
'promotion',
'buy now',
];
const URL_PATTERN = /https?:\/\//i;
function wordCount(text: string): number {
return text.trim().split(/\s+/).filter(Boolean).length;
}
export type ValidateOptions = {
isReply?: boolean;
failOnWarnings?: boolean;
};
export function validateEmailContent(
subject: string,
body: string,
options: ValidateOptions = {},
): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
const combined = `${subject}\n${body}`;
for (const { pattern, message } of PLACEHOLDER_PATTERNS) {
if (pattern.test(combined)) {
errors.push(message);
}
}
if (URL_PATTERN.test(combined)) {
errors.push('URLs are not allowed in warmup content');
}
if (subject.length > 60) {
errors.push('Subject exceeds 60 characters');
}
const minWords = options.isReply ? 20 : 40;
const maxWords = options.isReply ? 120 : 200;
const words = wordCount(body);
if (words < minWords || words > maxWords) {
errors.push(`Body word count ${words} outside allowed range ${minWords}-${maxWords}`);
}
const lower = combined.toLowerCase();
for (const phrase of TIER1_SPAM) {
if (lower.includes(phrase)) {
errors.push(`Spam trigger phrase: "${phrase}"`);
}
}
for (const phrase of TIER2_SPAM) {
if (lower.includes(phrase)) {
warnings.push(`Soft spam phrase: "${phrase}"`);
}
}
const valid = errors.length === 0 && (!options.failOnWarnings || warnings.length === 0);
return { valid, errors, warnings };
}

View file

@ -1,121 +0,0 @@
import nodemailer, { Transporter } from 'nodemailer';
import type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma';
import { withRetry } from '../lib/retry';
import { decrypt } from './crypto.service';
import { EmailPayload } from '../models/types';
import { resolveMailboxAccessToken } from './mailbox-auth.service';
import { formatMicrosoftAuthError } from './microsoft-oauth.service';
function getPassword(inbox: Inbox): string {
return decrypt(inbox.password);
}
export async function createSmtpTransport(inbox: Inbox): Promise<Transporter> {
const secure = inbox.smtpPort === 465;
if (inbox.authType === MailboxAuthType.microsoft_oauth) {
const accessToken = await resolveMailboxAccessToken(inbox);
const { clientId, clientSecret } = await import('./microsoft-oauth.service').then((m) =>
m.getMicrosoftOAuthSettings(),
);
if (!clientId || !clientSecret || !inbox.oauthRefreshToken) {
throw new Error('Microsoft OAuth is not configured for this mailbox');
}
return nodemailer.createTransport({
host: inbox.smtpHost,
port: inbox.smtpPort,
secure,
requireTLS: !secure && inbox.smtpPort === 587,
auth: {
type: 'OAuth2',
user: inbox.username,
clientId,
clientSecret,
refreshToken: decrypt(inbox.oauthRefreshToken),
accessUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
accessToken,
},
connectionTimeout: 30_000,
greetingTimeout: 30_000,
} as nodemailer.TransportOptions);
}
return nodemailer.createTransport({
host: inbox.smtpHost,
port: inbox.smtpPort,
secure,
requireTLS: !secure && inbox.smtpPort === 587,
auth: {
user: inbox.username,
pass: getPassword(inbox),
},
connectionTimeout: 30_000,
greetingTimeout: 30_000,
});
}
export async function testSmtp(
inbox: Inbox,
): Promise<{ ok: boolean; error?: string }> {
try {
await withRetry(
async () => {
const transporter = await createSmtpTransport(inbox);
try {
await transporter.verify();
} finally {
transporter.close();
}
},
{ label: `smtp-test:${inbox.email}` },
);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'SMTP verification failed';
return {
ok: false,
error: formatMicrosoftAuthError(message),
};
}
}
export async function sendMail(
inbox: Inbox,
payload: EmailPayload,
): Promise<{ messageId: string }> {
return withRetry(
async () => {
const transporter = await createSmtpTransport(inbox);
try {
const info = await transporter.sendMail({
from: inbox.email,
to: payload.to,
subject: payload.subject,
text: payload.body,
inReplyTo: payload.inReplyTo,
references: payload.references,
});
const messageId = info.messageId ?? `<${Date.now()}@${inbox.email.split('@')[1]}>`;
return { messageId };
} finally {
transporter.close();
}
},
{ label: `smtp-send:${inbox.email}` },
);
}
export async function testConnection(
inbox: Inbox,
): Promise<{
smtp: { ok: boolean; error?: string };
imap: { ok: boolean; error?: string };
}> {
const { testImap } = await import('./imap.service');
const [smtp, imap] = await Promise.all([testSmtp(inbox), testImap(inbox)]);
return { smtp, imap };
}

View file

@ -1,101 +0,0 @@
import { ImapFlow } from 'imapflow';
import type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma';
import { withRetry } from '../lib/retry';
import { decrypt } from './crypto.service';
import { resolveMailboxAccessToken } from './mailbox-auth.service';
import { formatMicrosoftAuthError } from './microsoft-oauth.service';
const CONNECTION_TIMEOUT = 30_000;
function getPassword(inbox: Inbox): string {
return decrypt(inbox.password);
}
export async function createImapClient(inbox: Inbox): Promise<ImapFlow> {
const secure = inbox.imapPort === 993;
if (inbox.authType === MailboxAuthType.microsoft_oauth) {
const accessToken = await resolveMailboxAccessToken(inbox);
if (!accessToken) {
throw new Error('Microsoft OAuth access token could not be resolved');
}
return new ImapFlow({
host: inbox.imapHost,
port: inbox.imapPort,
secure,
auth: {
user: inbox.username,
accessToken,
},
logger: false,
connectionTimeout: CONNECTION_TIMEOUT,
greetingTimeout: CONNECTION_TIMEOUT,
});
}
return new ImapFlow({
host: inbox.imapHost,
port: inbox.imapPort,
secure,
auth: {
user: inbox.username,
pass: getPassword(inbox),
},
logger: false,
connectionTimeout: CONNECTION_TIMEOUT,
greetingTimeout: CONNECTION_TIMEOUT,
});
}
export async function testImap(
inbox: Inbox,
): Promise<{ ok: boolean; error?: string }> {
try {
await withRetry(
async () => {
const client = await createImapClient(inbox);
try {
await client.connect();
await client.mailboxOpen('INBOX');
} finally {
try {
await client.logout();
} catch {
// ignore logout errors
}
}
},
{ label: `imap-test:${inbox.email}` },
);
return { ok: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'IMAP connection failed';
return {
ok: false,
error: formatMicrosoftAuthError(message),
};
}
}
export async function withImapClient<T>(
inbox: Inbox,
fn: (client: ImapFlow) => Promise<T>,
): Promise<T> {
return withRetry(
async () => {
const client = await createImapClient(inbox);
await client.connect();
try {
return await fn(client);
} finally {
try {
await client.logout();
} catch {
// ignore logout errors
}
}
},
{ label: `imap:${inbox.email}` },
);
}

View file

@ -1,134 +0,0 @@
import { SendJobStatus, prisma } from '../lib/prisma';
import { logger } from '../lib/logger';
export async function logJobRun(
jobName: string,
success: boolean,
message?: string,
): Promise<void> {
await prisma.jobRunLog.create({
data: { jobName, success, message },
});
}
export async function getJobStatuses(): Promise<
Array<{ jobName: string; lastRunAt: string | null; success: boolean | null; message: string | null }>
> {
const jobNames = [
'campaign-dispatcher',
'rescuer',
'campaign-rollover',
'stats-rollup',
];
const results = [];
for (const jobName of jobNames) {
const last = await prisma.jobRunLog.findFirst({
where: { jobName },
orderBy: { ranAt: 'desc' },
});
results.push({
jobName,
lastRunAt: last?.ranAt.toISOString() ?? null,
success: last?.success ?? null,
message: last?.message ?? null,
});
}
const pendingSends = await prisma.sendJob.count({
where: { status: SendJobStatus.pending },
});
return results.map((row) =>
row.jobName === 'campaign-dispatcher'
? { ...row, message: row.message ?? `${pendingSends} queued sends` }
: row,
);
}
export async function enqueueSendJob(
campaignId: string,
scheduledAt: Date = new Date(),
): Promise<string | null> {
const dayKey = scheduledAt.toISOString().slice(0, 10);
const idempotencyKey = `${campaignId}:${dayKey}:${scheduledAt.getTime()}`;
const existing = await prisma.sendJob.findUnique({
where: { idempotencyKey },
});
if (existing) {
return existing.id;
}
const job = await prisma.sendJob.create({
data: {
campaignId,
scheduledAt,
idempotencyKey,
status: SendJobStatus.pending,
},
});
logger.debug({ campaignId, jobId: job.id }, 'Send job enqueued');
return job.id;
}
const STALE_SEND_JOB_MS = 60 * 60 * 1000;
export async function reclaimStaleSendJobs(staleMs = STALE_SEND_JOB_MS): Promise<number> {
const cutoff = new Date(Date.now() - staleMs);
const result = await prisma.sendJob.updateMany({
where: {
status: SendJobStatus.processing,
updatedAt: { lt: cutoff },
},
data: {
status: SendJobStatus.failed,
errorMessage: 'Stale processing job reclaimed',
processedAt: new Date(),
},
});
if (result.count > 0) {
logger.warn({ count: result.count }, 'Reclaimed stale send jobs stuck in processing');
}
return result.count;
}
export async function claimDueSendJob(campaignId: string) {
const now = new Date();
const job = await prisma.sendJob.findFirst({
where: {
campaignId,
status: SendJobStatus.pending,
scheduledAt: { lte: now },
},
orderBy: { scheduledAt: 'asc' },
});
if (!job) return null;
const updated = await prisma.sendJob.updateMany({
where: { id: job.id, status: SendJobStatus.pending },
data: { status: SendJobStatus.processing },
});
if (updated.count === 0) return null;
return job;
}
export async function completeSendJob(
jobId: string,
success: boolean,
errorMessage?: string,
): Promise<void> {
await prisma.sendJob.update({
where: { id: jobId },
data: {
status: success ? SendJobStatus.completed : SendJobStatus.failed,
processedAt: new Date(),
errorMessage,
},
});
}

View file

@ -1,14 +0,0 @@
import type { Inbox } from '../lib/prisma';
import { MailboxAuthType } from '../lib/prisma';
import { decrypt } from './crypto.service';
import { getMicrosoftAccessToken } from './microsoft-oauth.service';
export async function resolveMailboxAccessToken(inbox: Inbox): Promise<string | null> {
if (inbox.authType !== MailboxAuthType.microsoft_oauth) {
return null;
}
if (!inbox.oauthRefreshToken) {
throw new Error('Microsoft OAuth refresh token is missing for this mailbox');
}
return getMicrosoftAccessToken(decrypt(inbox.oauthRefreshToken));
}

View file

@ -1,121 +0,0 @@
import { config } from '../config';
import { getSetting } from './settings.service';
const MICROSOFT_SCOPES = [
'https://outlook.office.com/IMAP.AccessAsUser.All',
'https://outlook.office.com/SMTP.Send',
'offline_access',
'openid',
].join(' ');
const TOKEN_URL = 'https://login.microsoftonline.com/common/oauth2/v2.0/token';
export function isMicrosoftConsumerEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase() ?? '';
return ['live.com', 'outlook.com', 'hotmail.com', 'msn.com'].includes(domain);
}
function getRedirectUri(): string {
const base = config.PUBLIC_BASE_URL ?? `http://localhost:${config.PORT}`;
return `${base.replace(/\/$/, '')}/api/integrations/microsoft/callback`;
}
export async function getMicrosoftOAuthSettings(): Promise<{
clientId: string | null;
clientSecret: string | null;
}> {
const [clientId, clientSecret] = await Promise.all([
getSetting('microsoft_oauth_client_id'),
getSetting('microsoft_oauth_client_secret'),
]);
return { clientId, clientSecret };
}
export function buildMicrosoftAuthUrl(clientId: string, state?: string): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: MICROSOFT_SCOPES,
response_mode: 'query',
...(state ? { state } : {}),
});
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${params.toString()}`;
}
export async function exchangeMicrosoftAuthCode(code: string): Promise<{
refreshToken: string;
accessToken: string;
}> {
const { clientId, clientSecret } = await getMicrosoftOAuthSettings();
if (!clientId || !clientSecret) {
throw new Error('Microsoft OAuth client ID and secret must be configured first');
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
code,
redirect_uri: getRedirectUri(),
grant_type: 'authorization_code',
scope: MICROSOFT_SCOPES,
}),
});
const data = (await response.json()) as {
refresh_token?: string;
access_token?: string;
error_description?: string;
error?: string;
};
if (!response.ok || !data.refresh_token || !data.access_token) {
throw new Error(data.error_description ?? data.error ?? 'Microsoft token exchange failed');
}
return { refreshToken: data.refresh_token, accessToken: data.access_token };
}
export async function getMicrosoftAccessToken(refreshToken: string): Promise<string> {
const { clientId, clientSecret } = await getMicrosoftOAuthSettings();
if (!clientId || !clientSecret) {
throw new Error('Microsoft OAuth is not configured');
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: MICROSOFT_SCOPES,
}),
});
const data = (await response.json()) as {
access_token?: string;
error_description?: string;
error?: string;
};
if (!response.ok || !data.access_token) {
throw new Error(data.error_description ?? data.error ?? 'Microsoft token refresh failed');
}
return data.access_token;
}
export function formatMicrosoftAuthError(error: string): string {
if (error.includes('basic authentication is disabled') || error.includes('5.7.139')) {
return (
'Microsoft disabled basic password login for this account. Use "Connect Microsoft" in the mailbox ' +
'form instead of an app password. Register OAuth credentials in Settings → Microsoft Outlook.'
);
}
return error;
}

View file

@ -1,212 +0,0 @@
import { config } from '../config';
import { getSetting } from './settings.service';
const POSTMASTER_SCOPE = 'https://www.googleapis.com/auth/postmaster.readonly';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const DOMAINS_URL = 'https://gmailpostmastertools.googleapis.com/v1/domains';
export type PostmasterStatus = {
configured: boolean;
connected: boolean;
domains: string[];
message: string;
};
export type PostmasterDomainCheck = {
applicable: boolean;
connected: boolean;
listed: boolean;
status: 'pass' | 'warn' | 'fail' | 'na';
message: string;
actionPath?: string;
};
function getRedirectUri(): string {
const base = config.PUBLIC_BASE_URL ?? `http://localhost:${config.PORT}`;
return `${base.replace(/\/$/, '')}/api/integrations/google/callback`;
}
export async function getGoogleOAuthSettings(): Promise<{
clientId: string | null;
clientSecret: string | null;
refreshToken: string | null;
}> {
const [clientId, clientSecret, refreshToken] = await Promise.all([
getSetting('google_oauth_client_id'),
getSetting('google_oauth_client_secret'),
getSetting('google_postmaster_refresh_token'),
]);
return { clientId, clientSecret, refreshToken };
}
export function buildGoogleAuthUrl(clientId: string): string {
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: getRedirectUri(),
response_type: 'code',
scope: POSTMASTER_SCOPE,
access_type: 'offline',
prompt: 'consent',
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}
export async function exchangeGoogleAuthCode(code: string): Promise<string> {
const { clientId, clientSecret } = await getGoogleOAuthSettings();
if (!clientId || !clientSecret) {
throw new Error('Google OAuth client ID and secret must be configured first');
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: getRedirectUri(),
grant_type: 'authorization_code',
}),
});
const data = (await response.json()) as {
refresh_token?: string;
error?: string;
error_description?: string;
};
if (!response.ok || !data.refresh_token) {
throw new Error(data.error_description ?? data.error ?? 'Failed to exchange Google auth code');
}
return data.refresh_token;
}
async function getAccessToken(): Promise<string | null> {
const { clientId, clientSecret, refreshToken } = await getGoogleOAuthSettings();
if (!clientId || !clientSecret || !refreshToken) {
return null;
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
const data = (await response.json()) as { access_token?: string; error?: string };
if (!response.ok || !data.access_token) {
return null;
}
return data.access_token;
}
export async function getPostmasterStatus(): Promise<PostmasterStatus> {
const { clientId, clientSecret, refreshToken } = await getGoogleOAuthSettings();
const configured = Boolean(clientId && clientSecret);
if (!configured) {
return {
configured: false,
connected: false,
domains: [],
message: 'Add Google OAuth client ID and secret in Settings, then connect Postmaster Tools.',
};
}
if (!refreshToken) {
return {
configured: true,
connected: false,
domains: [],
message: 'OAuth credentials saved. Click Connect Google Postmaster to authorize.',
};
}
const accessToken = await getAccessToken();
if (!accessToken) {
return {
configured: true,
connected: false,
domains: [],
message: 'Stored refresh token is invalid. Reconnect Google Postmaster.',
};
}
const response = await fetch(DOMAINS_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
return {
configured: true,
connected: false,
domains: [],
message: 'Google Postmaster API call failed. Verify API is enabled and reconnect.',
};
}
const data = (await response.json()) as { domains?: Array<{ name?: string }> };
const domains =
data.domains?.map((row) => row.name).filter((name): name is string => Boolean(name)) ?? [];
return {
configured: true,
connected: true,
domains,
message:
domains.length > 0
? `Connected — ${domains.length} domain(s) visible in Postmaster Tools`
: 'Connected, but no domains found yet. Verify your domain in Postmaster Tools.',
};
}
export async function checkDomainInPostmaster(domain: string): Promise<PostmasterDomainCheck> {
const status = await getPostmasterStatus();
if (!status.configured) {
return {
applicable: true,
connected: false,
listed: false,
status: 'warn',
message: status.message,
actionPath: '/settings#google-postmaster',
};
}
if (!status.connected) {
return {
applicable: true,
connected: false,
listed: false,
status: 'warn',
message: status.message,
actionPath: '/settings#google-postmaster',
};
}
const normalized = domain.toLowerCase();
const listed = status.domains.some(
(name) => name.toLowerCase() === normalized || normalized.endsWith(`.${name.toLowerCase()}`),
);
return {
applicable: true,
connected: true,
listed,
status: listed ? 'pass' : 'fail',
message: listed
? `Domain ${domain} is registered in Google Postmaster Tools`
: `Domain ${domain} not found in Postmaster Tools. Add and verify it at postmaster.google.com`,
actionPath: listed ? undefined : '/settings#google-postmaster',
};
}

View file

@ -1,144 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CampaignRecipe } from '../lib/prisma';
import {
apply48hCap,
generateCustomSchedule,
generateFlatSchedule,
generateGrowSchedule,
generateMaintenanceDailySends,
generateRandomizedSchedule,
generateSchedule,
GROW_RECIPE_TEMPLATE,
RecipeValidationError,
validateRecipeParams,
} from '../services/recipe.service';
describe('recipe.service', () => {
it('generates grow schedule starting at min and ending at or below max', () => {
const schedule = generateGrowSchedule(GROW_RECIPE_TEMPLATE);
assert.equal(schedule.length, 30);
assert.equal(schedule[0]!.plannedSends, 1);
assert.ok(schedule[29]!.plannedSends <= 40);
for (let i = 1; i < schedule.length; i++) {
assert.ok(schedule[i]!.plannedSends >= schedule[i - 1]!.plannedSends);
}
});
it('applies 48h cap on grow schedule output', () => {
const schedule = generateGrowSchedule(GROW_RECIPE_TEMPLATE).map((r) => r.plannedSends);
for (let i = 2; i < schedule.length; i++) {
const avg = (schedule[i - 1]! + schedule[i - 2]!) / 2;
assert.ok(schedule[i]! <= Math.ceil(avg * 1.2) + 0.001);
}
});
it('generates flat schedule at max volume every day', () => {
const schedule = generateFlatSchedule({
...GROW_RECIPE_TEMPLATE,
recipe: CampaignRecipe.flat,
durationDays: 30,
maxEmailsPerDay: 40,
});
assert.equal(schedule.length, 30);
for (const row of schedule) {
assert.equal(row.plannedSends, 40);
}
});
it('generates deterministic randomized schedule for same seed', () => {
const params = {
...GROW_RECIPE_TEMPLATE,
recipe: CampaignRecipe.randomized,
};
const a = generateRandomizedSchedule(params, 'campaign-abc');
const b = generateRandomizedSchedule(params, 'campaign-abc');
assert.deepEqual(
a.map((r) => r.plannedSends),
b.map((r) => r.plannedSends),
);
for (const row of a) {
assert.ok(row.plannedSends >= 1);
assert.ok(row.plannedSends <= 40);
}
});
it('generates custom schedule with hold-last for missing days', () => {
const params = {
...GROW_RECIPE_TEMPLATE,
recipe: CampaignRecipe.custom,
durationDays: 14,
minEmailsPerDay: 1,
maxEmailsPerDay: 10,
};
const schedule = generateCustomSchedule(params, [
{ day: 1, sends: 2 },
{ day: 2, sends: 5 },
]);
assert.equal(schedule.length, 14);
assert.equal(schedule[0]!.plannedSends, 2);
assert.equal(schedule[1]!.plannedSends, 5);
assert.equal(schedule[2]!.plannedSends, 5);
assert.equal(schedule[13]!.plannedSends, 5);
});
it('routes generateSchedule to each recipe type', () => {
const grow = generateSchedule({ ...GROW_RECIPE_TEMPLATE, recipe: CampaignRecipe.grow });
const flat = generateSchedule({ ...GROW_RECIPE_TEMPLATE, recipe: CampaignRecipe.flat });
const randomized = generateSchedule(
{ ...GROW_RECIPE_TEMPLATE, recipe: CampaignRecipe.randomized },
{ seed: 'test-seed' },
);
const custom = generateSchedule(
{ ...GROW_RECIPE_TEMPLATE, recipe: CampaignRecipe.custom, durationDays: 14 },
{ customSchedule: [{ day: 1, sends: 3 }] },
);
assert.equal(grow.length, 30);
assert.equal(flat.length, 30);
assert.equal(randomized.length, 30);
assert.equal(custom.length, 14);
});
it('rejects invalid recipe params', () => {
assert.throws(
() =>
validateRecipeParams({
...GROW_RECIPE_TEMPLATE,
maxEmailsPerDay: 51,
}),
RecipeValidationError,
);
assert.throws(
() =>
validateRecipeParams({
...GROW_RECIPE_TEMPLATE,
durationDays: 10,
}),
RecipeValidationError,
);
assert.throws(
() =>
validateRecipeParams({
...GROW_RECIPE_TEMPLATE,
replyRatePercent: 50,
}),
RecipeValidationError,
);
assert.throws(
() =>
validateRecipeParams({
...GROW_RECIPE_TEMPLATE,
recipe: CampaignRecipe.custom,
}),
RecipeValidationError,
);
});
it('computes maintenance volume at 20% of max', () => {
assert.equal(generateMaintenanceDailySends(40, 20), 8);
assert.equal(generateMaintenanceDailySends(50, 20), 10);
});
});

View file

@ -1,251 +0,0 @@
import { CampaignRecipe } from '../lib/prisma';
export type RecipeParams = {
recipe: CampaignRecipe;
durationDays: number;
minEmailsPerDay: number;
maxEmailsPerDay: number;
replyRatePercent: number;
};
export type CustomDay = {
day: number;
sends: number;
};
export type DailyScheduleRow = {
dayIndex: number;
plannedSends: number;
plannedReplies: number;
};
export class RecipeValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'RecipeValidationError';
}
}
function validateSharedBounds(params: RecipeParams): void {
if (params.durationDays < 14 || params.durationDays > 45) {
throw new RecipeValidationError('durationDays must be between 14 and 45');
}
if (params.minEmailsPerDay < 1) {
throw new RecipeValidationError('minEmailsPerDay must be at least 1');
}
if (params.maxEmailsPerDay > 50) {
throw new RecipeValidationError('maxEmailsPerDay cannot exceed 50');
}
if (params.minEmailsPerDay > params.maxEmailsPerDay) {
throw new RecipeValidationError('minEmailsPerDay cannot exceed maxEmailsPerDay');
}
if (params.replyRatePercent < 10 || params.replyRatePercent > 45) {
throw new RecipeValidationError('replyRatePercent must be between 10 and 45');
}
}
export function validateRecipeParams(
params: RecipeParams,
options?: { customSchedule?: CustomDay[] },
): void {
validateSharedBounds(params);
if (params.recipe === CampaignRecipe.custom) {
const customSchedule = options?.customSchedule;
if (!customSchedule?.length) {
throw new RecipeValidationError('customSchedule is required for custom recipe');
}
for (const entry of customSchedule) {
if (entry.day < 1 || entry.day > params.durationDays) {
throw new RecipeValidationError(
`customSchedule day ${entry.day} must be between 1 and ${params.durationDays}`,
);
}
if (entry.sends < params.minEmailsPerDay || entry.sends > params.maxEmailsPerDay) {
throw new RecipeValidationError(
`customSchedule day ${entry.day} sends must be between ${params.minEmailsPerDay} and ${params.maxEmailsPerDay}`,
);
}
}
}
}
export function apply48hCap(schedule: number[]): number[] {
const result: number[] = [];
for (let i = 0; i < schedule.length; i++) {
let value = schedule[i]!;
if (i >= 2) {
const avg48h = (result[i - 1]! + result[i - 2]!) / 2;
const cap = Math.ceil(avg48h * 1.2);
value = Math.min(value, cap);
}
if (i > 0) {
value = Math.max(result[i - 1]!, value);
}
result.push(value);
}
return result;
}
function toScheduleRows(raw: number[], replyRatePercent: number): DailyScheduleRow[] {
return raw.map((plannedSends, index) => ({
dayIndex: index + 1,
plannedSends,
plannedReplies: Math.ceil((plannedSends * replyRatePercent) / 100),
}));
}
function hashSeed(seed: string): number {
let h = 2166136261;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function createSeededRandom(seed: string): () => number {
let state = hashSeed(seed) || 1;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), state | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function generateGrowSchedule(params: RecipeParams): DailyScheduleRow[] {
validateRecipeParams(params);
const { durationDays, minEmailsPerDay, maxEmailsPerDay, replyRatePercent } = params;
const totalGrowthDays = Math.max(1, Math.floor(durationDays * 0.7));
const dailyIncrement = (maxEmailsPerDay - minEmailsPerDay) / totalGrowthDays;
const raw: number[] = [];
for (let day = 1; day <= durationDays; day++) {
const growthDay = Math.min(day, totalGrowthDays);
const ramped = minEmailsPerDay + dailyIncrement * (growthDay - 1);
const target = Math.min(
maxEmailsPerDay,
Math.max(minEmailsPerDay, Math.floor(ramped)),
);
raw.push(target);
}
return toScheduleRows(apply48hCap(raw), replyRatePercent);
}
export function generateFlatSchedule(params: RecipeParams): DailyScheduleRow[] {
validateRecipeParams(params);
const { durationDays, maxEmailsPerDay, replyRatePercent } = params;
const raw = Array.from({ length: durationDays }, () => maxEmailsPerDay);
return toScheduleRows(raw, replyRatePercent);
}
export function generateRandomizedSchedule(
params: RecipeParams,
seed: string,
): DailyScheduleRow[] {
validateRecipeParams(params);
const { durationDays, minEmailsPerDay, maxEmailsPerDay, replyRatePercent } = params;
const rand = createSeededRandom(seed);
const raw = Array.from({ length: durationDays }, () => {
const range = maxEmailsPerDay - minEmailsPerDay + 1;
return Math.floor(rand() * range) + minEmailsPerDay;
});
return toScheduleRows(apply48hCap(raw), replyRatePercent);
}
export function generateCustomSchedule(
params: RecipeParams,
customDays: CustomDay[],
): DailyScheduleRow[] {
validateRecipeParams(params, { customSchedule: customDays });
const dayMap = new Map(customDays.map((d) => [d.day, d.sends]));
let lastValue = params.minEmailsPerDay;
const raw: number[] = [];
for (let day = 1; day <= params.durationDays; day++) {
if (dayMap.has(day)) {
lastValue = dayMap.get(day)!;
}
raw.push(lastValue);
}
return toScheduleRows(raw, params.replyRatePercent);
}
export function generateSchedule(
params: RecipeParams,
options?: { seed?: string; customSchedule?: CustomDay[] },
): DailyScheduleRow[] {
switch (params.recipe) {
case CampaignRecipe.grow:
return generateGrowSchedule(params);
case CampaignRecipe.flat:
return generateFlatSchedule(params);
case CampaignRecipe.randomized:
return generateRandomizedSchedule(params, options?.seed ?? 'default');
case CampaignRecipe.custom:
return generateCustomSchedule(params, options?.customSchedule ?? []);
default:
throw new RecipeValidationError(`Unknown recipe: ${params.recipe}`);
}
}
export function generateMaintenanceDailySends(
maxEmailsPerDay: number,
maintenanceVolumePercent = 20,
): number {
return Math.max(1, Math.ceil((maxEmailsPerDay * maintenanceVolumePercent) / 100));
}
export function generateMaintenanceScheduleRow(
dayIndex: number,
maxEmailsPerDay: number,
replyRatePercent: number,
maintenanceVolumePercent = 20,
): DailyScheduleRow {
const plannedSends = generateMaintenanceDailySends(maxEmailsPerDay, maintenanceVolumePercent);
return {
dayIndex,
plannedSends,
plannedReplies: Math.ceil((plannedSends * replyRatePercent) / 100),
};
}
export const GROW_RECIPE_TEMPLATE: RecipeParams = {
recipe: CampaignRecipe.grow,
durationDays: 30,
minEmailsPerDay: 1,
maxEmailsPerDay: 40,
replyRatePercent: 30,
};
export const FLAT_RECIPE_TEMPLATE: RecipeParams = {
recipe: CampaignRecipe.flat,
durationDays: 30,
minEmailsPerDay: 1,
maxEmailsPerDay: 40,
replyRatePercent: 30,
};
export const RANDOMIZED_RECIPE_TEMPLATE: RecipeParams = {
recipe: CampaignRecipe.randomized,
durationDays: 30,
minEmailsPerDay: 1,
maxEmailsPerDay: 40,
replyRatePercent: 30,
};
export const CUSTOM_RECIPE_TEMPLATE: RecipeParams = {
recipe: CampaignRecipe.custom,
durationDays: 30,
minEmailsPerDay: 1,
maxEmailsPerDay: 40,
replyRatePercent: 30,
};

View file

@ -1,65 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
MAX_REPLY_RATE_PERCENT,
shouldReply,
simulateReplyRateOverMessages,
} from '../services/reply-rate.service';
describe('reply-rate.service', () => {
it('returns false when daily reply quota is met', () => {
const result = shouldReply(
{ replyRatePercent: 30, plannedReplies: 6, actualReplies: 6 },
{ received: 20, replies: 5 },
0,
);
assert.equal(result, false);
});
it('returns false when actual rate already meets target', () => {
const result = shouldReply(
{ replyRatePercent: 30, plannedReplies: 10, actualReplies: 3 },
{ received: 10, replies: 3 },
0,
);
assert.equal(result, false);
});
it('clamps target at 45% even if configured higher', () => {
const atCap = shouldReply(
{ replyRatePercent: 50, plannedReplies: 100, actualReplies: 0 },
{ received: 0, replies: 0 },
0.4,
);
const belowCap = shouldReply(
{ replyRatePercent: 50, plannedReplies: 100, actualReplies: 0 },
{ received: 0, replies: 0 },
0.5,
);
assert.equal(atCap, true);
assert.equal(belowCap, false);
assert.equal(MAX_REPLY_RATE_PERCENT, 45);
});
it('biases toward target as day progresses', () => {
const belowTarget = shouldReply(
{ replyRatePercent: 30, plannedReplies: 30, actualReplies: 1 },
{ received: 10, replies: 1 },
0.2,
);
const atTarget = shouldReply(
{ replyRatePercent: 30, plannedReplies: 30, actualReplies: 3 },
{ received: 10, replies: 3 },
0.2,
);
assert.equal(belowTarget, true);
assert.equal(atTarget, false);
});
it('converges within ±5% of target over 100+ messages', () => {
const target = 30;
const { replyRate } = simulateReplyRateOverMessages(target, 120);
assert.ok(replyRate >= target - 5, `reply rate ${replyRate} below ${target - 5}%`);
assert.ok(replyRate <= target + 5, `reply rate ${replyRate} above ${target + 5}%`);
});
});

View file

@ -1,122 +0,0 @@
import type { WarmupCampaign } from '../generated/prisma/client';
import { WarmupStatus, prisma } from '../lib/prisma';
export const MAX_REPLY_RATE_PERCENT = 45;
export type TodayReplyStats = {
received: number;
replies: number;
};
export type ShouldReplyInput = {
replyRatePercent: number;
plannedReplies: number;
actualReplies: number;
};
function clampTarget(replyRatePercent: number): number {
return Math.min(replyRatePercent, MAX_REPLY_RATE_PERCENT);
}
export function shouldReply(
input: ShouldReplyInput,
statsToday: TodayReplyStats,
randomValue = Math.random(),
): boolean {
const target = clampTarget(input.replyRatePercent);
if (input.actualReplies >= input.plannedReplies) {
return false;
}
if (statsToday.received === 0) {
return randomValue < target / 100;
}
const actualRate = (statsToday.replies / statsToday.received) * 100;
if (actualRate >= target) {
return false;
}
return randomValue < target / 100;
}
export async function getTodayReplyStats(
campaignId: string,
primaryMailboxId: string,
): Promise<TodayReplyStats> {
const receivedStatuses: WarmupStatus[] = [
WarmupStatus.sent,
WarmupStatus.rescued_from_spam,
WarmupStatus.engaged_no_reply,
WarmupStatus.replied,
WarmupStatus.complete,
];
const received = await prisma.warmupLog.count({
where: {
campaignId,
receiverId: primaryMailboxId,
status: { in: receivedStatuses },
},
});
const replies = await prisma.warmupLog.count({
where: {
campaignId,
senderId: primaryMailboxId,
status: WarmupStatus.replied,
},
});
return { received, replies };
}
export function buildShouldReplyInput(
campaign: Pick<WarmupCampaign, 'replyRatePercent'>,
schedule: { plannedReplies: number; actualReplies: number },
): ShouldReplyInput {
return {
replyRatePercent: campaign.replyRatePercent,
plannedReplies: schedule.plannedReplies,
actualReplies: schedule.actualReplies,
};
}
/**
* Simulate reply sampling over N messages to verify convergence toward target.
*/
export function simulateReplyRateOverMessages(
replyRatePercent: number,
messageCount: number,
seed = 42,
): { replyRate: number; replies: number } {
let state = seed;
const random = () => {
state = (state * 1664525 + 1013904223) % 4294967296;
return state / 4294967296;
};
let replies = 0;
for (let i = 0; i < messageCount; i++) {
const stats: TodayReplyStats = { received: i + 1, replies };
if (
shouldReply(
{
replyRatePercent,
plannedReplies: Math.ceil((messageCount * replyRatePercent) / 100),
actualReplies: replies,
},
stats,
random(),
)
) {
replies++;
}
}
return {
replies,
replyRate: (replies / messageCount) * 100,
};
}

View file

@ -1,46 +0,0 @@
import type { ImapFlow } from 'imapflow';
import type { Inbox } from '../lib/prisma';
import { prisma } from '../lib/prisma';
import { SPAM_FOLDERS } from '../models/enums';
const SPAM_FOLDER_PATTERNS = [/spam/i, /junk/i, /bulk/i];
export async function resolveSpamFolder(
client: ImapFlow,
inbox: Inbox,
): Promise<string | null> {
if (inbox.spamFolderPath) {
return inbox.spamFolderPath;
}
for (const folder of SPAM_FOLDERS) {
try {
await client.mailboxOpen(folder);
await prisma.inbox.update({
where: { id: inbox.id },
data: { spamFolderPath: folder },
});
return folder;
} catch {
// try next
}
}
try {
const mailboxes = await client.list();
for (const mailbox of mailboxes) {
const path = mailbox.path;
if (SPAM_FOLDER_PATTERNS.some((pattern) => pattern.test(path))) {
await prisma.inbox.update({
where: { id: inbox.id },
data: { spamFolderPath: path },
});
return path;
}
}
} catch {
return null;
}
return null;
}

View file

@ -1,17 +0,0 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { computeInboxPlacementRate } from './stats.service';
describe('spam-threshold.service', () => {
it('treats inbox rate below 70% as high spam (30%+ spam)', () => {
const inboxRate = computeInboxPlacementRate(10, 4);
assert.equal(inboxRate, 60);
assert.ok(100 - inboxRate >= 30);
});
it('treats inbox rate above 70% as acceptable', () => {
const inboxRate = computeInboxPlacementRate(10, 2);
assert.equal(inboxRate, 80);
assert.ok(100 - inboxRate < 30);
});
});

View file

@ -1,68 +0,0 @@
import { CampaignStatus, prisma } from '../lib/prisma';
import { pauseCampaign } from './campaign.service';
import { computeInboxPlacementRate } from './stats.service';
import { logger } from '../lib/logger';
const SPAM_RATE_THRESHOLD = 30;
const MIN_DAYS_FOR_PAUSE = 3;
export async function checkCampaignSpamThresholds(): Promise<number> {
const campaigns = await prisma.warmupCampaign.findMany({
where: {
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
},
});
let paused = 0;
for (const campaign of campaigns) {
try {
const shouldPause = await shouldPauseForSpam(campaign.id, campaign.timezone);
if (shouldPause) {
await pauseCampaign(campaign.id);
paused += 1;
logger.warn({ campaignId: campaign.id }, 'Campaign auto-paused due to high spam rate');
}
} catch (err) {
logger.error({ err, campaignId: campaign.id }, 'Spam threshold check failed');
}
}
return paused;
}
export async function shouldPauseForSpam(
campaignId: string,
_timezone: string,
): Promise<boolean> {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id: campaignId },
select: { currentDay: true },
});
if (!campaign || campaign.currentDay < MIN_DAYS_FOR_PAUSE) {
return false;
}
const schedules = await prisma.dailySchedule.findMany({
where: {
campaignId,
dayIndex: {
lte: campaign.currentDay,
gte: campaign.currentDay - MIN_DAYS_FOR_PAUSE + 1,
},
actualSends: { gt: 0 },
},
orderBy: { dayIndex: 'asc' },
});
if (schedules.length < MIN_DAYS_FOR_PAUSE) {
return false;
}
const highSpamDays = schedules.filter((row) => {
const inboxRate = computeInboxPlacementRate(row.actualSends, row.spamDetected);
return 100 - inboxRate >= SPAM_RATE_THRESHOLD;
});
return highSpamDays.length >= MIN_DAYS_FOR_PAUSE;
}

View file

@ -1,102 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
computeInboxPlacementRate,
computeOnTrack,
computeOnTrackReasons,
} from '../services/stats.service';
describe('stats.service', () => {
it('computes inbox placement rate', () => {
assert.equal(computeInboxPlacementRate(10, 2), 80);
assert.equal(computeInboxPlacementRate(0, 0), 100);
});
it('marks on track when sends and replies are within tolerance', () => {
assert.equal(
computeOnTrack(
{
plannedSends: 20,
plannedReplies: 6,
actualSends: 18,
actualReplies: 5,
},
30,
),
true,
);
});
it('stays on track during early-day warmup with few sends', () => {
assert.equal(
computeOnTrack(
{
plannedSends: 10,
plannedReplies: 3,
actualSends: 1,
actualReplies: 0,
},
30,
),
true,
);
});
it('marks off track when sends fall below 85% of plan', () => {
assert.equal(
computeOnTrack(
{
plannedSends: 20,
plannedReplies: 6,
actualSends: 10,
actualReplies: 3,
},
30,
),
false,
);
});
it('marks off track when reply rate deviates more than 10%', () => {
assert.equal(
computeOnTrack(
{
plannedSends: 10,
plannedReplies: 3,
actualSends: 10,
actualReplies: 9,
},
30,
),
false,
);
});
});
describe('computeOnTrackReasons', () => {
it('returns no reasons during early warmup', () => {
const reasons = computeOnTrackReasons(
{
plannedSends: 10,
plannedReplies: 3,
actualSends: 1,
actualReplies: 0,
},
30,
);
assert.equal(reasons.length, 0);
});
it('explains send shortfall', () => {
const reasons = computeOnTrackReasons(
{
plannedSends: 10,
plannedReplies: 3,
actualSends: 3,
actualReplies: 0,
},
30,
);
assert.ok(reasons.some((r) => r.includes('Sends behind schedule')));
});
});

View file

@ -1,446 +0,0 @@
import { DateTime } from 'luxon';
import type { DailySchedule, WarmupCampaign } from '../generated/prisma/client';
import { CampaignStatus, prisma } from '../lib/prisma';
import { CampaignError } from './campaign.service';
export type StatsRange = '7d' | '30d' | 'all';
export type CampaignWarmupPlanDay = {
dayIndex: number;
date: string | null;
isToday: boolean;
isFuture: boolean;
planned: { sends: number; replies: number };
actual: {
sends: number;
replies: number;
spamDetected: number;
spamRescued: number;
};
};
export type CampaignWarmupPlan = {
campaignId: string;
currentDay: number;
durationDays: number;
days: CampaignWarmupPlanDay[];
};
export type CampaignStatsPayload = {
campaignId: string;
date: string;
dayIndex: number;
planned: { sends: number; replies: number };
actual: {
sends: number;
replies: number;
spamDetected: number;
spamRescued: number;
};
rates: { reply: number; inbox: number };
recipe: string;
onTrack: boolean;
onTrackReasons: string[];
};
const MIN_SENDS_FOR_SEND_CHECK = 2;
const MIN_SENDS_FOR_REPLY_CHECK = 5;
function scheduleToPayload(
campaign: Pick<WarmupCampaign, 'id' | 'recipe' | 'replyRatePercent'>,
schedule: DailySchedule,
dateIso: string,
): CampaignStatsPayload {
const replyRate =
schedule.actualSends > 0
? (schedule.actualReplies / schedule.actualSends) * 100
: 0;
const inboxRate = computeInboxPlacementRate(
schedule.actualSends,
schedule.spamDetected,
);
return {
campaignId: campaign.id,
date: dateIso,
dayIndex: schedule.dayIndex,
planned: {
sends: schedule.plannedSends,
replies: schedule.plannedReplies,
},
actual: {
sends: schedule.actualSends,
replies: schedule.actualReplies,
spamDetected: schedule.spamDetected,
spamRescued: schedule.spamRescued,
},
rates: {
reply: Math.round(replyRate * 10) / 10,
inbox: Math.round(inboxRate * 10) / 10,
},
recipe: campaign.recipe,
onTrack: computeOnTrack(schedule, campaign.replyRatePercent),
onTrackReasons: computeOnTrackReasons(schedule, campaign.replyRatePercent),
};
}
export function computeInboxPlacementRate(received: number, spamDetected: number): number {
if (received <= 0) return 100;
return (1 - spamDetected / received) * 100;
}
export function computeOnTrackReasons(
schedule: Pick<
DailySchedule,
'plannedSends' | 'plannedReplies' | 'actualSends' | 'actualReplies'
>,
replyRatePercent: number,
): string[] {
const reasons: string[] = [];
if (schedule.actualSends < MIN_SENDS_FOR_SEND_CHECK) {
return [];
}
if (
schedule.plannedSends > 0 &&
schedule.actualSends < schedule.plannedSends * 0.85
) {
const needed = Math.ceil(schedule.plannedSends * 0.85);
reasons.push(
`Sends behind schedule: ${schedule.actualSends}/${schedule.plannedSends} today (need at least ${needed})`,
);
}
if (schedule.actualSends >= MIN_SENDS_FOR_REPLY_CHECK) {
const actualReplyRate =
schedule.actualSends > 0
? (schedule.actualReplies / schedule.actualSends) * 100
: 0;
const delta = Math.abs(actualReplyRate - replyRatePercent);
if (delta > 10) {
reasons.push(
`Reply rate off target: ${actualReplyRate.toFixed(1)}% actual vs ${replyRatePercent}% target`,
);
}
}
return reasons;
}
export function computeOnTrack(
schedule: Pick<
DailySchedule,
'plannedSends' | 'plannedReplies' | 'actualSends' | 'actualReplies'
>,
replyRatePercent: number,
): boolean {
if (schedule.actualSends < MIN_SENDS_FOR_SEND_CHECK) {
return true;
}
const sendOnTrack =
schedule.plannedSends === 0 ||
schedule.actualSends >= schedule.plannedSends * 0.85;
const actualReplyRate =
schedule.actualSends > 0
? (schedule.actualReplies / schedule.actualSends) * 100
: 0;
const replyOnTrack =
schedule.actualSends < MIN_SENDS_FOR_REPLY_CHECK ||
Math.abs(actualReplyRate - replyRatePercent) <= 10;
return sendOnTrack && replyOnTrack;
}
async function getCampaignOrThrow(campaignId: string) {
const campaign = await prisma.warmupCampaign.findUnique({
where: { id: campaignId },
});
if (!campaign) {
throw new CampaignError('Campaign not found', 404);
}
return campaign;
}
export async function getCampaignWarmupPlan(campaignId: string): Promise<CampaignWarmupPlan> {
const campaign = await getCampaignOrThrow(campaignId);
const zone = campaign.timezone;
const schedules = await prisma.dailySchedule.findMany({
where: { campaignId, dayIndex: { gt: 0 } },
orderBy: { dayIndex: 'asc' },
});
const days: CampaignWarmupPlanDay[] = schedules.map((schedule) => {
const isToday = schedule.dayIndex === campaign.currentDay;
const isFuture = campaign.currentDay > 0 && schedule.dayIndex > campaign.currentDay;
return {
dayIndex: schedule.dayIndex,
date: schedule.date
? (DateTime.fromJSDate(schedule.date).setZone(zone).toISODate() ?? null)
: null,
isToday,
isFuture,
planned: {
sends: schedule.plannedSends,
replies: schedule.plannedReplies,
},
actual: {
sends: schedule.actualSends,
replies: schedule.actualReplies,
spamDetected: schedule.spamDetected,
spamRescued: schedule.spamRescued,
},
};
});
return {
campaignId: campaign.id,
currentDay: campaign.currentDay,
durationDays: campaign.durationDays,
days,
};
}
export async function getCampaignStatsToday(campaignId: string): Promise<CampaignStatsPayload> {
const campaign = await getCampaignOrThrow(campaignId);
const todayIso = DateTime.now().setZone(campaign.timezone).toISODate() ?? '';
const schedule = await prisma.dailySchedule.findUnique({
where: {
campaignId_dayIndex: { campaignId, dayIndex: campaign.currentDay },
},
});
if (!schedule) {
throw new CampaignError('No schedule for current campaign day', 404);
}
return scheduleToPayload(campaign, schedule, todayIso);
}
export async function getCampaignStatsDaily(
campaignId: string,
from?: string,
to?: string,
): Promise<CampaignStatsPayload[]> {
const campaign = await getCampaignOrThrow(campaignId);
const zone = campaign.timezone;
const fromDate = from
? DateTime.fromISO(from, { zone }).startOf('day')
: DateTime.now().setZone(zone).minus({ days: 29 }).startOf('day');
const toDate = to
? DateTime.fromISO(to, { zone }).endOf('day')
: DateTime.now().setZone(zone).endOf('day');
const historical = await prisma.dailyStat.findMany({
where: {
campaignId,
date: {
gte: fromDate.toJSDate(),
lte: toDate.toJSDate(),
},
},
orderBy: { date: 'asc' },
});
const results: CampaignStatsPayload[] = historical.map((stat) => ({
campaignId,
date: DateTime.fromJSDate(stat.date).setZone(zone).toISODate() ?? '',
dayIndex: stat.dayIndex,
planned: { sends: stat.plannedSends, replies: stat.plannedReplies },
actual: {
sends: stat.actualSends,
replies: stat.actualReplies,
spamDetected: stat.spamDetected,
spamRescued: stat.spamRescued,
},
rates: {
reply: stat.replyRateActual ?? 0,
inbox: stat.inboxPlacementRate ?? 100,
},
recipe: campaign.recipe,
onTrack: computeOnTrack(stat, campaign.replyRatePercent),
onTrackReasons: computeOnTrackReasons(stat, campaign.replyRatePercent),
}));
const todayIso = DateTime.now().setZone(zone).toISODate();
const toIso = toDate.toISODate();
if (todayIso && toIso && todayIso <= toIso) {
const today = await getCampaignStatsToday(campaignId);
const withoutToday = results.filter((r) => r.date !== todayIso);
return [...withoutToday, today];
}
return results;
}
export async function getCampaignStatsSummary(
campaignId: string,
range: StatsRange = '7d',
): Promise<{
campaignId: string;
range: StatsRange;
totals: CampaignStatsPayload['actual'] & { plannedSends: number; plannedReplies: number };
rates: { reply: number; inbox: number };
onTrack: boolean;
days: number;
}> {
const campaign = await getCampaignOrThrow(campaignId);
const zone = campaign.timezone;
const now = DateTime.now().setZone(zone);
let fromDate: DateTime;
if (range === '7d') {
fromDate = now.minus({ days: 6 }).startOf('day');
} else if (range === '30d') {
fromDate = now.minus({ days: 29 }).startOf('day');
} else {
fromDate = DateTime.fromMillis(0);
}
const daily = await getCampaignStatsDaily(
campaignId,
fromDate.toISODate() ?? undefined,
now.toISODate() ?? undefined,
);
const totals = daily.reduce(
(acc, day) => ({
plannedSends: acc.plannedSends + day.planned.sends,
plannedReplies: acc.plannedReplies + day.planned.replies,
sends: acc.sends + day.actual.sends,
replies: acc.replies + day.actual.replies,
spamDetected: acc.spamDetected + day.actual.spamDetected,
spamRescued: acc.spamRescued + day.actual.spamRescued,
}),
{
plannedSends: 0,
plannedReplies: 0,
sends: 0,
replies: 0,
spamDetected: 0,
spamRescued: 0,
},
);
const replyRate = totals.sends > 0 ? (totals.replies / totals.sends) * 100 : 0;
const inboxRate = computeInboxPlacementRate(totals.sends, totals.spamDetected);
return {
campaignId,
range,
totals,
rates: {
reply: Math.round(replyRate * 10) / 10,
inbox: Math.round(inboxRate * 10) / 10,
},
onTrack: daily.length > 0 ? daily.every((d) => d.onTrack) : true,
days: daily.length,
};
}
export async function getWorkspaceOverview(workspaceId?: string) {
const campaigns = await prisma.warmupCampaign.findMany({
where: {
...(workspaceId ? { workspaceId } : {}),
status: {
in: [CampaignStatus.active, CampaignStatus.maintenance, CampaignStatus.paused],
},
},
include: {
primaryMailbox: true,
dailySchedules: {
where: { dayIndex: { gt: 0 } },
orderBy: { dayIndex: 'desc' },
take: 1,
},
},
orderBy: { updatedAt: 'desc' },
});
const items = await Promise.all(
campaigns.map(async (campaign) => {
let today: CampaignStatsPayload | null = null;
if (campaign.currentDay > 0) {
try {
today = await getCampaignStatsToday(campaign.id);
} catch {
today = null;
}
}
return {
campaignId: campaign.id,
name: campaign.name,
status: campaign.status,
recipe: campaign.recipe,
primaryEmail: campaign.primaryMailbox.email,
currentDay: campaign.currentDay,
durationDays: campaign.durationDays,
today,
};
}),
);
return {
workspaceId: workspaceId ?? null,
campaigns: items,
count: items.length,
};
}
export async function finalizeDailyStatForSchedule(
campaign: WarmupCampaign,
schedule: DailySchedule,
): Promise<void> {
if (!schedule.date) return;
const replyRateActual =
schedule.actualSends > 0
? (schedule.actualReplies / schedule.actualSends) * 100
: 0;
const inboxPlacementRate = computeInboxPlacementRate(
schedule.actualSends,
schedule.spamDetected,
);
const dayStart = DateTime.fromJSDate(schedule.date)
.setZone(campaign.timezone)
.startOf('day')
.toJSDate();
await prisma.dailyStat.upsert({
where: {
campaignId_date: { campaignId: campaign.id, date: dayStart },
},
create: {
campaignId: campaign.id,
date: dayStart,
dayIndex: schedule.dayIndex,
plannedSends: schedule.plannedSends,
plannedReplies: schedule.plannedReplies,
actualSends: schedule.actualSends,
actualReplies: schedule.actualReplies,
spamDetected: schedule.spamDetected,
spamRescued: schedule.spamRescued,
replyRateActual,
inboxPlacementRate,
},
update: {
dayIndex: schedule.dayIndex,
plannedSends: schedule.plannedSends,
plannedReplies: schedule.plannedReplies,
actualSends: schedule.actualSends,
actualReplies: schedule.actualReplies,
spamDetected: schedule.spamDetected,
spamRescued: schedule.spamRescued,
replyRateActual,
inboxPlacementRate,
},
});
}

View file

@ -1,34 +0,0 @@
import { prisma } from '../lib/prisma';
import { DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_NAME } from '../models/workspace.constants';
export async function seedDefaultWorkspace(): Promise<void> {
await prisma.workspace.upsert({
where: { name: DEFAULT_WORKSPACE_NAME },
create: {
id: DEFAULT_WORKSPACE_ID,
name: DEFAULT_WORKSPACE_NAME,
},
update: {},
});
}
export async function getDefaultWorkspace() {
await seedDefaultWorkspace();
const workspace = await prisma.workspace.findUnique({
where: { name: DEFAULT_WORKSPACE_NAME },
});
if (!workspace) {
throw new Error('Default workspace not found after seed');
}
return workspace;
}
export async function resolveWorkspaceId(workspaceId?: string): Promise<string> {
if (workspaceId) {
const ws = await prisma.workspace.findUnique({ where: { id: workspaceId } });
if (!ws) throw new Error(`Workspace not found: ${workspaceId}`);
return workspaceId;
}
const ws = await getDefaultWorkspace();
return ws.id;
}

View file

@ -1,60 +0,0 @@
import { randomUUID } from 'node:crypto';
import { MailboxProvider, MailboxRole } from '../../lib/prisma';
import { prisma } from '../../lib/prisma';
import { DEFAULT_WORKSPACE_ID } from '../../models/workspace.constants';
let counter = 0;
function nextId(prefix: string): string {
counter += 1;
return `${prefix}-${Date.now()}-${counter}`;
}
export async function ensureTestWorkspace(): Promise<string> {
await prisma.workspace.upsert({
where: { id: DEFAULT_WORKSPACE_ID },
create: { id: DEFAULT_WORKSPACE_ID, name: 'default' },
update: {},
});
return DEFAULT_WORKSPACE_ID;
}
export async function createTestInbox(overrides?: {
email?: string;
role?: MailboxRole;
}) {
const workspaceId = await ensureTestWorkspace();
const email = overrides?.email ?? `inbox-${randomUUID()}@test.local`;
return prisma.inbox.create({
data: {
workspaceId,
email,
smtpHost: 'smtp.test.local',
smtpPort: 587,
imapHost: 'imap.test.local',
imapPort: 993,
username: email,
password: 'encrypted-test',
role: overrides?.role ?? MailboxRole.unassigned,
provider: MailboxProvider.custom,
},
});
}
export async function cleanupCampaign(campaignId: string): Promise<void> {
await prisma.warmupLog.deleteMany({ where: { campaignId } });
await prisma.dailyStat.deleteMany({ where: { campaignId } });
await prisma.dailySchedule.deleteMany({ where: { campaignId } });
await prisma.campaignSatellite.deleteMany({ where: { campaignId } });
await prisma.warmupCampaign.delete({ where: { id: campaignId } });
}
export async function cleanupInbox(inboxId: string): Promise<void> {
await prisma.warmupLog.deleteMany({
where: { OR: [{ senderId: inboxId }, { receiverId: inboxId }] },
});
await prisma.campaignSatellite.deleteMany({ where: { satelliteMailboxId: inboxId } });
await prisma.warmupCampaign.deleteMany({ where: { primaryMailboxId: inboxId } });
await prisma.inbox.delete({ where: { id: inboxId } });
}

View file

@ -1,42 +0,0 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { shouldReply, simulateReplyRateOverMessages } from '../../services/reply-rate.service';
import { WarmupStatus } from '../../lib/prisma';
describe('integration: campaign reply flow', () => {
it('samples replies instead of replying to every message', () => {
let replies = 0;
const messageCount = 50;
const target = 30;
for (let i = 0; i < messageCount; i++) {
const stats = { received: i + 1, replies };
if (
shouldReply(
{
replyRatePercent: target,
plannedReplies: 15,
actualReplies: replies,
},
stats,
(i * 17 + 3) / 100,
)
) {
replies++;
}
}
assert.ok(replies < messageCount);
assert.ok(replies > 0);
});
it('simulation converges near 30% over 120 messages', () => {
const { replyRate } = simulateReplyRateOverMessages(30, 120);
assert.ok(replyRate >= 25);
assert.ok(replyRate <= 35);
});
it('engaged_no_reply is a valid terminal path status', () => {
assert.equal(WarmupStatus.engaged_no_reply, 'engaged_no_reply');
});
});

View file

@ -1,152 +0,0 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CampaignRecipe, MailboxRole } from '../../lib/prisma';
import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service';
import { prisma } from '../../lib/prisma';
import { getCampaignStatsToday, getCampaignWarmupPlan } from '../../services/stats.service';
import {
cleanupCampaign,
cleanupInbox,
createTestInbox,
} from '../helpers/test-db';
describe('integration: concurrent primaries', () => {
const campaignIds: string[] = [];
const inboxIds: string[] = [];
after(async () => {
for (const id of campaignIds) await cleanupCampaign(id);
for (const id of inboxIds) await cleanupInbox(id);
});
it('runs two primaries with shared satellites independently', async () => {
const primaryA = await createTestInbox({ role: MailboxRole.primary });
const primaryB = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primaryA.id, primaryB.id, sat1.id, sat2.id);
const campaignA = await createCampaign({
primaryMailboxId: primaryA.id,
name: 'Primary A',
recipe: CampaignRecipe.grow,
durationDays: 14,
});
const campaignB = await createCampaign({
primaryMailboxId: primaryB.id,
name: 'Primary B',
recipe: CampaignRecipe.flat,
durationDays: 14,
});
campaignIds.push(campaignA.id, campaignB.id);
for (const campaignId of [campaignA.id, campaignB.id]) {
await assignSatellite(campaignId, sat1.id);
await assignSatellite(campaignId, sat2.id);
}
const startedA = await startCampaign(campaignA.id);
const startedB = await startCampaign(campaignB.id);
assert.equal(startedA.status, 'active');
assert.equal(startedB.status, 'active');
assert.notEqual(startedA.id, startedB.id);
const flatSchedule = await prisma.dailySchedule.findFirst({
where: { campaignId: startedB.id, dayIndex: 1 },
});
const growSchedule = await prisma.dailySchedule.findFirst({
where: { campaignId: startedA.id, dayIndex: 1 },
});
assert.equal(flatSchedule!.plannedSends, 40);
assert.equal(growSchedule!.plannedSends, 1);
});
});
describe('integration: campaign stats flow', () => {
const campaignIds: string[] = [];
const inboxIds: string[] = [];
after(async () => {
for (const id of campaignIds) await cleanupCampaign(id);
for (const id of inboxIds) await cleanupInbox(id);
});
it('stats today matches DailySchedule counters', async () => {
const primary = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const campaign = await createCampaign({
primaryMailboxId: primary.id,
name: 'Stats test',
recipe: CampaignRecipe.flat,
durationDays: 14,
});
campaignIds.push(campaign.id);
await assignSatellite(campaign.id, sat1.id);
await assignSatellite(campaign.id, sat2.id);
const started = await startCampaign(campaign.id);
await prisma.dailySchedule.update({
where: {
campaignId_dayIndex: { campaignId: started.id, dayIndex: 1 },
},
data: {
actualSends: 5,
actualReplies: 2,
spamDetected: 1,
spamRescued: 1,
},
});
const stats = await getCampaignStatsToday(started.id);
assert.equal(stats.actual.sends, 5);
assert.equal(stats.actual.replies, 2);
assert.equal(stats.actual.spamDetected, 1);
assert.equal(stats.planned.sends, 40);
});
it('warmup plan returns full schedule with live actuals', async () => {
const primary = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const campaign = await createCampaign({
primaryMailboxId: primary.id,
name: 'Plan test',
recipe: CampaignRecipe.grow,
durationDays: 14,
});
campaignIds.push(campaign.id);
await assignSatellite(campaign.id, sat1.id);
await assignSatellite(campaign.id, sat2.id);
const started = await startCampaign(campaign.id);
await prisma.dailySchedule.update({
where: {
campaignId_dayIndex: { campaignId: started.id, dayIndex: 1 },
},
data: {
actualSends: 3,
actualReplies: 1,
spamDetected: 1,
},
});
const plan = await getCampaignWarmupPlan(started.id);
assert.equal(plan.durationDays, 14);
assert.equal(plan.days.length, 14);
assert.equal(plan.days[0]!.dayIndex, 1);
assert.equal(plan.days[0]!.planned.sends, 1);
assert.equal(plan.days[0]!.actual.sends, 3);
assert.equal(plan.days[0]!.actual.replies, 1);
assert.equal(plan.days[0]!.actual.spamDetected, 1);
assert.equal(plan.days[0]!.isToday, true);
assert.equal(plan.days[1]!.isFuture, true);
assert.equal(plan.days[1]!.actual.sends, 0);
});
});

View file

@ -1,74 +0,0 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CampaignRecipe, MailboxRole } from '../../lib/prisma';
import { prisma } from '../../lib/prisma';
import {
createCampaign,
deleteCampaign,
startCampaign,
CampaignError,
} from '../../services/campaign.service';
import {
cleanupCampaign,
cleanupInbox,
createTestInbox,
} from '../helpers/test-db';
describe('integration: primary mailbox exclusivity', () => {
const campaignIds: string[] = [];
const inboxIds: string[] = [];
after(async () => {
for (const id of campaignIds) await cleanupCampaign(id);
for (const id of inboxIds) await cleanupInbox(id);
});
it('rejects a second campaign for the same primary while one is active', async () => {
const primary = await createTestInbox({ role: MailboxRole.unassigned });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const running = await createCampaign({
primaryMailboxId: primary.id,
name: 'Running',
recipe: CampaignRecipe.flat,
durationDays: 14,
satelliteMailboxIds: [sat1.id, sat2.id],
});
campaignIds.push(running.id);
await startCampaign(running.id);
await assert.rejects(
() =>
createCampaign({
primaryMailboxId: primary.id,
name: 'Duplicate',
recipe: CampaignRecipe.grow,
durationDays: 14,
}),
(err: unknown) => {
assert.ok(err instanceof CampaignError);
assert.match(err.message, /already used by campaign/i);
return true;
},
);
});
it('deleting a draft resets primary mailbox role when unused', async () => {
const primary = await createTestInbox({ role: MailboxRole.unassigned });
inboxIds.push(primary.id);
const draft = await createCampaign({
primaryMailboxId: primary.id,
name: 'Draft to delete',
recipe: CampaignRecipe.grow,
durationDays: 14,
});
await deleteCampaign(draft.id);
const refreshed = await prisma.inbox.findUnique({ where: { id: primary.id } });
assert.equal(refreshed?.role, MailboxRole.unassigned);
});
});

View file

@ -1,118 +0,0 @@
import { after, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { CampaignRecipe, MailboxRole } from '../../lib/prisma';
import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service';
import { prisma } from '../../lib/prisma';
import {
cleanupCampaign,
cleanupInbox,
createTestInbox,
} from '../helpers/test-db';
describe('integration: recipes', () => {
const campaignIds: string[] = [];
const inboxIds: string[] = [];
after(async () => {
for (const id of campaignIds) await cleanupCampaign(id);
for (const id of inboxIds) await cleanupInbox(id);
});
async function setupCampaign(recipe: CampaignRecipe, extra?: Record<string, unknown>) {
const primary = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const campaign = await createCampaign({
primaryMailboxId: primary.id,
name: `Test ${recipe}`,
recipe,
durationDays: 14,
minEmailsPerDay: 1,
maxEmailsPerDay: 40,
...extra,
});
campaignIds.push(campaign.id);
await assignSatellite(campaign.id, sat1.id);
await assignSatellite(campaign.id, sat2.id);
return startCampaign(campaign.id);
}
it('flat recipe persists constant plannedSends', async () => {
const campaign = await setupCampaign(CampaignRecipe.flat);
const schedules = await prisma.dailySchedule.findMany({
where: { campaignId: campaign.id },
orderBy: { dayIndex: 'asc' },
});
assert.equal(schedules.length, 14);
for (const row of schedules) {
assert.equal(row.plannedSends, 40);
}
});
it('custom recipe persists hold-last schedule', async () => {
const primary = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const campaign = await createCampaign({
primaryMailboxId: primary.id,
name: 'Test custom',
recipe: CampaignRecipe.custom,
durationDays: 14,
customSchedule: [
{ day: 1, sends: 2 },
{ day: 2, sends: 5 },
],
});
campaignIds.push(campaign.id);
await assignSatellite(campaign.id, sat1.id);
await assignSatellite(campaign.id, sat2.id);
const started = await startCampaign(campaign.id);
const schedules = await prisma.dailySchedule.findMany({
where: { campaignId: started.id },
orderBy: { dayIndex: 'asc' },
});
assert.equal(schedules[0]!.plannedSends, 2);
assert.equal(schedules[1]!.plannedSends, 5);
assert.equal(schedules[13]!.plannedSends, 5);
});
it('randomized recipe is deterministic per campaign id seed', async () => {
const a = await setupCampaign(CampaignRecipe.randomized);
const bPrimary = await createTestInbox({ role: MailboxRole.primary });
inboxIds.push(bPrimary.id);
const b = await createCampaign({
primaryMailboxId: bPrimary.id,
name: 'Random B',
recipe: CampaignRecipe.randomized,
durationDays: 14,
});
campaignIds.push(b.id);
const satB1 = await createTestInbox();
const satB2 = await createTestInbox();
inboxIds.push(satB1.id, satB2.id);
await assignSatellite(b.id, satB1.id);
await assignSatellite(b.id, satB2.id);
const scheduleA = await prisma.dailySchedule.findMany({
where: { campaignId: a.id },
orderBy: { dayIndex: 'asc' },
});
const restarted = await startCampaign(b.id);
const scheduleB = await prisma.dailySchedule.findMany({
where: { campaignId: restarted.id },
orderBy: { dayIndex: 'asc' },
});
assert.notDeepEqual(
scheduleA.map((r) => r.plannedSends),
scheduleB.map((r) => r.plannedSends),
);
});
});

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Warmbox</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -1,35 +0,0 @@
{
"name": "@warmbox/web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.80.7",
"@warmbox/shared": "*",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"luxon": "^3.7.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.2",
"recharts": "^2.15.3",
"tailwind-merge": "^3.3.0",
"zod": "^3.25.28"
},
"devDependencies": {
"@types/luxon": "^3.7.2",
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.2",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#4f46e5"/>
<path d="M16 8c-2 4-6 6-6 10a6 6 0 1 0 12 0c0-4-4-6-6-10z" fill="#fff"/>
</svg>

Before

Width:  |  Height:  |  Size: 210 B

View file

@ -1,36 +0,0 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { AppShell } from './components/layout/AppShell';
import { LoginPage, RequireAuth } from './lib/auth';
import { DashboardPage } from './pages/DashboardPage';
import { CampaignsPage } from './pages/CampaignsPage';
import { CampaignWizardPage } from './pages/CampaignWizardPage';
import { CampaignDetailPage } from './pages/CampaignDetailPage';
import { MailboxesPage } from './pages/MailboxesPage';
import { MailboxDetailPage } from './pages/MailboxDetailPage';
import { ActivityPage } from './pages/ActivityPage';
import { SettingsPage } from './pages/SettingsPage';
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAuth>
<AppShell />
</RequireAuth>
}
>
<Route index element={<DashboardPage />} />
<Route path="campaigns" element={<CampaignsPage />} />
<Route path="campaigns/new" element={<CampaignWizardPage />} />
<Route path="campaigns/:id" element={<CampaignDetailPage />} />
<Route path="mailboxes" element={<MailboxesPage />} />
<Route path="mailboxes/:id" element={<MailboxDetailPage />} />
<Route path="activity" element={<ActivityPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View file

@ -1,68 +0,0 @@
import type { ComplianceCheckItem, ComplianceReport } from '@warmbox/shared';
import { Link } from 'react-router-dom';
import { CheckCircle2, AlertTriangle, XCircle, MinusCircle } from 'lucide-react';
import { cn } from '../lib/utils';
import { Badge } from './ui/Badge';
const statusIcons = {
pass: CheckCircle2,
warn: AlertTriangle,
fail: XCircle,
na: MinusCircle,
};
const statusColors = {
pass: 'text-emerald-600',
warn: 'text-amber-600',
fail: 'text-red-600',
na: 'text-slate-400',
};
const overallVariants = {
pass: 'success' as const,
warn: 'warning' as const,
fail: 'danger' as const,
na: 'default' as const,
};
function CheckRow({ check }: { check: ComplianceCheckItem }) {
const Icon = statusIcons[check.status];
return (
<div className="flex items-start gap-3 rounded-lg border border-slate-100 p-3">
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', statusColors[check.status])} />
<div className="min-w-0 flex-1">
<p className="font-medium text-slate-900">{check.label}</p>
<p className="text-sm text-slate-500">{check.message}</p>
{check.actionPath && (
<Link
to={check.actionPath.split('#')[0]!}
className="mt-1 inline-block text-sm font-medium text-primary-600 hover:underline"
>
{check.actionLabel ?? 'Open settings'}
</Link>
)}
</div>
<Badge variant={overallVariants[check.status]} className="capitalize shrink-0">
{check.status}
</Badge>
</div>
);
}
export function ComplianceChecklist({ report }: { report: ComplianceReport }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600">Overall:</span>
<Badge variant={overallVariants[report.overall]} className="capitalize">
{report.overall}
</Badge>
</div>
<div className="space-y-2">
{report.checks.map((check) => (
<CheckRow key={check.id} check={check} />
))}
</div>
</div>
);
}

View file

@ -1,98 +0,0 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { DailyChartRow } from '../lib/campaign-chart-utils';
function PerformanceTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: DailyChartRow; name?: string; value?: number; color?: string }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
<p className="font-semibold text-slate-900">{row.label}</p>
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
<dl className="space-y-1">
<div className="flex justify-between gap-4">
<dt className="text-slate-500">Scheduled</dt>
<dd className="font-medium tabular-nums">{row.plannedSends}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-indigo-600">Sent</dt>
<dd className="font-medium tabular-nums text-indigo-600">{row.sends}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-emerald-600">Replied</dt>
<dd className="font-medium tabular-nums text-emerald-600">{row.replies}</dd>
</div>
</dl>
</div>
);
}
type DailyPerformanceChartProps = {
data: DailyChartRow[];
};
export function DailyPerformanceChart({ data }: DailyPerformanceChartProps) {
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
No activity in the last 7 days yet.
</p>
);
}
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PerformanceTooltip />} />
<Legend />
<Line
type="monotone"
dataKey="plannedSends"
name="Scheduled"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
/>
<Line
type="monotone"
dataKey="sends"
name="Sent"
stroke="#4f46e5"
strokeWidth={2}
dot={{ r: 3, fill: '#4f46e5' }}
activeDot={{ r: 5 }}
/>
<Line
type="monotone"
dataKey="replies"
name="Replied"
stroke="#ec4899"
strokeWidth={2}
dot={{ r: 3, fill: '#ec4899' }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,27 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent } from './ui/Card';
export function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="mb-4 rounded-full bg-slate-100 p-4">
<Icon className="h-8 w-8 text-slate-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
{description && <p className="mt-1 max-w-sm text-sm text-slate-500">{description}</p>}
{action && <div className="mt-4">{action}</div>}
</CardContent>
</Card>
);
}

View file

@ -1,88 +0,0 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { DailyChartRow } from '../lib/campaign-chart-utils';
import { formatPercent } from '../lib/utils';
function PlacementBarTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: DailyChartRow }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
const total = row.inbox + row.spam;
const inboxRate = total > 0 ? (row.inbox / total) * 100 : 100;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[200px]">
<p className="font-semibold text-slate-900">{row.label}</p>
<p className="text-xs text-slate-500 mb-2">Day {row.dayIndex}</p>
<dl className="space-y-1">
<div className="flex justify-between gap-4">
<dt className="text-emerald-700">Inbox</dt>
<dd className="font-medium tabular-nums text-emerald-700">{row.inbox}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-red-600">Spam</dt>
<dd className="font-medium tabular-nums text-red-600">{row.spam}</dd>
</div>
<div className="flex justify-between gap-4 pt-1 border-t border-slate-100">
<dt className="text-slate-500">Inbox rate</dt>
<dd className="font-medium tabular-nums">{formatPercent(inboxRate)}</dd>
</div>
</dl>
</div>
);
}
type InboxPlacementChartProps = {
data: DailyChartRow[];
};
export function InboxPlacementChart({ data }: InboxPlacementChartProps) {
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
No placement data in the last 7 days yet.
</p>
);
}
return (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PlacementBarTooltip />} />
<Legend />
<Bar
dataKey="inbox"
name="Inbox"
stackId="placement"
fill="#10b981"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="spam"
name="Spam"
stackId="placement"
fill="#ef4444"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,114 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { formatPercent } from '../lib/utils';
const INBOX_COLOR = '#1e3a5f';
const SPAM_COLOR = '#b91c1c';
type PlacementSlice = {
name: string;
value: number;
percent: number;
color: string;
};
type InboxVsSpamChartProps = {
inboxCount: number;
spamCount: number;
};
function PlacementTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: PlacementSlice }>;
}) {
if (!active || !payload?.length) return null;
const slice = payload[0]!.payload;
return (
<div className="rounded-lg border border-slate-200 bg-white px-3 py-2 shadow-lg text-sm">
<p className="font-medium" style={{ color: slice.color }}>
{slice.name}
</p>
<p className="tabular-nums text-slate-600">
{slice.value} emails · {formatPercent(slice.percent)}
</p>
</div>
);
}
export function InboxVsSpamChart({ inboxCount, spamCount }: InboxVsSpamChartProps) {
const total = inboxCount + spamCount;
if (total === 0) {
return (
<p className="text-sm text-slate-500 py-12 text-center">
No warmup emails received yet placement appears after the first sends land in the
primary inbox.
</p>
);
}
const inboxPercent = (inboxCount / total) * 100;
const spamPercent = (spamCount / total) * 100;
const data: PlacementSlice[] = [
{
name: 'In Inbox',
value: inboxCount,
percent: inboxPercent,
color: INBOX_COLOR,
},
{
name: 'In Spam',
value: spamCount,
percent: spamPercent,
color: SPAM_COLOR,
},
].filter((slice) => slice.value > 0);
return (
<div className="h-72 flex flex-col">
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm mb-2">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ background: INBOX_COLOR }} />
<span className="text-slate-700">
In Inbox <strong className="tabular-nums">{formatPercent(inboxPercent)}</strong>
</span>
</span>
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ background: SPAM_COLOR }} />
<span className="text-slate-700">
In Spam <strong className="tabular-nums">{formatPercent(spamPercent)}</strong>
</span>
</span>
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius="58%"
outerRadius="82%"
paddingAngle={data.length > 1 ? 2 : 0}
strokeWidth={2}
stroke="#fff"
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip content={<PlacementTooltip />} />
</PieChart>
</ResponsiveContainer>
</div>
<p className="text-center text-xs text-slate-400 mt-1 tabular-nums">
{total} warmup {total === 1 ? 'email' : 'emails'} tracked
</p>
</div>
);
}

View file

@ -1,48 +0,0 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent } from './ui/Card';
import { cn } from '../lib/utils';
export function MetricCard({
title,
value,
subtitle,
icon: Icon,
trend,
className,
}: {
title: string;
value: string | number;
subtitle?: string;
icon?: LucideIcon;
trend?: { value: string; positive?: boolean };
className?: string;
}) {
return (
<Card className={cn('', className)}>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="text-2xl font-bold text-slate-900">{value}</p>
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
{trend && (
<p
className={cn(
'text-xs font-medium',
trend.positive ? 'text-emerald-600' : 'text-red-600',
)}
>
{trend.value}
</p>
)}
</div>
{Icon && (
<div className="rounded-lg bg-primary-50 p-2.5 text-primary-600">
<Icon className="h-5 w-5" />
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -1,24 +0,0 @@
import type { ReactNode } from 'react';
import { cn } from '../lib/utils';
export function PageHeader({
title,
description,
actions,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn('flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between', className)}>
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">{title}</h1>
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}

View file

@ -1,44 +0,0 @@
import type { CampaignStatus, InboxStatus } from '@warmbox/shared';
import { Badge } from './ui/Badge';
const campaignVariants: Record<
CampaignStatus,
'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline'
> = {
draft: 'outline',
active: 'success',
paused: 'warning',
maintenance: 'primary',
completed: 'default',
failed: 'danger',
};
const inboxVariants: Record<
InboxStatus,
'default' | 'primary' | 'success' | 'warning' | 'danger' | 'outline'
> = {
active: 'success',
paused: 'warning',
error: 'danger',
};
export function StatusBadge({
status,
type = 'campaign',
}: {
status: CampaignStatus | InboxStatus | string;
type?: 'campaign' | 'inbox';
}) {
const variant =
type === 'inbox'
? inboxVariants[status as InboxStatus] ?? 'default'
: campaignVariants[status as CampaignStatus] ?? 'default';
const label = status.replace(/_/g, ' ');
return (
<Badge variant={variant} className="capitalize">
{label}
</Badge>
);
}

View file

@ -1,194 +0,0 @@
import {
Bar,
CartesianGrid,
ComposedChart,
LabelList,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { CampaignWarmupPlan } from '@warmbox/shared';
import { formatChartDate } from '../lib/campaign-chart-utils';
type ChartRow = {
dayIndex: number;
label: string;
date: string | null;
isToday: boolean;
isFuture: boolean;
plannedSends: number;
plannedReplies: number;
inboxDelivered: number;
spamDetected: number;
scheduledRemainder: number;
actualReplies: number;
barTotal: number;
};
function toChartRows(plan: CampaignWarmupPlan): ChartRow[] {
return plan.days.map((day) => {
const inboxDelivered = day.isFuture
? 0
: Math.max(0, day.actual.sends - day.actual.spamDetected);
const spamDetected = day.isFuture ? 0 : day.actual.spamDetected;
const actualTotal = inboxDelivered + spamDetected;
const scheduledRemainder = day.isFuture
? day.planned.sends
: Math.max(0, day.planned.sends - actualTotal);
const barTotal = day.isFuture ? day.planned.sends : Math.max(day.planned.sends, actualTotal);
return {
dayIndex: day.dayIndex,
label: day.date ? formatChartDate(day.date) : `D${day.dayIndex}`,
date: day.date,
isToday: day.isToday,
isFuture: day.isFuture,
plannedSends: day.planned.sends,
plannedReplies: day.planned.replies,
inboxDelivered,
spamDetected,
scheduledRemainder,
actualReplies: day.isFuture ? 0 : day.actual.replies,
barTotal,
};
});
}
function PlanTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: ChartRow }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
const sent = row.inboxDelivered + row.spamDetected;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold text-slate-900">
Day {row.dayIndex}
{row.date ? ` · ${row.label}` : ''}
</p>
{row.isToday && <p className="text-xs text-indigo-600 font-medium mt-0.5">Today</p>}
{row.isFuture && <p className="text-xs text-slate-400 mt-0.5">Upcoming</p>}
<dl className="mt-3 space-y-1.5">
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Scheduled</dt>
<dd className="font-medium tabular-nums">
{row.plannedSends} sends · {row.plannedReplies} replies
</dd>
</div>
{!row.isFuture && (
<>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Sent</dt>
<dd className="font-medium tabular-nums text-indigo-700">{sent}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Replied</dt>
<dd className="font-medium tabular-nums text-pink-600">{row.actualReplies}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">In spam</dt>
<dd className="font-medium tabular-nums text-red-600">{row.spamDetected}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Inbox</dt>
<dd className="font-medium tabular-nums text-emerald-700">{row.inboxDelivered}</dd>
</div>
</>
)}
</dl>
</div>
);
}
type WarmupPlanChartProps = {
plan: CampaignWarmupPlan;
};
export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
const data = toChartRows(plan);
const todayRow = data.find((row) => row.isToday);
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
Start the campaign to generate the warmup schedule.
</p>
);
}
return (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
interval={plan.durationDays > 20 ? Math.floor(plan.durationDays / 10) : 0}
/>
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} width={32} />
<Tooltip content={<PlanTooltip />} />
<Legend />
{todayRow && (
<ReferenceLine
x={todayRow.label}
stroke="#4f46e5"
strokeDasharray="4 4"
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
/>
)}
<Bar
dataKey="inboxDelivered"
stackId="volume"
fill="#1e3a5f"
name="Sent"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="spamDetected"
stackId="volume"
fill="#b91c1c"
name="In spam"
radius={[0, 0, 0, 0]}
/>
<Bar
dataKey="scheduledRemainder"
stackId="volume"
fill="#cbd5e1"
name="Scheduled"
radius={[4, 4, 0, 0]}
>
<LabelList
dataKey="barTotal"
position="top"
className="fill-slate-600 text-[10px] font-medium"
formatter={(value: number) => (value > 0 ? String(value) : '')}
/>
</Bar>
<Line
type="monotone"
dataKey="actualReplies"
name="Replies"
stroke="#ec4899"
strokeWidth={2}
dot={{ r: 4, fill: '#ec4899', strokeWidth: 0 }}
activeDot={{ r: 6 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -1,17 +0,0 @@
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav';
export function AppShell() {
return (
<div className="min-h-screen bg-slate-50">
<Sidebar />
<div className="md:pl-64">
<main className="mx-auto max-w-7xl px-4 py-6 pb-24 md:pb-6 sm:px-6 lg:px-8">
<Outlet />
</main>
</div>
<BottomNav />
</div>
);
}

View file

@ -1,36 +0,0 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Megaphone, Mail, Activity, Settings } from 'lucide-react';
import { cn } from '../../lib/utils';
const navItems = [
{ to: '/', label: 'Home', icon: LayoutDashboard, end: true },
{ to: '/campaigns', label: 'Campaigns', icon: Megaphone },
{ to: '/mailboxes', label: 'Mail', icon: Mail },
{ to: '/activity', label: 'Activity', icon: Activity },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export function BottomNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t border-slate-200 bg-white md:hidden safe-bottom">
<div className="flex items-center justify-around">
{navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium transition-colors',
isActive ? 'text-primary-600' : 'text-slate-500',
)
}
>
<Icon className="h-5 w-5" />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
);
}

View file

@ -1,65 +0,0 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
Megaphone,
Mail,
Activity,
Settings,
Flame,
LogOut,
} from 'lucide-react';
import { cn } from '../../lib/utils';
import { useAuth } from '../../lib/auth';
const navItems = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
{ to: '/campaigns', label: 'Campaigns', icon: Megaphone },
{ to: '/mailboxes', label: 'Mailboxes', icon: Mail },
{ to: '/activity', label: 'Activity', icon: Activity },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export function Sidebar() {
const { logout } = useAuth();
return (
<aside className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0 border-r border-slate-200 bg-white">
<div className="flex h-16 items-center gap-2 border-b border-slate-200 px-6">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600 text-white">
<Flame className="h-4 w-4" />
</div>
<span className="text-lg font-bold text-slate-900">Warmbox</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
isActive
? 'bg-primary-50 text-primary-700'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-200 p-4">
<button
type="button"
onClick={logout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-900"
>
<LogOut className="h-5 w-5" />
Sign out
</button>
</div>
</aside>
);
}

View file

@ -1,42 +0,0 @@
import { type ReactNode } from 'react';
import { AlertCircle, CheckCircle2, Info } from 'lucide-react';
import { cn } from '../../lib/utils';
const variants = {
default: 'border-slate-200 bg-slate-50 text-slate-800',
destructive: 'border-red-200 bg-red-50 text-red-800',
success: 'border-emerald-200 bg-emerald-50 text-emerald-800',
warning: 'border-amber-200 bg-amber-50 text-amber-800',
};
const icons = {
default: Info,
destructive: AlertCircle,
success: CheckCircle2,
warning: AlertCircle,
};
export function Alert({
children,
variant = 'default',
className,
}: {
children: ReactNode;
variant?: keyof typeof variants;
className?: string;
}) {
const Icon = icons[variant];
return (
<div
className={cn(
'flex gap-3 rounded-lg border p-4 text-sm',
variants[variant],
className,
)}
role="alert"
>
<Icon className="h-5 w-5 shrink-0" />
<div className="flex-1">{children}</div>
</div>
);
}

View file

@ -1,28 +0,0 @@
import { type HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
const variants = {
default: 'bg-slate-100 text-slate-700',
primary: 'bg-primary-100 text-primary-800',
success: 'bg-emerald-100 text-emerald-800',
warning: 'bg-amber-100 text-amber-800',
danger: 'bg-red-100 text-red-800',
outline: 'border border-slate-200 bg-white text-slate-700',
};
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: keyof typeof variants;
}
export function Badge({ className, variant = 'default', ...props }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
variants[variant],
className,
)}
{...props}
/>
);
}

View file

@ -1,41 +0,0 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
const variants = {
default: 'bg-primary-600 text-white hover:bg-primary-700 focus-visible:ring-primary-500',
secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-200 focus-visible:ring-slate-400',
outline: 'border border-slate-300 bg-white hover:bg-slate-50 focus-visible:ring-slate-400',
ghost: 'hover:bg-slate-100 focus-visible:ring-slate-400',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500',
};
const sizes = {
default: 'h-10 px-4 py-2 text-sm',
sm: 'h-8 px-3 text-xs',
lg: 'h-11 px-6 text-base',
icon: 'h-10 w-10',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: keyof typeof variants;
size?: keyof typeof sizes;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', disabled, ...props }, ref) => (
<button
ref={ref}
disabled={disabled}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
),
);
Button.displayName = 'Button';

View file

@ -1,48 +0,0 @@
import { forwardRef, type HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-xl border border-slate-200 bg-white shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1.5 p-5 pb-0', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold leading-none', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
export const CardDescription = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-slate-500', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5', className)} {...props} />
),
);
CardContent.displayName = 'CardContent';
export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';

View file

@ -1,19 +0,0 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type = 'text', ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm',
'placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
);
Input.displayName = 'Input';

View file

@ -1,13 +0,0 @@
import { forwardRef, type LabelHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
export const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn('text-sm font-medium text-slate-700', className)}
{...props}
/>
),
);
Label.displayName = 'Label';

View file

@ -1,21 +0,0 @@
import { cn } from '../../lib/utils';
export function Progress({
value,
max = 100,
className,
}: {
value: number;
max?: number;
className?: string;
}) {
const pct = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={cn('h-2 w-full overflow-hidden rounded-full bg-slate-200', className)}>
<div
className="h-full rounded-full bg-primary-600 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
);
}

View file

@ -1,20 +0,0 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
({ className, children, ...props }, ref) => (
<select
ref={ref}
className={cn(
'flex h-10 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
{children}
</select>
),
);
Select.displayName = 'Select';

View file

@ -1,5 +0,0 @@
import { cn } from '../../lib/utils';
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-slate-200', className)} />;
}

Some files were not shown because too many files have changed in this diff Show more