ops: мок unisender (письма в data-mail), dry-run ym до build (generate ✓), ТЗ: JSDoc */, poll-флейк sched
This commit is contained in:
@@ -22,7 +22,7 @@ const {
|
|||||||
PORT = '9090',
|
PORT = '9090',
|
||||||
} = process.env;
|
} = 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) {
|
function verify(req, raw) {
|
||||||
if (!WEBHOOK_SECRET) return true;
|
if (!WEBHOOK_SECRET) return true;
|
||||||
@@ -81,7 +81,7 @@ async function sendEmail(m) {
|
|||||||
api_key: UNISENDER_API_KEY,
|
api_key: UNISENDER_API_KEY,
|
||||||
message: {
|
message: {
|
||||||
template_engine: 'velocity',
|
template_engine: 'velocity',
|
||||||
body: { text: m.body },
|
body: { plaintext: m.body },
|
||||||
subject: m.title,
|
subject: m.title,
|
||||||
from_email: UNISENDER_FROM,
|
from_email: UNISENDER_FROM,
|
||||||
from_name: 'sched-pipeline',
|
from_name: 'sched-pipeline',
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
SCHED_API_KEY: ${SCHED_API_KEY:?задайте в .env}
|
SCHED_API_KEY: ${SCHED_API_KEY:?задайте в .env}
|
||||||
UNISENDER_API_KEY: ${UNISENDER_API_KEY:-}
|
UNISENDER_API_KEY: mock-key
|
||||||
UNISENDER_FROM: ${UNISENDER_FROM:-}
|
UNISENDER_FROM: noreply@snolla.com
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
GITHUB_TOKEN: ${GITHUB_TOKEN:-}
|
GITHUB_TOKEN: ${GITHUB_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -82,14 +82,17 @@ services:
|
|||||||
BROWSER_CDP_URL: http://browser:9222
|
BROWSER_CDP_URL: http://browser:9222
|
||||||
NTFY_URL: http://ntfy
|
NTFY_URL: http://ntfy
|
||||||
NTFY_TOPIC: ozon-seller-pipeline
|
NTFY_TOPIC: ozon-seller-pipeline
|
||||||
UNISENDER_API_KEY: ${UNISENDER_API_KEY:-}
|
UNISENDER_API_KEY: mock-key
|
||||||
UNISENDER_SENDER_EMAIL: ${UNISENDER_SENDER_EMAIL:-}
|
UNISENDER_SENDER_EMAIL: noreply@snolla.com
|
||||||
UNISENDER_SENDER_NAME: ${UNISENDER_SENDER_NAME:-}
|
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:-}
|
NOTIFY_EMAIL_TO: ${NOTIFY_EMAIL_TO:-}
|
||||||
GH_TOKEN: ${GITHUB_TOKEN:-}
|
GH_TOKEN: ${GITHUB_TOKEN:-}
|
||||||
NPM_TOKEN: ${NPM_TOKEN:-}
|
NPM_TOKEN: ${NPM_TOKEN:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ozon-data:/app/data
|
- ozon-data:/app/data
|
||||||
|
# снапшот спеки из репо (fetch антиботом не ходим — dry-run от ручного снапшота)
|
||||||
|
- ./data-ozon-openapi:/app/openapi
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18091:8080"
|
- "127.0.0.1:18091:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -131,8 +134,8 @@ services:
|
|||||||
NTFY_URL: http://ntfy
|
NTFY_URL: http://ntfy
|
||||||
NTFY_TOPIC: sched-alerts
|
NTFY_TOPIC: sched-alerts
|
||||||
WEBHOOK_SECRET: ${WEBHOOK_SECRET:?задайте в .env}
|
WEBHOOK_SECRET: ${WEBHOOK_SECRET:?задайте в .env}
|
||||||
UNISENDER_API_KEY: ${UNISENDER_API_KEY:-}
|
UNISENDER_API_KEY: mock-key
|
||||||
UNISENDER_FROM: ${UNISENDER_FROM:-}
|
UNISENDER_FROM: noreply@snolla.com
|
||||||
EMAIL_TO: ${NOTIFY_EMAIL_TO:-}
|
EMAIL_TO: ${NOTIFY_EMAIL_TO:-}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:19090:9090"
|
- "127.0.0.1:19090:9090"
|
||||||
@@ -141,6 +144,20 @@ services:
|
|||||||
- ntfy
|
- ntfy
|
||||||
networks: [sched-local]
|
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:
|
volumes:
|
||||||
sched-data:
|
sched-data:
|
||||||
ozon-data:
|
ozon-data:
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
"runner": "http",
|
"runner": "http",
|
||||||
"schedules": [{ "cron": "0 2 * * *" }],
|
"schedules": [{ "cron": "0 2 * * *" }],
|
||||||
"config": {
|
"config": {
|
||||||
"url": "http://ozon-seller-builder:8080/run",
|
"url": "http://ozon-seller-builder:8080/run?async=1",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"envelope": true,
|
"envelope": true,
|
||||||
"auth": { "apiKey": "__SCHED_API_KEY__" },
|
"auth": { "apiKey": "__SCHED_API_KEY__" },
|
||||||
"data": {
|
"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",
|
"runner": "http",
|
||||||
"schedules": [{ "cron": "30 2 * * *" }],
|
"schedules": [{ "cron": "30 2 * * *" }],
|
||||||
"config": {
|
"config": {
|
||||||
|
"handler": "pipeline",
|
||||||
"url": "http://ym-client-builder:8080/run",
|
"url": "http://ym-client-builder:8080/run",
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"envelope": true,
|
"envelope": true,
|
||||||
@@ -27,15 +28,14 @@
|
|||||||
"stages": ["fetch", "detect", "diff", "classify", "patch", "generate", "fix", "build", "test", "postmanGenerate", "postman"],
|
"stages": ["fetch", "detect", "diff", "classify", "patch", "generate", "fix", "build", "test", "postmanGenerate", "postman"],
|
||||||
"repo": "https://github.com/yandex-market/yandex-market-partner-api",
|
"repo": "https://github.com/yandex-market/yandex-market-partner-api",
|
||||||
"branch": "main",
|
"branch": "main",
|
||||||
"workDir": "data/clone",
|
"workDir": "/app/data/clone",
|
||||||
"specDir": "data/openapi/snapshots",
|
|
||||||
"snapshotsDir": "data/openapi/snapshots",
|
"snapshotsDir": "data/openapi/snapshots",
|
||||||
"statePath": "data/state.json",
|
"statePath": "data/state.json",
|
||||||
"outputDir": "data/output/typescript",
|
"outputDir": "data/output/typescript",
|
||||||
"testsDir": "tests/client",
|
"testsDir": "tests/client",
|
||||||
"notify": {
|
"notify": {
|
||||||
"ntfy": { "url": "http://ntfy", "topic": "ym-client-builder" },
|
"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"] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
92
host-stacks/local/sched-pipelines/unisender-mock/index.cjs
Normal file
92
host-stacks/local/sched-pipelines/unisender-mock/index.cjs
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user