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