From a77fa945a6b13ebd4c94c95f202553b1c0f2b234 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 9 Jun 2026 11:47:59 +0000 Subject: [PATCH] =?UTF-8?q?[C28-28][@ant]=20chore(email-relay):=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20=D1=80=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B8=D0=B7=20mono=20=D0=B2=20C9S/email-relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Релей переехал в отдельный репозиторий 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) --- .github/workflows/release.yaml | 1 - components/email-relay/.env.example | 23 ------ components/email-relay/README.md | 56 --------------- components/email-relay/package.json | 27 ------- components/email-relay/src/config.ts | 65 ----------------- components/email-relay/src/index.ts | 102 --------------------------- components/email-relay/tsconfig.json | 15 ---- docker-compose.yaml | 21 ------ pnpm-lock.yaml | 35 --------- 9 files changed, 345 deletions(-) delete mode 100644 components/email-relay/.env.example delete mode 100644 components/email-relay/README.md delete mode 100644 components/email-relay/package.json delete mode 100644 components/email-relay/src/config.ts delete mode 100644 components/email-relay/src/index.ts delete mode 100644 components/email-relay/tsconfig.json diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 49b5cdaa587..25dc145058e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 деплоя === diff --git a/components/email-relay/.env.example b/components/email-relay/.env.example deleted file mode 100644 index 3cd3f2ec5cc..00000000000 --- a/components/email-relay/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -# Порт HTTP-сервера релея (за ним nginx+TLS) -PORT=3025 - -# Общий секрет. Коопы шлют Authorization: Bearer . -# Сгенерировать: 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 diff --git a/components/email-relay/README.md b/components/email-relay/README.md deleted file mode 100644 index c747cfd4ccd..00000000000 --- a/components/email-relay/README.md +++ /dev/null @@ -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 `) - ```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). diff --git a/components/email-relay/package.json b/components/email-relay/package.json deleted file mode 100644 index f79890630d2..00000000000 --- a/components/email-relay/package.json +++ /dev/null @@ -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" - } -} diff --git a/components/email-relay/src/config.ts b/components/email-relay/src/config.ts deleted file mode 100644 index a771d552367..00000000000 --- a/components/email-relay/src/config.ts +++ /dev/null @@ -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: 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'), - }; -} diff --git a/components/email-relay/src/index.ts b/components/email-relay/src/index.ts deleted file mode 100644 index 94ca3bfbc88..00000000000 --- a/components/email-relay/src/index.ts +++ /dev/null @@ -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})`); -}); diff --git a/components/email-relay/tsconfig.json b/components/email-relay/tsconfig.json deleted file mode 100644 index 2bdcc27312e..00000000000 --- a/components/email-relay/tsconfig.json +++ /dev/null @@ -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"] -} diff --git a/docker-compose.yaml b/docker-compose.yaml index a3da797a933..993f9ad9aae 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f26bc655a08..18a86132615 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {}