Refactor project structure to support monorepo with workspaces; add API and web applications with respective configurations, dependencies, and scripts. Introduce environment variable validation and initial API setup for email warmup functionality.
This commit is contained in:
parent
4eedbbfb1e
commit
807722dc3e
151 changed files with 36505 additions and 705 deletions
43
apps/api/package.json
Normal file
43
apps/api/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'node:path';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
|
|
@ -13,6 +14,7 @@ const envSchema = z.object({
|
|||
OPENAI_API_KEY: z.string().optional(),
|
||||
API_KEY: z.string().optional(),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
PUBLIC_BASE_URL: z.string().url().optional(),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
});
|
||||
|
||||
|
|
@ -25,7 +27,15 @@ if (!parsed.success) {
|
|||
|
||||
const data = parsed.data;
|
||||
|
||||
function resolveDatabaseUrl(url: string): string {
|
||||
if (!url.startsWith('file:')) return url;
|
||||
const filePath = url.slice('file:'.length);
|
||||
if (path.isAbsolute(filePath)) return url;
|
||||
return `file:${path.resolve(__dirname, '../../../', filePath)}`;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
...data,
|
||||
DATABASE_URL: resolveDatabaseUrl(data.DATABASE_URL),
|
||||
AI_API_KEY: data.AI_API_KEY ?? data.OPENAI_API_KEY,
|
||||
};
|
||||
69
apps/api/src/generated/prisma/browser.ts
Normal file
69
apps/api/src/generated/prisma/browser.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
|
||||
/* !!! 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
|
||||
91
apps/api/src/generated/prisma/client.ts
Normal file
91
apps/api/src/generated/prisma/client.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
|
||||
/* !!! 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
|
||||
708
apps/api/src/generated/prisma/commonInputTypes.ts
Normal file
708
apps/api/src/generated/prisma/commonInputTypes.ts
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
|
||||
/* !!! 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>
|
||||
}
|
||||
|
||||
|
||||
89
apps/api/src/generated/prisma/enums.ts
Normal file
89
apps/api/src/generated/prisma/enums.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
|
||||
/* !!! 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]
|
||||
294
apps/api/src/generated/prisma/internal/class.ts
Normal file
294
apps/api/src/generated/prisma/internal/class.ts
Normal file
File diff suppressed because one or more lines are too long
1663
apps/api/src/generated/prisma/internal/prismaNamespace.ts
Normal file
1663
apps/api/src/generated/prisma/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load diff
270
apps/api/src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
270
apps/api/src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
|
||||
/* !!! 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',
|
||||
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]
|
||||
|
||||
21
apps/api/src/generated/prisma/models.ts
Normal file
21
apps/api/src/generated/prisma/models.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
|
||||
/* !!! 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'
|
||||
1593
apps/api/src/generated/prisma/models/CampaignSatellite.ts
Normal file
1593
apps/api/src/generated/prisma/models/CampaignSatellite.ts
Normal file
File diff suppressed because it is too large
Load diff
1673
apps/api/src/generated/prisma/models/DailySchedule.ts
Normal file
1673
apps/api/src/generated/prisma/models/DailySchedule.ts
Normal file
File diff suppressed because it is too large
Load diff
1763
apps/api/src/generated/prisma/models/DailyStat.ts
Normal file
1763
apps/api/src/generated/prisma/models/DailyStat.ts
Normal file
File diff suppressed because it is too large
Load diff
2969
apps/api/src/generated/prisma/models/Inbox.ts
Normal file
2969
apps/api/src/generated/prisma/models/Inbox.ts
Normal file
File diff suppressed because it is too large
Load diff
1151
apps/api/src/generated/prisma/models/JobRunLog.ts
Normal file
1151
apps/api/src/generated/prisma/models/JobRunLog.ts
Normal file
File diff suppressed because it is too large
Load diff
1494
apps/api/src/generated/prisma/models/SendJob.ts
Normal file
1494
apps/api/src/generated/prisma/models/SendJob.ts
Normal file
File diff suppressed because it is too large
Load diff
1147
apps/api/src/generated/prisma/models/Setting.ts
Normal file
1147
apps/api/src/generated/prisma/models/Setting.ts
Normal file
File diff suppressed because it is too large
Load diff
3268
apps/api/src/generated/prisma/models/WarmupCampaign.ts
Normal file
3268
apps/api/src/generated/prisma/models/WarmupCampaign.ts
Normal file
File diff suppressed because it is too large
Load diff
2026
apps/api/src/generated/prisma/models/WarmupLog.ts
Normal file
2026
apps/api/src/generated/prisma/models/WarmupLog.ts
Normal file
File diff suppressed because it is too large
Load diff
1411
apps/api/src/generated/prisma/models/Workspace.ts
Normal file
1411
apps/api/src/generated/prisma/models/Workspace.ts
Normal file
File diff suppressed because it is too large
Load diff
82
apps/api/src/index.ts
Normal file
82
apps/api/src/index.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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);
|
||||
});
|
||||
220
apps/api/src/jobs/campaign-dispatcher.job.ts
Normal file
220
apps/api/src/jobs/campaign-dispatcher.job.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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 { 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,
|
||||
} 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.active) {
|
||||
logger.warn({ campaignId }, 'Primary mailbox is not active');
|
||||
if (jobId) await completeSendJob(jobId, false, 'Primary mailbox inactive');
|
||||
return;
|
||||
}
|
||||
|
||||
await withInboxGuard(satellite, async () => {
|
||||
const { subject, body, validationResult } =
|
||||
await aiService.generateInitialEmailWithValidation(primary.industry, {
|
||||
senderName: satellite.senderName,
|
||||
companyName: satellite.companyName,
|
||||
});
|
||||
|
||||
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() },
|
||||
});
|
||||
});
|
||||
|
||||
if (jobId) await completeSendJob(jobId, true);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
campaignId: campaign.id,
|
||||
satellite: satellite.email,
|
||||
primary: primary.email,
|
||||
messageId,
|
||||
},
|
||||
'Campaign warmup email dispatched',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runCampaignDispatcherJob(): Promise<string> {
|
||||
logger.info('Campaign dispatcher job started');
|
||||
|
||||
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)`;
|
||||
}
|
||||
24
apps/api/src/jobs/campaign-rollover.job.ts
Normal file
24
apps/api/src/jobs/campaign-rollover.job.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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');
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { logger } from '../lib/logger';
|
|||
import * as aiService from '../services/ai.service';
|
||||
import * as emailService from '../services/email.service';
|
||||
import { getSettingOrDefault } from '../services/settings.service';
|
||||
import { hasAnyCampaign } from '../services/campaign.service';
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
|
|
@ -51,6 +52,12 @@ async function maybeBumpRamp(): Promise<void> {
|
|||
|
||||
export async function runDispatcherJob(): Promise<void> {
|
||||
logger.info('Dispatcher job started');
|
||||
|
||||
if (await hasAnyCampaign()) {
|
||||
logger.info('Legacy flat dispatcher skipped: campaigns exist');
|
||||
return;
|
||||
}
|
||||
|
||||
await maybeBumpRamp();
|
||||
|
||||
const inboxes: Inbox[] = await prisma.inbox.findMany({
|
||||
|
|
@ -74,7 +81,10 @@ export async function runDispatcherJob(): Promise<void> {
|
|||
const receiver = pickRandom(receivers);
|
||||
|
||||
await withInboxGuard(sender, async () => {
|
||||
const { subject, body } = await aiService.generateInitialEmail(sender.industry);
|
||||
const { subject, body } = await aiService.generateInitialEmail(sender.industry, {
|
||||
senderName: sender.senderName,
|
||||
companyName: sender.companyName,
|
||||
});
|
||||
const { messageId } = await emailService.sendMail(sender, {
|
||||
to: receiver.email,
|
||||
subject,
|
||||
17
apps/api/src/jobs/job-runner.ts
Normal file
17
apps/api/src/jobs/job-runner.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
464
apps/api/src/jobs/rescuer.job.ts
Normal file
464
apps/api/src/jobs/rescuer.job.ts
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import { simpleParser } from 'mailparser';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import type { Inbox, WarmupCampaign, WarmupLog } from '../generated/prisma/client';
|
||||
import { CampaignStatus, InboxStatus, WarmupStatus } from '../lib/prisma';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { withInboxGuard } from '../lib/inboxGuard';
|
||||
import { logger } from '../lib/logger';
|
||||
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;
|
||||
|
||||
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()),
|
||||
);
|
||||
|
||||
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> {
|
||||
const pendingLogs = (await prisma.warmupLog.findMany({
|
||||
where: {
|
||||
campaignId: null,
|
||||
receiverId: receiver.id,
|
||||
status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] },
|
||||
},
|
||||
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.active) continue;
|
||||
|
||||
try {
|
||||
await processCampaignPrimary(inbox);
|
||||
} catch (err) {
|
||||
logger.error({ err, inboxId: inbox.id }, 'Campaign rescuer failed for primary');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inboxes = await prisma.inbox.findMany({
|
||||
where: { status: InboxStatus.active },
|
||||
});
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
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');
|
||||
}
|
||||
|
|
@ -1,27 +1,49 @@
|
|||
import cron from 'node-cron';
|
||||
import { logger } from '../lib/logger';
|
||||
import { seedDefaultSettings, getSettingOrDefault } from '../services/settings.service';
|
||||
import { seedDefaultWorkspace } from '../services/workspace.service';
|
||||
import { runCampaignDispatcherJob } from './campaign-dispatcher.job';
|
||||
import { runCampaignRolloverJob } from './campaign-rollover.job';
|
||||
import { runStatsRollupJob } from './stats-rollup.job';
|
||||
import { runDispatcherJob } from './dispatcher.job';
|
||||
import { runRescuerJob } from './rescuer.job';
|
||||
import { runLoggedJob } from './job-runner';
|
||||
|
||||
let dispatcherTask: cron.ScheduledTask | null = null;
|
||||
let rescuerTask: cron.ScheduledTask | null = null;
|
||||
let rolloverTask: cron.ScheduledTask | null = null;
|
||||
let rollupTask: cron.ScheduledTask | null = null;
|
||||
let rampTask: cron.ScheduledTask | null = null;
|
||||
|
||||
async function scheduleJobs(): Promise<void> {
|
||||
if (dispatcherTask) dispatcherTask.stop();
|
||||
if (rescuerTask) rescuerTask.stop();
|
||||
if (rolloverTask) rolloverTask.stop();
|
||||
if (rollupTask) rollupTask.stop();
|
||||
if (rampTask) rampTask.stop();
|
||||
|
||||
const dispatcherCron = await getSettingOrDefault('dispatcher_cron');
|
||||
const rescuerCron = await getSettingOrDefault('rescuer_cron');
|
||||
|
||||
dispatcherTask = cron.schedule(dispatcherCron, () => {
|
||||
runDispatcherJob().catch((err) => logger.error({ err }, 'Dispatcher cron error'));
|
||||
runLoggedJob('campaign-dispatcher', runCampaignDispatcherJob).catch(() => undefined);
|
||||
runDispatcherJob().catch((err) => logger.error({ err }, 'Legacy dispatcher cron error'));
|
||||
});
|
||||
|
||||
rescuerTask = cron.schedule(rescuerCron, () => {
|
||||
runRescuerJob().catch((err) => logger.error({ err }, 'Rescuer cron error'));
|
||||
runLoggedJob('rescuer', async () => {
|
||||
await runRescuerJob();
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
|
||||
rolloverTask = cron.schedule('0 * * * *', () => {
|
||||
runLoggedJob('campaign-rollover', async () => {
|
||||
await runCampaignRolloverJob();
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
|
||||
rollupTask = cron.schedule('5 * * * *', () => {
|
||||
runLoggedJob('stats-rollup', runStatsRollupJob).catch(() => undefined);
|
||||
});
|
||||
|
||||
rampTask = cron.schedule('0 0 * * *', () => {
|
||||
|
|
@ -34,6 +56,7 @@ async function scheduleJobs(): Promise<void> {
|
|||
}
|
||||
|
||||
export async function startScheduler(): Promise<void> {
|
||||
await seedDefaultWorkspace();
|
||||
await seedDefaultSettings();
|
||||
await scheduleJobs();
|
||||
}
|
||||
50
apps/api/src/jobs/stats-rollup.job.ts
Normal file
50
apps/api/src/jobs/stats-rollup.job.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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)`;
|
||||
}
|
||||
70
apps/api/src/lib/dns-check.ts
Normal file
70
apps/api/src/lib/dns-check.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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));
|
||||
}
|
||||
47
apps/api/src/lib/domain-age.ts
Normal file
47
apps/api/src/lib/domain-age.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -31,9 +31,9 @@ export async function withInboxGuard<T>(
|
|||
}
|
||||
}
|
||||
|
||||
export function stripPassword<T extends { password?: string }>(
|
||||
export function stripPassword<T extends { password?: string; oauthRefreshToken?: string | null }>(
|
||||
inbox: T,
|
||||
): Omit<T, 'password'> {
|
||||
const { password: _, ...rest } = inbox;
|
||||
): Omit<T, 'password' | 'oauthRefreshToken'> {
|
||||
const { password: _, oauthRefreshToken: __, ...rest } = inbox;
|
||||
return rest;
|
||||
}
|
||||
|
|
@ -22,5 +22,22 @@ if (config.NODE_ENV !== 'production') {
|
|||
}
|
||||
|
||||
export { PrismaClient } from '../generated/prisma/client';
|
||||
export type { Inbox, WarmupLog, Setting } from '../generated/prisma/client';
|
||||
export { InboxStatus, WarmupStatus } from '../generated/prisma/client';
|
||||
export type {
|
||||
Inbox,
|
||||
WarmupLog,
|
||||
Setting,
|
||||
WarmupCampaign,
|
||||
Workspace,
|
||||
DailySchedule,
|
||||
CampaignSatellite,
|
||||
} from '../generated/prisma/client';
|
||||
export {
|
||||
InboxStatus,
|
||||
WarmupStatus,
|
||||
MailboxRole,
|
||||
MailboxProvider,
|
||||
CampaignRecipe,
|
||||
CampaignStatus,
|
||||
SendJobStatus,
|
||||
MailboxAuthType,
|
||||
} from '../generated/prisma/client';
|
||||
56
apps/api/src/lib/send-window.ts
Normal file
56
apps/api/src/lib/send-window.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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');
|
||||
}
|
||||
75
apps/api/src/models/campaign.schemas.ts
Normal file
75
apps/api/src/models/campaign.schemas.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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);
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
export { InboxStatus, WarmupStatus } from '../lib/prisma';
|
||||
export {
|
||||
InboxStatus,
|
||||
WarmupStatus,
|
||||
MailboxRole,
|
||||
MailboxProvider,
|
||||
CampaignRecipe,
|
||||
CampaignStatus,
|
||||
} from '../lib/prisma';
|
||||
|
||||
export const SPAM_FOLDERS = ['[Gmail]/Spam', 'Junk', 'Spam'] as const;
|
||||
|
||||
50
apps/api/src/models/provider-presets.ts
Normal file
50
apps/api/src/models/provider-presets.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
2
apps/api/src/models/workspace.constants.ts
Normal file
2
apps/api/src/models/workspace.constants.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const DEFAULT_WORKSPACE_NAME = 'default';
|
||||
export const DEFAULT_WORKSPACE_ID = 'ws_default';
|
||||
46
apps/api/src/routes/ai.routes.ts
Normal file
46
apps/api/src/routes/ai.routes.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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;
|
||||
183
apps/api/src/routes/campaigns.routes.ts
Normal file
183
apps/api/src/routes/campaigns.routes.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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;
|
||||
367
apps/api/src/routes/inboxes.routes.ts
Normal file
367
apps/api/src/routes/inboxes.routes.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
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,
|
||||
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 }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
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 if (inbox.status === InboxStatus.error) {
|
||||
await prisma.inbox.update({
|
||||
where: { id: inbox.id },
|
||||
data: { status: InboxStatus.active, lastError: null, errorAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ ok, ...result });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
159
apps/api/src/routes/integrations.routes.ts
Normal file
159
apps/api/src/routes/integrations.routes.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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;
|
||||
60
apps/api/src/routes/jobs.routes.ts
Normal file
60
apps/api/src/routes/jobs.routes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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;
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { WarmupStatus } from '../lib/prisma';
|
||||
import type { WarmupLogWithRelations } from '../models/types';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { stripPassword } from '../lib/inboxGuard';
|
||||
|
||||
|
|
@ -10,17 +9,19 @@ const router = Router();
|
|||
const querySchema = z.object({
|
||||
status: z.nativeEnum(WarmupStatus).optional(),
|
||||
inboxId: z.string().optional(),
|
||||
campaignId: z.string().optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { status, inboxId, page, limit } = querySchema.parse(req.query);
|
||||
const { status, inboxId, campaignId, page, limit } = querySchema.parse(req.query);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
...(status ? { status } : {}),
|
||||
...(campaignId ? { campaignId } : {}),
|
||||
...(inboxId
|
||||
? { OR: [{ senderId: inboxId }, { receiverId: inboxId }] }
|
||||
: {}),
|
||||
|
|
@ -38,7 +39,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|||
]);
|
||||
|
||||
res.json({
|
||||
data: logs.map((log: WarmupLogWithRelations) => ({
|
||||
data: logs.map((log) => ({
|
||||
...log,
|
||||
sender: stripPassword(log.sender),
|
||||
receiver: stripPassword(log.receiver),
|
||||
60
apps/api/src/routes/recipes.routes.ts
Normal file
60
apps/api/src/routes/recipes.routes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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;
|
||||
78
apps/api/src/routes/stats.routes.ts
Normal file
78
apps/api/src/routes/stats.routes.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { CampaignError } from '../services/campaign.service';
|
||||
import {
|
||||
getCampaignStatsDaily,
|
||||
getCampaignStatsSummary,
|
||||
getCampaignStatsToday,
|
||||
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/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);
|
||||
}
|
||||
});
|
||||
233
apps/api/src/services/ai.service.ts
Normal file
233
apps/api/src/services/ai.service.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
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;
|
||||
}
|
||||
654
apps/api/src/services/campaign.service.ts
Normal file
654
apps/api/src/services/campaign.service.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
169
apps/api/src/services/compliance.service.ts
Normal file
169
apps/api/src/services/compliance.service.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
37
apps/api/src/services/content-validator.service.test.ts
Normal file
37
apps/api/src/services/content-validator.service.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
108
apps/api/src/services/content-validator.service.ts
Normal file
108
apps/api/src/services/content-validator.service.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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 };
|
||||
}
|
||||
|
|
@ -1,15 +1,46 @@
|
|||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
import type { Inbox } from '../lib/prisma';
|
||||
import { MailboxAuthType } from '../lib/prisma';
|
||||
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 function createSmtpTransport(inbox: Inbox): Transporter {
|
||||
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,
|
||||
|
|
@ -27,14 +58,15 @@ export function createSmtpTransport(inbox: Inbox): Transporter {
|
|||
export async function testSmtp(
|
||||
inbox: Inbox,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const transporter = createSmtpTransport(inbox);
|
||||
const transporter = await createSmtpTransport(inbox);
|
||||
try {
|
||||
await transporter.verify();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'SMTP verification failed';
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'SMTP verification failed',
|
||||
error: formatMicrosoftAuthError(message),
|
||||
};
|
||||
} finally {
|
||||
transporter.close();
|
||||
|
|
@ -45,7 +77,7 @@ export async function sendMail(
|
|||
inbox: Inbox,
|
||||
payload: EmailPayload,
|
||||
): Promise<{ messageId: string }> {
|
||||
const transporter = createSmtpTransport(inbox);
|
||||
const transporter = await createSmtpTransport(inbox);
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { ImapFlow } from 'imapflow';
|
||||
import type { Inbox } from '../lib/prisma';
|
||||
import { MailboxAuthType } from '../lib/prisma';
|
||||
import { decrypt } from './crypto.service';
|
||||
import { resolveMailboxAccessToken } from './mailbox-auth.service';
|
||||
import { formatMicrosoftAuthError } from './microsoft-oauth.service';
|
||||
|
||||
const CONNECTION_TIMEOUT = 30_000;
|
||||
|
||||
|
|
@ -8,9 +11,28 @@ function getPassword(inbox: Inbox): string {
|
|||
return decrypt(inbox.password);
|
||||
}
|
||||
|
||||
export function createImapClient(inbox: Inbox): ImapFlow {
|
||||
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,
|
||||
|
|
@ -28,15 +50,16 @@ export function createImapClient(inbox: Inbox): ImapFlow {
|
|||
export async function testImap(
|
||||
inbox: Inbox,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const client = createImapClient(inbox);
|
||||
const client = await createImapClient(inbox);
|
||||
try {
|
||||
await client.connect();
|
||||
await client.mailboxOpen('INBOX');
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'IMAP connection failed';
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'IMAP connection failed',
|
||||
error: formatMicrosoftAuthError(message),
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
|
|
@ -51,7 +74,7 @@ export async function withImapClient<T>(
|
|||
inbox: Inbox,
|
||||
fn: (client: ImapFlow) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = createImapClient(inbox);
|
||||
const client = await createImapClient(inbox);
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
111
apps/api/src/services/job-queue.service.ts
Normal file
111
apps/api/src/services/job-queue.service.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
14
apps/api/src/services/mailbox-auth.service.ts
Normal file
14
apps/api/src/services/mailbox-auth.service.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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));
|
||||
}
|
||||
121
apps/api/src/services/microsoft-oauth.service.ts
Normal file
121
apps/api/src/services/microsoft-oauth.service.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
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;
|
||||
}
|
||||
212
apps/api/src/services/postmaster.service.ts
Normal file
212
apps/api/src/services/postmaster.service.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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',
|
||||
};
|
||||
}
|
||||
144
apps/api/src/services/recipe.service.test.ts
Normal file
144
apps/api/src/services/recipe.service.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
251
apps/api/src/services/recipe.service.ts
Normal file
251
apps/api/src/services/recipe.service.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
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,
|
||||
};
|
||||
65
apps/api/src/services/reply-rate.service.test.ts
Normal file
65
apps/api/src/services/reply-rate.service.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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}%`);
|
||||
});
|
||||
});
|
||||
122
apps/api/src/services/reply-rate.service.ts
Normal file
122
apps/api/src/services/reply-rate.service.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
46
apps/api/src/services/spam-folder.service.ts
Normal file
46
apps/api/src/services/spam-folder.service.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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;
|
||||
}
|
||||
17
apps/api/src/services/spam-threshold.service.test.ts
Normal file
17
apps/api/src/services/spam-threshold.service.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
68
apps/api/src/services/spam-threshold.service.ts
Normal file
68
apps/api/src/services/spam-threshold.service.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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;
|
||||
}
|
||||
102
apps/api/src/services/stats.service.test.ts
Normal file
102
apps/api/src/services/stats.service.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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')));
|
||||
});
|
||||
});
|
||||
384
apps/api/src/services/stats.service.ts
Normal file
384
apps/api/src/services/stats.service.ts
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
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 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 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
34
apps/api/src/services/workspace.service.ts
Normal file
34
apps/api/src/services/workspace.service.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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;
|
||||
}
|
||||
60
apps/api/src/test/helpers/test-db.ts
Normal file
60
apps/api/src/test/helpers/test-db.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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 } });
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
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 } 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
118
apps/api/src/test/integration/recipes.integration.test.ts
Normal file
118
apps/api/src/test/integration/recipes.integration.test.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
19
apps/api/tsconfig.json
Normal file
19
apps/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"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"]
|
||||
}
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!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>
|
||||
35
apps/web/package.json
Normal file
35
apps/web/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
4
apps/web/public/vite.svg
Normal file
4
apps/web/public/vite.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 210 B |
36
apps/web/src/App.tsx
Normal file
36
apps/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/ComplianceChecklist.tsx
Normal file
68
apps/web/src/components/ComplianceChecklist.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/components/EmptyState.tsx
Normal file
27
apps/web/src/components/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/MetricCard.tsx
Normal file
48
apps/web/src/components/MetricCard.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/components/PageHeader.tsx
Normal file
24
apps/web/src/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
44
apps/web/src/components/StatusBadge.tsx
Normal file
44
apps/web/src/components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/components/layout/AppShell.tsx
Normal file
17
apps/web/src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/layout/BottomNav.tsx
Normal file
36
apps/web/src/components/layout/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/layout/Sidebar.tsx
Normal file
65
apps/web/src/components/layout/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
42
apps/web/src/components/ui/Alert.tsx
Normal file
42
apps/web/src/components/ui/Alert.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/ui/Badge.tsx
Normal file
28
apps/web/src/components/ui/Badge.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/ui/Button.tsx
Normal file
41
apps/web/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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';
|
||||
48
apps/web/src/components/ui/Card.tsx
Normal file
48
apps/web/src/components/ui/Card.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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';
|
||||
19
apps/web/src/components/ui/Input.tsx
Normal file
19
apps/web/src/components/ui/Input.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
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';
|
||||
13
apps/web/src/components/ui/Label.tsx
Normal file
13
apps/web/src/components/ui/Label.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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';
|
||||
21
apps/web/src/components/ui/Progress.tsx
Normal file
21
apps/web/src/components/ui/Progress.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
20
apps/web/src/components/ui/Select.tsx
Normal file
20
apps/web/src/components/ui/Select.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue