initial commit with working barebone prototype

This commit is contained in:
Jonny Nguyen 2026-06-24 21:42:18 -07:00
commit 3b540371cf
30 changed files with 5859 additions and 0 deletions

11
.env.example Normal file
View 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
View file

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.db
*.db-journal
src/generated/

314
README.md Normal file
View 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 45 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

File diff suppressed because it is too large Load diff

48
package.json Normal file
View 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
View 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'),
},
});

View 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");

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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';

View 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();
}

View 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
View 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
View 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;
};

View 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
View 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;

View 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
View 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;
}

View 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');
}

View 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 };
}

View 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
}
}
}

View 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
View 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"]
}