initial commit with working barebone prototype
This commit is contained in:
commit
3b540371cf
30 changed files with 5859 additions and 0 deletions
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
DATABASE_URL="file:./prisma/dev.db"
|
||||
ENCRYPTION_KEY="<64-char-hex>"
|
||||
|
||||
# Free AI via OpenRouter — sign up at https://openrouter.ai/keys
|
||||
AI_API_KEY="sk-or-v1-..."
|
||||
AI_BASE_URL="https://openrouter.ai/api/v1"
|
||||
AI_MODEL="openrouter/free"
|
||||
|
||||
API_KEY=""
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
src/generated/
|
||||
314
README.md
Normal file
314
README.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# Warmbox
|
||||
|
||||
Self-hosted email warmup and deliverability engine for custom domains. Uses SMTP to send realistic B2B conversations and IMAP to rescue messages from spam folders, flag them as important, and reply — improving sender reputation over time.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Node.js + TypeScript + Express
|
||||
- **Database:** SQLite via Prisma ORM
|
||||
- **Email:** nodemailer (SMTP), imapflow (IMAP)
|
||||
- **AI:** OpenRouter (free models) for conversation generation
|
||||
- **Scheduler:** node-cron
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate an encryption key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Set these in `.env`:
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------- | --------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | SQLite path, e.g. `file:./prisma/dev.db` (configured in `prisma.config.ts`) |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string (32 bytes) for AES-256-GCM password encryption |
|
||||
| `AI_API_KEY` | API key from [OpenRouter](https://openrouter.ai/keys) (free tier works) |
|
||||
| `AI_BASE_URL` | Default `https://openrouter.ai/api/v1` |
|
||||
| `AI_MODEL` | Default `openrouter/free` (auto-picks a free model) |
|
||||
| `API_KEY` | Optional API key for REST auth (`x-api-key` header) |
|
||||
| `PORT` | HTTP port (default `3000`) |
|
||||
|
||||
### Get a free AI API key (OpenRouter)
|
||||
|
||||
Email generation only needs a small, fast language model — not GPT-4 class reasoning.
|
||||
|
||||
1. Sign up at [openrouter.ai](https://openrouter.ai/)
|
||||
2. Go to [openrouter.ai/keys](https://openrouter.ai/keys) and create an API key
|
||||
3. Paste it into `.env` as `AI_API_KEY`
|
||||
|
||||
**Recommended free models** (set `AI_MODEL` in `.env`):
|
||||
|
||||
| Model | Notes |
|
||||
| --------------------------------------- | ---------------------------------------------- |
|
||||
| `openrouter/free` | Auto-selects an available free model (default) |
|
||||
| `google/gemma-2-9b-it:free` | Good quality, reliable JSON |
|
||||
| `meta-llama/llama-3.2-3b-instruct:free` | Fast, lightweight |
|
||||
|
||||
You do **not** need an OpenAI account.
|
||||
|
||||
### 3. Run database migrations
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### 4. Start the server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The API and cron jobs start together. Default schedules:
|
||||
|
||||
- **Dispatcher:** every 15 minutes — sends warmup emails
|
||||
- **Rescuer:** every 10 minutes — rescues spam, replies to inbox
|
||||
- **Ramp:** midnight daily — increases `currentRamp` per inbox
|
||||
|
||||
### 5. Add mailboxes
|
||||
|
||||
You need **at least 2 active inboxes** for warmup to work. Warmbox randomly pairs senders and receivers from your pool.
|
||||
|
||||
#### SMTP/IMAP settings by provider
|
||||
|
||||
Every inbox needs **4 connection fields** plus credentials:
|
||||
|
||||
| Field | What it is |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| `smtpHost` / `smtpPort` | Where to **send** mail |
|
||||
| `imapHost` / `imapPort` | Where to **read** mail (spam rescue + replies) |
|
||||
| `username` | Usually the full email address |
|
||||
| `password` | **App password** (not your login password) |
|
||||
|
||||
##### Gmail (including Google Workspace custom domains)
|
||||
|
||||
If `hello@fillcareapp.com` is hosted on **Google Workspace**, use Gmail servers:
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ---------------- |
|
||||
| `smtpHost` | `smtp.gmail.com` |
|
||||
| `smtpPort` | `587` |
|
||||
| `imapHost` | `imap.gmail.com` |
|
||||
| `imapPort` | `993` |
|
||||
|
||||
**App password setup:**
|
||||
|
||||
1. Enable 2-Step Verification on the Google account
|
||||
2. Go to [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords)
|
||||
3. Create an app password for "Mail" → copy the 16-character code
|
||||
4. Use that code as `password` in Warmbox
|
||||
|
||||
##### Yahoo Mail
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | --------------------- |
|
||||
| `smtpHost` | `smtp.mail.yahoo.com` |
|
||||
| `smtpPort` | `587` |
|
||||
| `imapHost` | `imap.mail.yahoo.com` |
|
||||
| `imapPort` | `993` |
|
||||
|
||||
App password: [Yahoo Account Security](https://login.yahoo.com/account/security) → Generate app password.
|
||||
|
||||
##### Outlook / Hotmail / Live.com (@live.com, @outlook.com, @hotmail.com)
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ----------------------- |
|
||||
| `smtpHost` | `smtp-mail.outlook.com` |
|
||||
| `smtpPort` | `587` |
|
||||
| `imapHost` | `outlook.office365.com` |
|
||||
| `imapPort` | `993` |
|
||||
|
||||
App password: [Microsoft Account Security](https://account.microsoft.com/security) → Advanced security → App passwords. IMAP must be enabled in Outlook settings.
|
||||
|
||||
##### Custom domain (cPanel / generic hosting)
|
||||
|
||||
If `hello@fillcareapp.com` is on cPanel or similar (not Google/Microsoft), check your host's email docs. Common pattern:
|
||||
|
||||
| Field | Value |
|
||||
| ---------- | ------------------------------------------------ |
|
||||
| `smtpHost` | `mail.fillcareapp.com` or `smtp.fillcareapp.com` |
|
||||
| `smtpPort` | `587` (or `465` for SSL) |
|
||||
| `imapHost` | `mail.fillcareapp.com` or `imap.fillcareapp.com` |
|
||||
| `imapPort` | `993` |
|
||||
|
||||
Ask your DNS/hosting provider if unsure — look for "IMAP/SMTP settings" in cPanel → Email Accounts.
|
||||
|
||||
#### Example: FillCare + personal accounts
|
||||
|
||||
Add `hello@fillcareapp.com` as your primary warmup domain (industry: `Childcare`), then add 4–5 Gmail/Yahoo/Live accounts as the reply pool.
|
||||
|
||||
**1. Primary domain (FillCare)**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/inboxes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "hello@fillcareapp.com",
|
||||
"smtpHost": "smtp.gmail.com",
|
||||
"smtpPort": 587,
|
||||
"imapHost": "imap.gmail.com",
|
||||
"imapPort": 993,
|
||||
"username": "hello@fillcareapp.com",
|
||||
"password": "hkem mfmi bkmg bqyw",
|
||||
"industry": "Childcare",
|
||||
"dailyLimit": 40,
|
||||
"currentRamp": 5
|
||||
}'
|
||||
```
|
||||
|
||||
> If FillCare email is **not** on Google Workspace, swap `smtpHost`/`imapHost` to your host's values (see table above).
|
||||
|
||||
**2. Gmail account**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/inboxes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "you@gmail.com",
|
||||
"smtpHost": "smtp.gmail.com",
|
||||
"smtpPort": 587,
|
||||
"imapHost": "imap.gmail.com",
|
||||
"imapPort": 993,
|
||||
"username": "you@gmail.com",
|
||||
"password": "your-gmail-app-password",
|
||||
"industry": "SaaS"
|
||||
}'
|
||||
```
|
||||
|
||||
**3. Yahoo account**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/inboxes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "you@yahoo.com",
|
||||
"smtpHost": "smtp.mail.yahoo.com",
|
||||
"smtpPort": 587,
|
||||
"imapHost": "imap.mail.yahoo.com",
|
||||
"imapPort": 993,
|
||||
"username": "you@yahoo.com",
|
||||
"password": "your-yahoo-app-password",
|
||||
"industry": "Marketing"
|
||||
}'
|
||||
```
|
||||
|
||||
**4. Live.com / Outlook account**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/inboxes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "you@live.com",
|
||||
"smtpHost": "smtp-mail.outlook.com",
|
||||
"smtpPort": 587,
|
||||
"imapHost": "outlook.office365.com",
|
||||
"imapPort": 993,
|
||||
"username": "you@live.com",
|
||||
"password": "your-microsoft-app-password",
|
||||
"industry": "Finance"
|
||||
}'
|
||||
```
|
||||
|
||||
Repeat for each additional Gmail/Yahoo/Live account. Mixing providers is fine — Warmbox only needs them all in the same pool.
|
||||
|
||||
### 6. Test connections
|
||||
|
||||
After adding each inbox, verify SMTP + IMAP:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/inboxes/{id}/test
|
||||
```
|
||||
|
||||
A successful response looks like:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"smtp": { "ok": true },
|
||||
"imap": { "ok": true }
|
||||
}
|
||||
```
|
||||
|
||||
If either fails, double-check the app password and that IMAP is enabled on the account.
|
||||
|
||||
### 7. Monitor logs
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/logs
|
||||
```
|
||||
|
||||
Watch for status progression: `sent` → `rescued_from_spam` → `replied` → `complete`.
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints under `/api` accept optional `x-api-key` header when `API_KEY` is set.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------- | --------------------------------------------------- |
|
||||
| `POST` | `/api/inboxes` | Create inbox |
|
||||
| `GET` | `/api/inboxes` | List inboxes |
|
||||
| `GET` | `/api/inboxes/:id` | Get inbox |
|
||||
| `PATCH` | `/api/inboxes/:id` | Update inbox |
|
||||
| `DELETE` | `/api/inboxes/:id` | Delete inbox |
|
||||
| `POST` | `/api/inboxes/:id/test` | Test SMTP + IMAP |
|
||||
| `GET` | `/api/logs` | List warmup logs (`?status=&inboxId=&page=&limit=`) |
|
||||
| `GET` | `/api/settings` | Get settings |
|
||||
| `PATCH` | `/api/settings` | Update settings |
|
||||
| `GET` | `/health` | Health check |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── config.ts # Environment validation
|
||||
├── index.ts # Express + scheduler bootstrap
|
||||
├── jobs/
|
||||
│ ├── dispatcher.job.ts # Sends warmup emails
|
||||
│ ├── rescuer.job.ts # Spam rescue + replies
|
||||
│ └── scheduler.ts # Cron wiring
|
||||
├── lib/ # Prisma, logger, inbox guard
|
||||
├── middleware/ # Auth, error handling
|
||||
├── models/ # Enums, types
|
||||
├── routes/ # REST endpoints
|
||||
└── services/
|
||||
├── ai.service.ts # OpenRouter conversation generator
|
||||
├── crypto.service.ts # AES-256-GCM encryption
|
||||
├── email.service.ts # SMTP via nodemailer
|
||||
├── imap.service.ts # IMAP via imapflow
|
||||
└── settings.service.ts
|
||||
prisma/
|
||||
└── schema.prisma # Database schema
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Dispatcher** picks random sender/receiver pairs from active inboxes, generates a B2B email via AI, and sends via SMTP.
|
||||
2. **Rescuer** scans spam folders (`[Gmail]/Spam`, `Junk`, `Spam`) for warmup emails, moves them to INBOX, flags them as read/starred/important.
|
||||
3. **Rescuer** also processes unread warmup emails already in INBOX, generates contextual AI replies, and sends them back.
|
||||
4. Failed IMAP/SMTP connections mark the inbox as `error` and skip it until credentials are fixed.
|
||||
|
||||
## Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
Use a process manager (systemd, PM2) and back up the SQLite database file regularly.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
4082
package-lock.json
generated
Normal file
4082
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
48
package.json
Normal file
48
package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "warmbox",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted email warmup and deliverability engine",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:reset": "prisma migrate reset"
|
||||
},
|
||||
"keywords": [
|
||||
"email",
|
||||
"warmup",
|
||||
"deliverability",
|
||||
"imap",
|
||||
"smtp"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.21.2",
|
||||
"imapflow": "^1.0.189",
|
||||
"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/express": "^4.17.21",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
12
prisma.config.ts
Normal file
12
prisma.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig, env } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
url: env('DATABASE_URL'),
|
||||
},
|
||||
});
|
||||
63
prisma/migrations/20260620062940_init/migration.sql
Normal file
63
prisma/migrations/20260620062940_init/migration.sql
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Inbox" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"email" TEXT NOT NULL,
|
||||
"smtpHost" TEXT NOT NULL,
|
||||
"smtpPort" INTEGER NOT NULL,
|
||||
"imapHost" TEXT NOT NULL,
|
||||
"imapPort" INTEGER NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"dailyLimit" INTEGER NOT NULL DEFAULT 40,
|
||||
"currentRamp" INTEGER NOT NULL DEFAULT 5,
|
||||
"industry" TEXT NOT NULL DEFAULT 'SaaS',
|
||||
"lastError" TEXT,
|
||||
"errorAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WarmupLog" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"senderId" TEXT NOT NULL,
|
||||
"receiverId" TEXT NOT NULL,
|
||||
"messageId" TEXT,
|
||||
"inReplyTo" TEXT,
|
||||
"subject" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'sent',
|
||||
"rescuedFromSpam" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "WarmupLog_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "Inbox" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "WarmupLog_receiverId_fkey" FOREIGN KEY ("receiverId") REFERENCES "Inbox" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Setting" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Inbox_email_key" ON "Inbox"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Inbox_status_idx" ON "Inbox"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WarmupLog_senderId_status_idx" ON "WarmupLog"("senderId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WarmupLog_receiverId_status_idx" ON "WarmupLog"("receiverId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WarmupLog_messageId_idx" ON "WarmupLog"("messageId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
74
prisma/schema.prisma
Normal file
74
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
enum InboxStatus {
|
||||
active
|
||||
paused
|
||||
error
|
||||
}
|
||||
|
||||
enum WarmupStatus {
|
||||
sent
|
||||
rescued_from_spam
|
||||
replied
|
||||
complete
|
||||
}
|
||||
|
||||
model Inbox {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
smtpHost String
|
||||
smtpPort Int
|
||||
imapHost String
|
||||
imapPort Int
|
||||
username String
|
||||
password String
|
||||
status InboxStatus @default(active)
|
||||
dailyLimit Int @default(40)
|
||||
currentRamp Int @default(5)
|
||||
industry String @default("SaaS")
|
||||
lastError String?
|
||||
errorAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sentLogs WarmupLog[] @relation("Sender")
|
||||
receivedLogs WarmupLog[] @relation("Receiver")
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model WarmupLog {
|
||||
id String @id @default(cuid())
|
||||
senderId String
|
||||
receiverId String
|
||||
messageId String?
|
||||
inReplyTo String?
|
||||
subject String
|
||||
body String
|
||||
status WarmupStatus @default(sent)
|
||||
rescuedFromSpam Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sender Inbox @relation("Sender", fields: [senderId], references: [id], onDelete: Cascade)
|
||||
receiver Inbox @relation("Receiver", fields: [receiverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([senderId, status])
|
||||
@@index([receiverId, status])
|
||||
@@index([messageId])
|
||||
}
|
||||
|
||||
model Setting {
|
||||
id String @id @default(cuid())
|
||||
key String @unique
|
||||
value String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
31
src/config.ts
Normal file
31
src/config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
ENCRYPTION_KEY: z.string().length(64, 'ENCRYPTION_KEY must be 64 hex characters (32 bytes)'),
|
||||
AI_API_KEY: z.string().optional(),
|
||||
AI_BASE_URL: z.string().url().default('https://openrouter.ai/api/v1'),
|
||||
AI_MODEL: z.string().default('openrouter/free'),
|
||||
// Legacy alias — still accepted
|
||||
OPENAI_API_KEY: z.string().optional(),
|
||||
API_KEY: z.string().optional(),
|
||||
PORT: z.coerce.number().default(3000),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment variables:', parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
export const config = {
|
||||
...data,
|
||||
AI_API_KEY: data.AI_API_KEY ?? data.OPENAI_API_KEY,
|
||||
};
|
||||
37
src/index.ts
Normal file
37
src/index.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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 logsRouter from './routes/logs.routes';
|
||||
import settingsRouter from './routes/settings.routes';
|
||||
import { startScheduler } from './jobs/scheduler';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/api', apiKeyAuth);
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.use('/api/inboxes', inboxesRouter);
|
||||
app.use('/api/logs', logsRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await startScheduler();
|
||||
|
||||
app.listen(config.PORT, () => {
|
||||
logger.info({ port: config.PORT }, 'Warmbox server started');
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Failed to start server');
|
||||
process.exit(1);
|
||||
});
|
||||
106
src/jobs/dispatcher.job.ts
Normal file
106
src/jobs/dispatcher.job.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Inbox } from '../generated/prisma/client';
|
||||
import { InboxStatus, WarmupStatus, prisma } from '../lib/prisma';
|
||||
import { withInboxGuard } from '../lib/inboxGuard';
|
||||
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';
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function pickRandom<T>(items: T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)]!;
|
||||
}
|
||||
|
||||
async function getTodaySendCount(senderId: string): Promise<number> {
|
||||
return prisma.warmupLog.count({
|
||||
where: {
|
||||
senderId,
|
||||
createdAt: { gte: startOfToday() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function bumpDailyRamp(): Promise<void> {
|
||||
const increment = parseInt(await getSettingOrDefault('ramp_increment'), 10) || 2;
|
||||
const inboxes = await prisma.inbox.findMany({ where: { status: InboxStatus.active } });
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox.currentRamp < inbox.dailyLimit) {
|
||||
await prisma.inbox.update({
|
||||
where: { id: inbox.id },
|
||||
data: { currentRamp: Math.min(inbox.currentRamp + increment, inbox.dailyLimit) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastRampDate: string | null = null;
|
||||
|
||||
async function maybeBumpRamp(): Promise<void> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (lastRampDate !== today) {
|
||||
lastRampDate = today;
|
||||
await bumpDailyRamp();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDispatcherJob(): Promise<void> {
|
||||
logger.info('Dispatcher job started');
|
||||
await maybeBumpRamp();
|
||||
|
||||
const inboxes: Inbox[] = await prisma.inbox.findMany({
|
||||
where: { status: InboxStatus.active },
|
||||
});
|
||||
|
||||
if (inboxes.length < 2) {
|
||||
logger.warn('Dispatcher skipped: need at least 2 active inboxes');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sender of inboxes) {
|
||||
try {
|
||||
const sentToday = await getTodaySendCount(sender.id);
|
||||
if (sentToday >= sender.currentRamp) {
|
||||
logger.debug({ inboxId: sender.id, sentToday }, 'Sender at daily ramp limit');
|
||||
continue;
|
||||
}
|
||||
|
||||
const receivers = inboxes.filter((i) => i.id !== sender.id);
|
||||
const receiver = pickRandom(receivers);
|
||||
|
||||
await withInboxGuard(sender, async () => {
|
||||
const { subject, body } = await aiService.generateInitialEmail(sender.industry);
|
||||
const { messageId } = await emailService.sendMail(sender, {
|
||||
to: receiver.email,
|
||||
subject,
|
||||
body,
|
||||
});
|
||||
|
||||
await prisma.warmupLog.create({
|
||||
data: {
|
||||
senderId: sender.id,
|
||||
receiverId: receiver.id,
|
||||
messageId,
|
||||
subject,
|
||||
body,
|
||||
status: WarmupStatus.sent,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ sender: sender.email, receiver: receiver.email, messageId },
|
||||
'Warmup email dispatched',
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, inboxId: sender.id }, 'Dispatcher failed for inbox');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Dispatcher job finished');
|
||||
}
|
||||
216
src/jobs/rescuer.job.ts
Normal file
216
src/jobs/rescuer.job.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { simpleParser } from 'mailparser';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
import type { Inbox, WarmupLog } from '../generated/prisma/client';
|
||||
import { InboxStatus, WarmupStatus } from '../lib/prisma';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { withInboxGuard } from '../lib/inboxGuard';
|
||||
import { logger } from '../lib/logger';
|
||||
import { SPAM_FOLDERS } from '../models/enums';
|
||||
import * as aiService from '../services/ai.service';
|
||||
import * as emailService from '../services/email.service';
|
||||
import { withImapClient } from '../services/imap.service';
|
||||
|
||||
type PendingLog = WarmupLog & { sender: Inbox };
|
||||
|
||||
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 processMessage(
|
||||
receiver: Inbox,
|
||||
uid: number,
|
||||
client: ImapFlow,
|
||||
pendingLogs: PendingLog[],
|
||||
): 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;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const incomingBody = parsed.text ?? parsed.html?.toString() ?? '';
|
||||
const { subject: replySubject, body: replyBody } = await aiService.generateReply(
|
||||
receiver.industry,
|
||||
incomingBody,
|
||||
log.subject,
|
||||
);
|
||||
|
||||
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.update({
|
||||
where: { id: log.id },
|
||||
data: { status: WarmupStatus.replied, inReplyTo: replyMessageId },
|
||||
});
|
||||
|
||||
await prisma.warmupLog.update({
|
||||
where: { id: log.id },
|
||||
data: { status: WarmupStatus.complete },
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{ receiver: receiver.email, sender: log.sender.email, logId: log.id },
|
||||
'Warmup reply sent',
|
||||
);
|
||||
});
|
||||
|
||||
// Remove from pending so we don't process twice
|
||||
const idx = pendingLogs.findIndex((l) => l.id === log.id);
|
||||
if (idx >= 0) pendingLogs.splice(idx, 1);
|
||||
}
|
||||
|
||||
async function rescueFromSpam(
|
||||
receiver: Inbox,
|
||||
client: ImapFlow,
|
||||
pendingLogs: PendingLog[],
|
||||
): Promise<void> {
|
||||
const senderEmails = [...new Set(pendingLogs.map((l) => l.sender.email))];
|
||||
|
||||
for (const folder of SPAM_FOLDERS) {
|
||||
try {
|
||||
await client.mailboxOpen(folder);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const senderEmail of senderEmails) {
|
||||
const uids = await client.search({ seen: false, from: senderEmail }, { uid: true });
|
||||
if (!uids || uids.length === 0) continue;
|
||||
|
||||
await client.messageMove(uids, 'INBOX', { uid: true });
|
||||
|
||||
const matchingLogs = pendingLogs.filter(
|
||||
(l) => l.sender.email.toLowerCase() === senderEmail.toLowerCase(),
|
||||
);
|
||||
|
||||
for (const log of matchingLogs) {
|
||||
await prisma.warmupLog.update({
|
||||
where: { id: log.id },
|
||||
data: {
|
||||
status: WarmupStatus.rescued_from_spam,
|
||||
rescuedFromSpam: true,
|
||||
},
|
||||
});
|
||||
log.status = WarmupStatus.rescued_from_spam;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ receiver: receiver.email, folder, count: uids.length, senderEmail },
|
||||
'Rescued emails from spam',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processInbox(receiver: Inbox): Promise<void> {
|
||||
const pendingLogs = (await prisma.warmupLog.findMany({
|
||||
where: {
|
||||
receiverId: receiver.id,
|
||||
status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] },
|
||||
},
|
||||
include: { sender: true },
|
||||
})) as PendingLog[];
|
||||
|
||||
if (pendingLogs.length === 0) return;
|
||||
|
||||
await withInboxGuard(receiver, async () => {
|
||||
await withImapClient(receiver, async (client) => {
|
||||
await rescueFromSpam(receiver, client, pendingLogs);
|
||||
|
||||
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 {
|
||||
await processMessage(receiver, uid, client, pendingLogs);
|
||||
} catch (err) {
|
||||
logger.error({ err, receiverId: receiver.id, uid }, 'Failed to process message');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRescuerJob(): Promise<void> {
|
||||
logger.info('Rescuer job started');
|
||||
|
||||
const inboxes = await prisma.inbox.findMany({
|
||||
where: { status: InboxStatus.active },
|
||||
});
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
try {
|
||||
await processInbox(inbox);
|
||||
} catch (err) {
|
||||
logger.error({ err, inboxId: inbox.id }, 'Rescuer failed for inbox');
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Rescuer job finished');
|
||||
}
|
||||
43
src/jobs/scheduler.ts
Normal file
43
src/jobs/scheduler.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import cron from 'node-cron';
|
||||
import { logger } from '../lib/logger';
|
||||
import { seedDefaultSettings, getSettingOrDefault } from '../services/settings.service';
|
||||
import { runDispatcherJob } from './dispatcher.job';
|
||||
import { runRescuerJob } from './rescuer.job';
|
||||
|
||||
let dispatcherTask: cron.ScheduledTask | null = null;
|
||||
let rescuerTask: cron.ScheduledTask | null = null;
|
||||
let rampTask: cron.ScheduledTask | null = null;
|
||||
|
||||
async function scheduleJobs(): Promise<void> {
|
||||
if (dispatcherTask) dispatcherTask.stop();
|
||||
if (rescuerTask) rescuerTask.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'));
|
||||
});
|
||||
|
||||
rescuerTask = cron.schedule(rescuerCron, () => {
|
||||
runRescuerJob().catch((err) => logger.error({ err }, 'Rescuer cron error'));
|
||||
});
|
||||
|
||||
rampTask = cron.schedule('0 0 * * *', () => {
|
||||
import('./dispatcher.job').then(({ bumpDailyRamp }) =>
|
||||
bumpDailyRamp().catch((err) => logger.error({ err }, 'Ramp cron error')),
|
||||
);
|
||||
});
|
||||
|
||||
logger.info({ dispatcherCron, rescuerCron }, 'Cron jobs scheduled');
|
||||
}
|
||||
|
||||
export async function startScheduler(): Promise<void> {
|
||||
await seedDefaultSettings();
|
||||
await scheduleJobs();
|
||||
}
|
||||
|
||||
export async function reloadScheduler(): Promise<void> {
|
||||
await scheduleJobs();
|
||||
}
|
||||
39
src/lib/inboxGuard.ts
Normal file
39
src/lib/inboxGuard.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { Inbox } from '../lib/prisma';
|
||||
import { InboxStatus } from '../lib/prisma';
|
||||
import { prisma } from '../lib/prisma';
|
||||
|
||||
export async function withInboxGuard<T>(
|
||||
inbox: Inbox,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await fn();
|
||||
|
||||
if (inbox.status === InboxStatus.error) {
|
||||
await prisma.inbox.update({
|
||||
where: { id: inbox.id },
|
||||
data: { status: InboxStatus.active, lastError: null, errorAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
await prisma.inbox.update({
|
||||
where: { id: inbox.id },
|
||||
data: {
|
||||
status: InboxStatus.error,
|
||||
lastError: message,
|
||||
errorAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function stripPassword<T extends { password?: string }>(
|
||||
inbox: T,
|
||||
): Omit<T, 'password'> {
|
||||
const { password: _, ...rest } = inbox;
|
||||
return rest;
|
||||
}
|
||||
9
src/lib/logger.ts
Normal file
9
src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import pino from 'pino';
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino/file', options: { destination: 1 } }
|
||||
: undefined,
|
||||
});
|
||||
26
src/lib/prisma.ts
Normal file
26
src/lib/prisma.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
|
||||
import { PrismaClient } from '../generated/prisma/client';
|
||||
import { config } from '../config';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
function createPrismaClient(): PrismaClient {
|
||||
const adapter = new PrismaBetterSqlite3({
|
||||
url: config.DATABASE_URL,
|
||||
});
|
||||
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: config.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma: PrismaClient = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (config.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export { PrismaClient } from '../generated/prisma/client';
|
||||
export type { Inbox, WarmupLog, Setting } from '../generated/prisma/client';
|
||||
export { InboxStatus, WarmupStatus } from '../generated/prisma/client';
|
||||
18
src/middleware/apiKeyAuth.ts
Normal file
18
src/middleware/apiKeyAuth.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { config } from '../config';
|
||||
import { AppError } from './errorHandler';
|
||||
|
||||
export function apiKeyAuth(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (!config.API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = req.headers['x-api-key'];
|
||||
if (key !== config.API_KEY) {
|
||||
next(new AppError(401, 'Unauthorized'));
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
32
src/middleware/errorHandler.ts
Normal file
32
src/middleware/errorHandler.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.statusCode).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({ error: err.errors.map((e) => e.message).join(', ') });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
12
src/models/enums.ts
Normal file
12
src/models/enums.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { InboxStatus, WarmupStatus } from '../lib/prisma';
|
||||
|
||||
export const SPAM_FOLDERS = ['[Gmail]/Spam', 'Junk', 'Spam'] as const;
|
||||
|
||||
export const DEFAULT_INDUSTRIES = ['SaaS', 'Marketing', 'Finance', 'Childcare'] as const;
|
||||
|
||||
export const DEFAULT_SETTINGS = {
|
||||
dispatcher_cron: '*/15 * * * *',
|
||||
rescuer_cron: '*/10 * * * *',
|
||||
ramp_increment: '2',
|
||||
industries: JSON.stringify(DEFAULT_INDUSTRIES),
|
||||
} as const;
|
||||
26
src/models/types.ts
Normal file
26
src/models/types.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Inbox, WarmupLog } from '../lib/prisma';
|
||||
|
||||
export type InboxPublic = Omit<Inbox, 'password'>;
|
||||
|
||||
export type WarmupLogWithRelations = WarmupLog & {
|
||||
sender: InboxPublic;
|
||||
receiver: InboxPublic;
|
||||
};
|
||||
|
||||
export type ConnectionTestResult = {
|
||||
smtp: { ok: boolean; error?: string };
|
||||
imap: { ok: boolean; error?: string };
|
||||
};
|
||||
|
||||
export type EmailPayload = {
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
inReplyTo?: string;
|
||||
references?: string;
|
||||
};
|
||||
|
||||
export type GeneratedEmail = {
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
142
src/routes/inboxes.routes.ts
Normal file
142
src/routes/inboxes.routes.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { InboxStatus } from '../lib/prisma';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { encrypt } from '../services/crypto.service';
|
||||
import { testConnection } from '../services/email.service';
|
||||
import { stripPassword } from '../lib/inboxGuard';
|
||||
import { AppError } from '../middleware/errorHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const createInboxSchema = z.object({
|
||||
email: z.string().email(),
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.coerce.number().int().positive(),
|
||||
imapHost: z.string().min(1),
|
||||
imapPort: z.coerce.number().int().positive(),
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
dailyLimit: z.coerce.number().int().positive().optional(),
|
||||
currentRamp: z.coerce.number().int().positive().optional(),
|
||||
industry: z.string().min(1).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().min(1).optional(),
|
||||
dailyLimit: z.coerce.number().int().positive().optional(),
|
||||
currentRamp: z.coerce.number().int().positive().optional(),
|
||||
industry: z.string().min(1).optional(),
|
||||
status: z.nativeEnum(InboxStatus).optional(),
|
||||
});
|
||||
|
||||
router.post('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createInboxSchema.parse(req.body);
|
||||
const inbox = await prisma.inbox.create({
|
||||
data: {
|
||||
...data,
|
||||
password: encrypt(data.password),
|
||||
dailyLimit: data.dailyLimit ?? 40,
|
||||
currentRamp: data.currentRamp ?? 5,
|
||||
industry: data.industry ?? 'SaaS',
|
||||
status: data.status ?? InboxStatus.active,
|
||||
},
|
||||
});
|
||||
res.status(201).json(stripPassword(inbox));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const inboxes = await prisma.inbox.findMany({ 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, ...rest } = data;
|
||||
|
||||
const inbox = await prisma.inbox.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
...rest,
|
||||
...(password ? { password: encrypt(password) } : {}),
|
||||
...(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/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;
|
||||
58
src/routes/logs.routes.ts
Normal file
58
src/routes/logs.routes.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const querySchema = z.object({
|
||||
status: z.nativeEnum(WarmupStatus).optional(),
|
||||
inboxId: 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 skip = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
...(status ? { status } : {}),
|
||||
...(inboxId
|
||||
? { OR: [{ senderId: inboxId }, { receiverId: inboxId }] }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [logs, total] = await Promise.all([
|
||||
prisma.warmupLog.findMany({
|
||||
where,
|
||||
include: { sender: true, receiver: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.warmupLog.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
data: logs.map((log: WarmupLogWithRelations) => ({
|
||||
...log,
|
||||
sender: stripPassword(log.sender),
|
||||
receiver: stripPassword(log.receiver),
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
51
src/routes/settings.routes.ts
Normal file
51
src/routes/settings.routes.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getSettingsMap, upsertSetting } from '../services/settings.service';
|
||||
import { reloadScheduler } from '../jobs/scheduler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const updateSettingsSchema = z.object({
|
||||
dispatcher_cron: z.string().min(1).optional(),
|
||||
rescuer_cron: z.string().min(1).optional(),
|
||||
ramp_increment: z.string().optional(),
|
||||
industries: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
ai_api_key: z.string().optional(),
|
||||
ai_model: z.string().optional(),
|
||||
openai_api_key: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const settings = await getSettingsMap();
|
||||
const { openai_api_key: _o, ai_api_key: _a, ...safe } = settings;
|
||||
res.json(safe);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = updateSettingsSchema.parse(req.body);
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value === undefined) continue;
|
||||
const stored =
|
||||
key === 'industries' && Array.isArray(value) ? JSON.stringify(value) : String(value);
|
||||
await upsertSetting(key, stored);
|
||||
}
|
||||
|
||||
if (data.dispatcher_cron || data.rescuer_cron) {
|
||||
await reloadScheduler();
|
||||
}
|
||||
|
||||
const settings = await getSettingsMap();
|
||||
const { openai_api_key: _o, ai_api_key: _a, ...safe } = settings;
|
||||
res.json(safe);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
165
src/services/ai.service.ts
Normal file
165
src/services/ai.service.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import OpenAI from 'openai';
|
||||
import { config } from '../config';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { GeneratedEmail } from '../models/types';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const DEFAULT_MODEL = 'openrouter/free';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Backward compatibility
|
||||
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() };
|
||||
}
|
||||
|
||||
async function chatWithRetry(
|
||||
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, 2000));
|
||||
|
||||
try {
|
||||
return await request(false);
|
||||
} catch (retryErr) {
|
||||
logger.warn({ err: retryErr, model }, 'AI retry failed');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return request(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateInitialEmail(industry: string): Promise<GeneratedEmail> {
|
||||
const client = await createClient();
|
||||
const model = await getModel();
|
||||
|
||||
return chatWithRetry(client, model, [
|
||||
{
|
||||
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.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Generate a new outbound business email.',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export async function generateReply(
|
||||
industry: string,
|
||||
incomingBody: string,
|
||||
threadSubject: string,
|
||||
): Promise<GeneratedEmail> {
|
||||
const client = await createClient();
|
||||
const model = await getModel();
|
||||
|
||||
const result = await chatWithRetry(client, model, [
|
||||
{
|
||||
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: ".`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Thread subject: ${threadSubject}\n\nIncoming message:\n${incomingBody}`,
|
||||
},
|
||||
]);
|
||||
|
||||
if (!result.subject.toLowerCase().startsWith('re:')) {
|
||||
result.subject = `Re: ${threadSubject.replace(/^Re:\s*/i, '')}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
32
src/services/crypto.service.ts
Normal file
32
src/services/crypto.service.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
||||
import { config } from '../config';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
|
||||
function getKey(): Buffer {
|
||||
return Buffer.from(config.ENCRYPTION_KEY, 'hex');
|
||||
}
|
||||
|
||||
export function encrypt(plain: string): string {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const [ivB64, tagB64, dataB64] = ciphertext.split(':');
|
||||
if (!ivB64 || !tagB64 || !dataB64) {
|
||||
throw new Error('Invalid encrypted password format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const tag = Buffer.from(tagB64, 'base64');
|
||||
const data = Buffer.from(dataB64, 'base64');
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
76
src/services/email.service.ts
Normal file
76
src/services/email.service.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import nodemailer, { Transporter } from 'nodemailer';
|
||||
import type { Inbox } from '../lib/prisma';
|
||||
import { decrypt } from './crypto.service';
|
||||
import { EmailPayload } from '../models/types';
|
||||
|
||||
function getPassword(inbox: Inbox): string {
|
||||
return decrypt(inbox.password);
|
||||
}
|
||||
|
||||
export function createSmtpTransport(inbox: Inbox): Transporter {
|
||||
const secure = inbox.smtpPort === 465;
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: inbox.smtpHost,
|
||||
port: inbox.smtpPort,
|
||||
secure,
|
||||
requireTLS: !secure && inbox.smtpPort === 587,
|
||||
auth: {
|
||||
user: inbox.username,
|
||||
pass: getPassword(inbox),
|
||||
},
|
||||
connectionTimeout: 30_000,
|
||||
greetingTimeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testSmtp(
|
||||
inbox: Inbox,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const transporter = createSmtpTransport(inbox);
|
||||
try {
|
||||
await transporter.verify();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'SMTP verification failed',
|
||||
};
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMail(
|
||||
inbox: Inbox,
|
||||
payload: EmailPayload,
|
||||
): Promise<{ messageId: string }> {
|
||||
const transporter = createSmtpTransport(inbox);
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({
|
||||
from: inbox.email,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
text: payload.body,
|
||||
inReplyTo: payload.inReplyTo,
|
||||
references: payload.references,
|
||||
});
|
||||
|
||||
const messageId = info.messageId ?? `<${Date.now()}@${inbox.email.split('@')[1]}>`;
|
||||
return { messageId };
|
||||
} finally {
|
||||
transporter.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function testConnection(
|
||||
inbox: Inbox,
|
||||
): Promise<{
|
||||
smtp: { ok: boolean; error?: string };
|
||||
imap: { ok: boolean; error?: string };
|
||||
}> {
|
||||
const { testImap } = await import('./imap.service');
|
||||
const [smtp, imap] = await Promise.all([testSmtp(inbox), testImap(inbox)]);
|
||||
return { smtp, imap };
|
||||
}
|
||||
65
src/services/imap.service.ts
Normal file
65
src/services/imap.service.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { ImapFlow } from 'imapflow';
|
||||
import type { Inbox } from '../lib/prisma';
|
||||
import { decrypt } from './crypto.service';
|
||||
|
||||
const CONNECTION_TIMEOUT = 30_000;
|
||||
|
||||
function getPassword(inbox: Inbox): string {
|
||||
return decrypt(inbox.password);
|
||||
}
|
||||
|
||||
export function createImapClient(inbox: Inbox): ImapFlow {
|
||||
const secure = inbox.imapPort === 993;
|
||||
|
||||
return new ImapFlow({
|
||||
host: inbox.imapHost,
|
||||
port: inbox.imapPort,
|
||||
secure,
|
||||
auth: {
|
||||
user: inbox.username,
|
||||
pass: getPassword(inbox),
|
||||
},
|
||||
logger: false,
|
||||
connectionTimeout: CONNECTION_TIMEOUT,
|
||||
greetingTimeout: CONNECTION_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testImap(
|
||||
inbox: Inbox,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const client = createImapClient(inbox);
|
||||
try {
|
||||
await client.connect();
|
||||
await client.mailboxOpen('INBOX');
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'IMAP connection failed',
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
// ignore logout errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function withImapClient<T>(
|
||||
inbox: Inbox,
|
||||
fn: (client: ImapFlow) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = createImapClient(inbox);
|
||||
await client.connect();
|
||||
try {
|
||||
return await fn(client);
|
||||
} finally {
|
||||
try {
|
||||
await client.logout();
|
||||
} catch {
|
||||
// ignore logout errors
|
||||
}
|
||||
}
|
||||
}
|
||||
43
src/services/settings.service.ts
Normal file
43
src/services/settings.service.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { prisma } from '../lib/prisma';
|
||||
import { DEFAULT_SETTINGS } from '../models/enums';
|
||||
|
||||
export async function getSetting(key: string): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function getSettingOrDefault(
|
||||
key: keyof typeof DEFAULT_SETTINGS,
|
||||
): Promise<string> {
|
||||
const value = await getSetting(key);
|
||||
return value ?? DEFAULT_SETTINGS[key];
|
||||
}
|
||||
|
||||
export async function getSettingsMap(): Promise<Record<string, string>> {
|
||||
const rows = await prisma.setting.findMany();
|
||||
const map: Record<string, string> = { ...DEFAULT_SETTINGS };
|
||||
|
||||
for (const row of rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function upsertSetting(key: string, value: string): Promise<void> {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function seedDefaultSettings(): Promise<void> {
|
||||
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
19
tsconfig.json
Normal file
19
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"]
|
||||
}
|
||||
Loading…
Reference in a new issue