[C28-28][@ant] chore(email-relay): вынести релей из mono в C9S/email-relay
Релей переехал в отдельный репозиторий C9S/email-relay, собирается локально плейбуком (playbooks/email-relay) — релиз mono его больше не пересобирает. Прод-образ mono-base (prune --prod) не подходил для standalone-сервиса. Удалено: components/email-relay, build_service в release.yaml, dev-сервис в docker-compose. Контроллерный relay-режим (EMAIL_RELAY_URL/TOKEN в email-channel.adapter + config) ОСТАЁТСЯ — он и активирует отправку через релей. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -322,7 +322,6 @@ jobs:
|
||||
# из отдельного coopenomics/parser repo.
|
||||
build_service cooparser '@coopenomics/parser' start
|
||||
build_service notificator 'coop-notificator' start
|
||||
build_service email-relay '@coopenomics/email-relay' start
|
||||
build_service notifications '@coopenomics/notifications' sync
|
||||
|
||||
# === Этап 3: webhook деплоя ===
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Порт HTTP-сервера релея (за ним nginx+TLS)
|
||||
PORT=3025
|
||||
|
||||
# Общий секрет. Коопы шлют Authorization: Bearer <RELAY_TOKEN>.
|
||||
# Сгенерировать: openssl rand -hex 32
|
||||
RELAY_TOKEN=
|
||||
|
||||
# SMTP реального почтового сервера (порты должны быть открыты на этом хосте)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
# secure: пусто = авто (true для 465, иначе STARTTLS). Можно явно true/false.
|
||||
SMTP_SECURE=
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
|
||||
# from по умолчанию, если запрос его не прислал
|
||||
EMAIL_FROM_DEFAULT=
|
||||
|
||||
# Опционально: allowlist IP коопов через запятую (defense-in-depth за TLS)
|
||||
RELAY_IP_ALLOWLIST=
|
||||
|
||||
# Опционально: лимит тела запроса
|
||||
RELAY_BODY_LIMIT=1mb
|
||||
@@ -1,56 +0,0 @@
|
||||
# @coopenomics/email-relay
|
||||
|
||||
Минимальный **HTTP→SMTP релей**. Принимает письма по HTTPS с Bearer-токеном и
|
||||
отправляет их по SMTP.
|
||||
|
||||
## Зачем
|
||||
|
||||
На хостингах многих кооперативов закрыт исходящий SMTP (25/465/587) — письма из
|
||||
`controller` уходят в connection timeout. Открывать порты по тикету на каждом
|
||||
сервере неудобно. Решение: один релей на сервере с открытыми портами (voskhod),
|
||||
все коопы шлют письма ему по HTTPS, он форвардит по SMTP.
|
||||
|
||||
Раньше эту роль выполнял Novu — после его удаления нужен свой сервис.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` → `{ "status": "ok" }`
|
||||
- `POST /send` (требует `Authorization: Bearer <RELAY_TOKEN>`)
|
||||
```json
|
||||
{ "from": "...", "to": "...", "subject": "...", "html": "...", "text": "..." }
|
||||
```
|
||||
`from` необязателен (берётся `EMAIL_FROM_DEFAULT`); нужно тело `html` или `text`.
|
||||
Ответ: `{ "delivered": true, "messageId": "..." }` либо `502` с `error`.
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Статичный Bearer-токен поверх TLS (nginx + Let's Encrypt). Сравнение токена —
|
||||
constant-time. Опционально IP-allowlist (`RELAY_IP_ALLOWLIST`). Ротация = смена
|
||||
`RELAY_TOKEN` на релее и у всех коопов.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
cp .env.example .env # заполнить RELAY_TOKEN + SMTP_*
|
||||
pnpm -F @coopenomics/email-relay dev # разработка
|
||||
pnpm -F @coopenomics/email-relay start # прод
|
||||
```
|
||||
|
||||
ENV — см. `.env.example`.
|
||||
|
||||
## Интеграция с controller
|
||||
|
||||
В `controller/.env` кооператива:
|
||||
|
||||
```
|
||||
EMAIL_RELAY_URL=https://mail-relay.coopenomics.world/send
|
||||
EMAIL_RELAY_TOKEN=<тот же RELAY_TOKEN>
|
||||
```
|
||||
|
||||
Если `EMAIL_RELAY_URL` не задан — `controller` шлёт напрямую по SMTP (старое
|
||||
поведение, без изменений).
|
||||
|
||||
## Деплой
|
||||
|
||||
- Образ: `dicoop/email-relay` (release-пайплайн, как остальные mono-сервисы).
|
||||
- Плейбук: `playbooks/email-relay/setup.yaml` (nginx TLS + docker compose).
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "@coopenomics/email-relay",
|
||||
"version": "1.0.0",
|
||||
"description": "HTTP→SMTP релей: принимает письма по HTTPS (Bearer-токен) и отправляет их по SMTP. Нужен там, где у кооператива закрыт исходящий SMTP — релей ставится на сервер с открытыми портами.",
|
||||
"type": "commonjs",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts",
|
||||
"dev": "nodemon --watch src --ext ts,env --exec 'ts-node' src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.22.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Конфиг релея целиком из ENV. Падаем на старте, если не заданы обязательные
|
||||
* параметры (токен + SMTP-хост) — релей без них бессмыслен и молча «глотать»
|
||||
* письма нельзя.
|
||||
*/
|
||||
function required(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value || !value.trim()) {
|
||||
throw new Error(`email-relay: обязательная переменная окружения ${name} не задана`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optional(name: string, fallback = ''): string {
|
||||
const value = process.env[name];
|
||||
return value && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw || !raw.trim()) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export interface RelayConfig {
|
||||
port: number;
|
||||
/** Общий секрет: Authorization: Bearer <token>. */
|
||||
token: string;
|
||||
/** Необязательный allowlist IP (defense-in-depth за TLS). Пусто = выключен. */
|
||||
allowlist: string[];
|
||||
smtp: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth?: { user: string; pass: string };
|
||||
};
|
||||
/** from по умолчанию, если запрос его не прислал. */
|
||||
defaultFrom: string;
|
||||
/** Максимальный размер тела запроса. */
|
||||
bodyLimit: string;
|
||||
}
|
||||
|
||||
export function loadConfig(): RelayConfig {
|
||||
const smtpPort = intEnv('SMTP_PORT', 587);
|
||||
const user = optional('SMTP_USERNAME');
|
||||
const pass = optional('SMTP_PASSWORD');
|
||||
return {
|
||||
port: intEnv('PORT', 3025),
|
||||
token: required('RELAY_TOKEN'),
|
||||
allowlist: optional('RELAY_IP_ALLOWLIST')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
smtp: {
|
||||
host: required('SMTP_HOST'),
|
||||
port: smtpPort,
|
||||
// secure=true только для 465 (implicit TLS); 587 = STARTTLS (secure=false).
|
||||
secure: optional('SMTP_SECURE') ? optional('SMTP_SECURE') === 'true' : smtpPort === 465,
|
||||
auth: user ? { user, pass } : undefined,
|
||||
},
|
||||
defaultFrom: optional('EMAIL_FROM_DEFAULT'),
|
||||
bodyLimit: optional('RELAY_BODY_LIMIT', '1mb'),
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { loadConfig } from './config';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.smtp.host,
|
||||
port: config.smtp.port,
|
||||
secure: config.smtp.secure,
|
||||
auth: config.smtp.auth,
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: config.bodyLimit }));
|
||||
|
||||
function log(level: 'info' | 'warn' | 'error', msg: string): void {
|
||||
// Без секретов в логах. Релей stateless, структурного логгера не тянем.
|
||||
// eslint-disable-next-line no-console
|
||||
console[level](`[email-relay] ${msg}`);
|
||||
}
|
||||
|
||||
/** Constant-time сравнение токена — без утечки по времени. */
|
||||
function tokenMatches(provided: string): boolean {
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(config.token);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function clientIp(req: Request): string {
|
||||
// За nginx реальный IP в X-Real-IP / первом X-Forwarded-For.
|
||||
const xff = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim();
|
||||
return (req.headers['x-real-ip'] as string | undefined) || xff || req.ip || '';
|
||||
}
|
||||
|
||||
function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (config.allowlist.length > 0) {
|
||||
const ip = clientIp(req);
|
||||
if (!config.allowlist.includes(ip)) {
|
||||
log('warn', `отказ по IP-allowlist: ${ip}`);
|
||||
res.status(403).json({ error: 'forbidden' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!token || !tokenMatches(token)) {
|
||||
log('warn', 'отказ: неверный Bearer-токен');
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
interface SendBody {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
app.post('/send', authMiddleware, async (req: Request, res: Response) => {
|
||||
const body = (req.body || {}) as SendBody;
|
||||
const to = body.to;
|
||||
const from = body.from || config.defaultFrom;
|
||||
const subject = body.subject ?? '';
|
||||
|
||||
if (!to) {
|
||||
res.status(400).json({ error: 'поле to обязательно' });
|
||||
return;
|
||||
}
|
||||
if (!from) {
|
||||
res.status(400).json({ error: 'не задан from (и нет EMAIL_FROM_DEFAULT)' });
|
||||
return;
|
||||
}
|
||||
if (!body.html && !body.text) {
|
||||
res.status(400).json({ error: 'нужно тело письма: html или text' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await transporter.sendMail({ from, to, subject, html: body.html, text: body.text });
|
||||
log('info', `письмо отправлено → ${to} (${info.messageId})`);
|
||||
res.json({ delivered: true, messageId: info.messageId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log('error', `ошибка SMTP при отправке на ${to}: ${message}`);
|
||||
res.status(502).json({ delivered: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
log('info', `слушает :${config.port}, SMTP ${config.smtp.host}:${config.smtp.port} (secure=${config.smtp.secure})`);
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -106,27 +106,6 @@ services:
|
||||
working_dir: /app
|
||||
command: pnpm --filter @coopenomics/desktop run dev
|
||||
|
||||
# HTTP→SMTP релей. Конфиг (RELAY_TOKEN, SMTP_*) — в components/email-relay/.env
|
||||
# (как coopback). Сам ходит в SMTP через nodemailer. На проде ставится отдельно
|
||||
# плейбуком на сервере с открытыми портами (см. playbooks/email-relay).
|
||||
email-relay:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: components/controller/Dockerfile
|
||||
image: coopback
|
||||
env_file:
|
||||
- ./components/email-relay/.env
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ./:/app
|
||||
ports:
|
||||
- "127.0.0.1:${EMAIL_RELAY_HOST_PORT:-3025}:3025"
|
||||
working_dir: /app
|
||||
command: pnpm --filter @coopenomics/email-relay run dev
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||
restart: unless-stopped
|
||||
|
||||
Generated
-35
@@ -1058,34 +1058,6 @@ importers:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
|
||||
components/email-relay:
|
||||
dependencies:
|
||||
express:
|
||||
specifier: ^4.22.1
|
||||
version: 4.22.1
|
||||
nodemailer:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.4
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)
|
||||
typescript:
|
||||
specifier: ^5.0.0
|
||||
version: 5.9.3
|
||||
devDependencies:
|
||||
'@types/express':
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.25
|
||||
'@types/node':
|
||||
specifier: ^20.0.0
|
||||
version: 20.19.37
|
||||
'@types/nodemailer':
|
||||
specifier: ^6.4.14
|
||||
version: 6.4.23
|
||||
nodemon:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.14
|
||||
|
||||
components/factory:
|
||||
dependencies:
|
||||
ajv:
|
||||
@@ -7446,9 +7418,6 @@ packages:
|
||||
'@types/node@8.10.66':
|
||||
resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==}
|
||||
|
||||
'@types/nodemailer@6.4.23':
|
||||
resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==}
|
||||
|
||||
'@types/normalize-package-data@2.4.4':
|
||||
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
|
||||
|
||||
@@ -26621,10 +26590,6 @@ snapshots:
|
||||
|
||||
'@types/node@8.10.66': {}
|
||||
|
||||
'@types/nodemailer@6.4.23':
|
||||
dependencies:
|
||||
'@types/node': 20.19.37
|
||||
|
||||
'@types/normalize-package-data@2.4.4': {}
|
||||
|
||||
'@types/nunjucks@3.2.6': {}
|
||||
|
||||
Reference in New Issue
Block a user