[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.
|
# из отдельного 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 деплоя ===
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
Generated
-35
@@ -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': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user