From e6c43314e9423ea30cb808a308c3ca37c0a0401d Mon Sep 17 00:00:00 2001 From: vitya Date: Thu, 20 Aug 2026 21:26:51 +0300 Subject: [PATCH] =?UTF-8?q?ops:=20=D0=BC=D0=BE=D0=BA=20unisender=20(=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8C=D0=BC=D0=B0=20=D0=B2=20data-mail),=20dry-run?= =?UTF-8?q?=20ym=20=D0=B4=D0=BE=20build=20(generate=20=E2=9C=93),=20=D0=A2?= =?UTF-8?q?=D0=97:=20JSDoc=20*/,=20poll-=D1=84=D0=BB=D0=B5=D0=B9=D0=BA=20s?= =?UTF-8?q?ched?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../local/sched-pipelines/bridge/index.cjs | 4 +- .../local/sched-pipelines/docker-compose.yml | 31 +++++-- host-stacks/local/sched-pipelines/tasks.json | 10 +- .../sched-pipelines/unisender-mock/Dockerfile | 7 ++ .../sched-pipelines/unisender-mock/index.cjs | 92 +++++++++++++++++++ 5 files changed, 130 insertions(+), 14 deletions(-) create mode 100644 host-stacks/local/sched-pipelines/unisender-mock/Dockerfile create mode 100644 host-stacks/local/sched-pipelines/unisender-mock/index.cjs diff --git a/host-stacks/local/sched-pipelines/bridge/index.cjs b/host-stacks/local/sched-pipelines/bridge/index.cjs index a6d7cb6..f4e7731 100644 --- a/host-stacks/local/sched-pipelines/bridge/index.cjs +++ b/host-stacks/local/sched-pipelines/bridge/index.cjs @@ -22,7 +22,7 @@ const { PORT = '9090', } = process.env; -const UNISENDER_ENDPOINT = 'https://go2.unisender.ru/ru/transactional/api/v1/email/send.json'; +const UNISENDER_ENDPOINT = process.env.UNISENDER_ENDPOINT || 'https://goapi.unisender.ru/ru/transactional/api/v1/email/send.json'; function verify(req, raw) { if (!WEBHOOK_SECRET) return true; @@ -81,7 +81,7 @@ async function sendEmail(m) { api_key: UNISENDER_API_KEY, message: { template_engine: 'velocity', - body: { text: m.body }, + body: { plaintext: m.body }, subject: m.title, from_email: UNISENDER_FROM, from_name: 'sched-pipeline', diff --git a/host-stacks/local/sched-pipelines/docker-compose.yml b/host-stacks/local/sched-pipelines/docker-compose.yml index 04a984f..08833eb 100644 --- a/host-stacks/local/sched-pipelines/docker-compose.yml +++ b/host-stacks/local/sched-pipelines/docker-compose.yml @@ -53,8 +53,8 @@ services: environment: PORT: "8080" SCHED_API_KEY: ${SCHED_API_KEY:?задайте в .env} - UNISENDER_API_KEY: ${UNISENDER_API_KEY:-} - UNISENDER_FROM: ${UNISENDER_FROM:-} + UNISENDER_API_KEY: mock-key + UNISENDER_FROM: noreply@snolla.com NPM_TOKEN: ${NPM_TOKEN:-} GITHUB_TOKEN: ${GITHUB_TOKEN:-} volumes: @@ -82,14 +82,17 @@ services: BROWSER_CDP_URL: http://browser:9222 NTFY_URL: http://ntfy NTFY_TOPIC: ozon-seller-pipeline - UNISENDER_API_KEY: ${UNISENDER_API_KEY:-} - UNISENDER_SENDER_EMAIL: ${UNISENDER_SENDER_EMAIL:-} - UNISENDER_SENDER_NAME: ${UNISENDER_SENDER_NAME:-} + UNISENDER_API_KEY: mock-key + UNISENDER_SENDER_EMAIL: noreply@snolla.com + UNISENDER_SENDER_NAME: sched-pipeline + UNISENDER_ENDPOINT: ${UNISENDER_ENDPOINT:-https://goapi.unisender.ru/ru/transactional/api/v1/email/send.json} NOTIFY_EMAIL_TO: ${NOTIFY_EMAIL_TO:-} GH_TOKEN: ${GITHUB_TOKEN:-} NPM_TOKEN: ${NPM_TOKEN:-} volumes: - ozon-data:/app/data + # снапшот спеки из репо (fetch антиботом не ходим — dry-run от ручного снапшота) + - ./data-ozon-openapi:/app/openapi ports: - "127.0.0.1:18091:8080" restart: unless-stopped @@ -131,8 +134,8 @@ services: NTFY_URL: http://ntfy NTFY_TOPIC: sched-alerts WEBHOOK_SECRET: ${WEBHOOK_SECRET:?задайте в .env} - UNISENDER_API_KEY: ${UNISENDER_API_KEY:-} - UNISENDER_FROM: ${UNISENDER_FROM:-} + UNISENDER_API_KEY: mock-key + UNISENDER_FROM: noreply@snolla.com EMAIL_TO: ${NOTIFY_EMAIL_TO:-} ports: - "127.0.0.1:19090:9090" @@ -141,6 +144,20 @@ services: - ntfy networks: [sched-local] + # --- мок Unisender Go: письма в ./data-mail, реальных отправок нет --- + unisender-mock: + build: ./unisender-mock + image: sched-pipelines/unisender-mock:local + environment: + PORT: "8080" + MAIL_DIR: /mail + volumes: + - ./data-mail:/mail + ports: + - "127.0.0.1:18093:8080" + restart: unless-stopped + networks: [sched-local] + volumes: sched-data: ozon-data: diff --git a/host-stacks/local/sched-pipelines/tasks.json b/host-stacks/local/sched-pipelines/tasks.json index d1b9690..1f90690 100644 --- a/host-stacks/local/sched-pipelines/tasks.json +++ b/host-stacks/local/sched-pipelines/tasks.json @@ -5,12 +5,12 @@ "runner": "http", "schedules": [{ "cron": "0 2 * * *" }], "config": { - "url": "http://ozon-seller-builder:8080/run", + "url": "http://ozon-seller-builder:8080/run?async=1", "method": "POST", "envelope": true, "auth": { "apiKey": "__SCHED_API_KEY__" }, "data": { - "stages": ["fetch", "hash", "patch", "diff", "classify", "generate", "fix", "build", "postman", "state"] + "stages": ["hash", "patch", "diff", "classify", "generate", "fix", "build", "postman", "state"] } } }, @@ -19,6 +19,7 @@ "runner": "http", "schedules": [{ "cron": "30 2 * * *" }], "config": { + "handler": "pipeline", "url": "http://ym-client-builder:8080/run", "method": "POST", "envelope": true, @@ -27,15 +28,14 @@ "stages": ["fetch", "detect", "diff", "classify", "patch", "generate", "fix", "build", "test", "postmanGenerate", "postman"], "repo": "https://github.com/yandex-market/yandex-market-partner-api", "branch": "main", - "workDir": "data/clone", - "specDir": "data/openapi/snapshots", + "workDir": "/app/data/clone", "snapshotsDir": "data/openapi/snapshots", "statePath": "data/state.json", "outputDir": "data/output/typescript", "testsDir": "tests/client", "notify": { "ntfy": { "url": "http://ntfy", "topic": "ym-client-builder" }, - "email": { "to": "vitya.kuznetsov@gmail.com", "on": ["published", "report", "failed"] } + "email": { "to": "vitya.kuznetsov@gmail.com", "endpoint": "http://unisender-mock:8080/ru/transactional/api/v1/email/send.json", "on": ["published", "report", "failed"] } } } } diff --git a/host-stacks/local/sched-pipelines/unisender-mock/Dockerfile b/host-stacks/local/sched-pipelines/unisender-mock/Dockerfile new file mode 100644 index 0000000..a95f5be --- /dev/null +++ b/host-stacks/local/sched-pipelines/unisender-mock/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY index.cjs ./ +ENV PORT=8080 +ENV MAIL_DIR=/mail +EXPOSE 8080 +CMD ["node", "index.cjs"] diff --git a/host-stacks/local/sched-pipelines/unisender-mock/index.cjs b/host-stacks/local/sched-pipelines/unisender-mock/index.cjs new file mode 100644 index 0000000..8b1809c --- /dev/null +++ b/host-stacks/local/sched-pipelines/unisender-mock/index.cjs @@ -0,0 +1,92 @@ +'use strict'; +/** + * Мок Unisender Go (goapi.unisender.ru/ru/transactional/api/v1/email/send.json). + * Реальных отправок НЕТ — каждое письмо пишется в MAIL_DIR как JSON-файл. + * Валидирует контракт как настоящий API (те же коды ошибок) — чтобы ловить + * регрессии формы (body.text vs plaintext и т.п.) без платных писем. + * + * Эндпоинты: + * POST /ru/transactional/api/v1/email/send.json — приём письма (X-API-KEY или api_key) + * GET /health — живость + */ +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); + +const PORT = Number(process.env.PORT || 8080); +const MAIL_DIR = process.env.MAIL_DIR || '/mail'; + +fs.mkdirSync(MAIL_DIR, { recursive: true }); + +function json(res, code, body) { + const payload = JSON.stringify(body); + res.writeHead(code, { 'content-type': 'application/json' }); + res.end(payload); +} + +function apiError(res, code, message) { + json(res, 400, { status: 'error', code, message }); +} + +function saveMail(payload, apiKey) { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const jobId = `mock-${ts}`; + const file = path.join(MAIL_DIR, `${ts}.json`); + const msg = payload.message || {}; + const recipients = (msg.recipients || []).map((r) => r.email); + const record = { + job_id: jobId, + receivedAt: new Date().toISOString(), + api_key: apiKey, + from_email: msg.from_email || null, + from_name: msg.from_name || null, + subject: msg.subject || null, + recipients, + body: msg.body || null, + raw: payload, + }; + fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n'); + return { jobId, recipients }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + + if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true }); + + if (req.method === 'POST' && url.pathname === '/ru/transactional/api/v1/email/send.json') { + const chunks = []; + for await (const c of req) chunks.push(c); + let payload; + try { + payload = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return apiError(res, 111, 'JSON parsing error'); + } + + const apiKey = req.headers['x-api-key'] || payload.api_key; + if (!apiKey) return apiError(res, 101, 'API key is missing'); + + const msg = payload.message; + if (!msg || typeof msg !== 'object') return apiError(res, 2000, "Error in 'message' field. Message object required."); + if (!Array.isArray(msg.recipients) || msg.recipients.length === 0 || !msg.recipients[0].email) { + return apiError(res, 2001, "Error in 'recipients' field. Recipients required."); + } + const body = msg.body || {}; + if (!body.plaintext && !body.html) { + // зеркалит реальный ответ: 400 code 1577 No message body passed + return apiError(res, 1577, "Error in 'body' field. No message body passed."); + } + if (!msg.from_email) return apiError(res, 2002, "Error in 'from_email' field. Sender required."); + + const { jobId, recipients } = saveMail(payload, apiKey); + console.log(`✉️ mock email: ${msg.subject || '(no subject)'} -> ${recipients.join(', ')} (${jobId})`); + return json(res, 200, { status: 'success', job_id: jobId, emails: recipients, tags: [] }); + } + + return json(res, 404, { status: 'error', code: 404, message: 'not found' }); +}); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`unisender-mock on :${PORT}, mail dir: ${MAIL_DIR}`); +});