[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:
coopops
2026-06-09 11:47:59 +00:00
parent be7e1585b0
commit a77fa945a6
9 changed files with 0 additions and 345 deletions
-1
View File
@@ -322,7 +322,6 @@ jobs:
# из отдельного coopenomics/parser repo. # из отдельного coopenomics/parser repo.
build_service cooparser '@coopenomics/parser' start build_service cooparser '@coopenomics/parser' start
build_service notificator 'coop-notificator' start build_service notificator 'coop-notificator' start
build_service email-relay '@coopenomics/email-relay' start
build_service notifications '@coopenomics/notifications' sync build_service notifications '@coopenomics/notifications' sync
# === Этап 3: webhook деплоя === # === Этап 3: webhook деплоя ===
-23
View File
@@ -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
-56
View File
@@ -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).
-27
View File
@@ -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"
}
}
-65
View File
@@ -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'),
};
}
-102
View File
@@ -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})`);
});
-15
View File
@@ -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"]
}
-21
View File
@@ -106,27 +106,6 @@ services:
working_dir: /app working_dir: /app
command: pnpm --filter @coopenomics/desktop run dev 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: minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped restart: unless-stopped
-35
View File
@@ -1058,34 +1058,6 @@ importers:
specifier: ^0.34.5 specifier: ^0.34.5
version: 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: components/factory:
dependencies: dependencies:
ajv: ajv:
@@ -7446,9 +7418,6 @@ packages:
'@types/node@8.10.66': '@types/node@8.10.66':
resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==} resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==}
'@types/nodemailer@6.4.23':
resolution: {integrity: sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==}
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -26621,10 +26590,6 @@ snapshots:
'@types/node@8.10.66': {} '@types/node@8.10.66': {}
'@types/nodemailer@6.4.23':
dependencies:
'@types/node': 20.19.37
'@types/normalize-package-data@2.4.4': {} '@types/normalize-package-data@2.4.4': {}
'@types/nunjucks@3.2.6': {} '@types/nunjucks@3.2.6': {}