release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image

Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
commit 8ce050ef7f
157 changed files with 17631 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="200">S</tspan><tspan font-weight="600">P</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SelfPost — знак на утверждение</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@200;300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{--ink:#12161C;--paper:#F7F6F4;--brick:#7A3B2E;--slate:#6B7684;--line:#DEDCD7}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font-family:'IBM Plex Sans',system-ui,sans-serif;font-weight:300;-webkit-font-smoothing:antialiased}
.wrap{max-width:960px;margin:0 auto;padding:56px 24px 96px}
header{border-bottom:2px solid var(--brick);padding-bottom:28px}
.eyebrow{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--brick);margin:0 0 14px}
h1{font-size:clamp(28px,5vw,40px);font-weight:200;letter-spacing:-.02em;margin:0 0 10px}
h1 b{font-weight:600}
header p{margin:0;max-width:62ch;color:#3C444F;line-height:1.55;font-size:15px}
section{border-bottom:1px solid var(--line);padding:40px 0}
h2{font-size:13px;font-weight:500;margin:0 0 20px;letter-spacing:.1em;text-transform:uppercase;font-family:'IBM Plex Mono',monospace;color:var(--slate)}
.hero{background:#fff;border:1px solid var(--line);border-radius:3px;padding:44px;display:flex;justify-content:center}
.hero svg{width:100%;max-width:430px;height:auto}
.swatches{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}
.sw{font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border:1px solid var(--line);border-radius:2px;padding:6px 10px;display:flex;align-items:center;gap:7px;background:#fff}
.sw i{width:14px;height:14px;border:1px solid rgba(0,0,0,.12);display:block}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
.cap{font-size:12.5px;line-height:1.5;color:#5A6470;margin-top:8px}
.cap b{font-family:'IBM Plex Mono',monospace;font-size:11px;font-weight:600;color:var(--ink)}
/* mock: github */
.gh{border:1px solid #D1D9E0;border-radius:6px;padding:18px;background:#fff;font-size:13px}
.gh.dk{background:#0D1117;border-color:#30363D;color:#E6EDF3}
.gh .bc{font-size:14px;color:#0969DA;margin-bottom:14px}
.gh.dk .bc{color:#4493F8}
.gh .bc b{font-weight:600}
.gh .rd{border:1px solid #D1D9E0;border-radius:6px;padding:20px}
.gh.dk .rd{border-color:#30363D}
.gh .rd svg{width:280px;max-width:100%;height:auto;display:block;margin-bottom:16px}
.gh h3{font-size:15px;font-weight:600;margin:0 0 8px;padding-bottom:6px;border-bottom:1px solid #D1D9E0}
.gh.dk h3{border-color:#30363D}
.gh p{margin:0;color:#59636E;line-height:1.6;font-size:12.5px}
.gh.dk p{color:#9198A1}
/* mock: browser tab */
.tabs{background:#DEE1E6;border-radius:8px 8px 0 0;padding:8px 8px 0;display:flex;gap:4px}
.tab{background:#fff;border-radius:8px 8px 0 0;padding:8px 12px;display:flex;align-items:center;gap:7px;font-size:12px;color:#3C4043;max-width:190px}
.tab.off{background:#C9CDD3;color:#5F6368}
.tab svg{width:16px;height:16px;flex:none}
.tab span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.url{background:#fff;padding:10px 12px;border-radius:0 0 8px 8px;font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--slate);border-top:1px solid #DEE1E6}
/* mock: panel */
.panel{border:1px solid var(--line);border-radius:4px;overflow:hidden;background:#fff}
.ph{background:#12161C;padding:12px 16px;display:flex;align-items:center;gap:20px}
.ph svg{height:34px;width:auto}
.nav{display:flex;gap:16px;font-size:12.5px;color:#9AA3AE}
.nav b{color:#fff;font-weight:500}
.pb{padding:18px 16px;display:flex;flex-direction:column;gap:9px}
.row{display:flex;align-items:center;gap:10px;font-family:'IBM Plex Mono',monospace;font-size:11.5px;color:#3C444F}
.badge{font-size:10px;padding:2px 7px;border-radius:10px;font-weight:600;letter-spacing:.03em}
.ok{background:#DCFCE7;color:#166534}
.er{background:#FEE2E2;color:#991B1B}
/* avatars */
.avs{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap}
.av{border-radius:6px;overflow:hidden;border:1px solid var(--line);display:block}
.sizes{display:flex;gap:22px;align-items:flex-end;flex-wrap:wrap;background:#fff;border:1px solid var(--line);border-radius:3px;padding:24px}
.sizes .u{text-align:center}
.sizes svg{display:block;margin:0 auto 8px}
.sizes .u span{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--slate)}
.rules{display:grid;grid-template-columns:1fr 1fr;gap:2px;background:var(--line);border:1px solid var(--line);border-radius:3px;overflow:hidden}
@media(max-width:760px){.rules{grid-template-columns:1fr}}
.rule{background:#fff;padding:16px 18px}
.rule b{display:block;font-weight:500;font-size:13.5px;margin-bottom:5px;color:var(--brick)}
.rule span{font-size:12.5px;line-height:1.55;color:#5A6470}
.files{font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:2;color:#3C444F}
.files b{color:var(--ink);font-weight:600}
.note{background:#F1EAE4;border-left:2px solid var(--brick);padding:14px 16px;font-size:13.5px;line-height:1.6;color:#3C444F;max-width:64ch;margin-top:20px}
code{font-family:'IBM Plex Mono',monospace;font-size:12px;background:#EFEDE9;padding:1px 5px;border-radius:2px}
</style></head>
<body><div class="wrap">
<header>
<p class="eyebrow">на утверждение · кирпич #7A3B2E</p>
<h1>Self<b>Post</b></h1>
<p>Марка-надпись: имя — само поле знака, край держат зубцовка и волосяной контур. Один файл работает на светлой и тёмной теме.</p>
</header>
<section>
<h2>Эталон</h2>
<div class="hero"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg></div>
<div class="swatches">
<div class="sw"><i style="background:#7A3B2E"></i>#7A3B2E · печать</div>
<div class="sw"><i style="background:#F3EDE1"></i>#F3EDE1 · поле</div>
<div class="sw"><i style="background:#F6EDE2"></i>#F6EDE2 · краска по полю</div>
<div class="sw"><i style="background:#12161C"></i>#12161C · контур 0.8</div>
<div class="sw">контраст 7,3:1 · подзаголовок 4,6:1</div>
</div>
</section>
<section>
<h2>README на GitHub</h2>
<div class="grid2">
<div><div class="gh">
<div class="bc">mixeme / <b>selfpost</b></div>
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>
<h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
</div><div class="cap"><b>light</b><br>Тёплое поле отделяет марку от белого GitHub, контур ловит край.</div></div>
<div><div class="gh dk">
<div class="bc">mixeme / <b>selfpost</b></div>
<div class="rd"><svg class="" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>
<h3>SelfPost</h3>
<p>Self-hosted outbound SMTP relay with a web control panel, shipped as a single Docker image.</p></div>
</div><div class="cap"><b>dark</b><br>Тот же файл: на тёмном работает поле, контур просто исчезает.</div></div>
</div>
</section>
<section>
<h2>Шапка панели</h2>
<div class="panel">
<div class="ph"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg>
<div class="nav"><b>Domains</b><span>Applications</span><span>Send log</span><span>Backup</span></div></div>
<div class="pb">
<div class="row">example.org &nbsp; 14:22:07 &nbsp; <span class="badge ok">delivered</span></div>
<div class="row">shop.example.org &nbsp; 14:21:55 &nbsp; <span class="badge er">bounced</span></div>
<div class="row">example.org &nbsp; 14:19:03 &nbsp; <span class="badge ok">delivered</span></div>
</div>
</div>
<div class="cap"><b>проверка на конфликт</b><br>Ровно тот экран, ради которого мы отбраковывали краски: кирпич рядом с зелёным «delivered» и красным «bounced» не читается ни как один из них.</div>
</section>
<section>
<h2>Фавикон и аватар</h2>
<div class="grid2">
<div>
<div class="tabs">
<div class="tab"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="43" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="26" letter-spacing="-1" fill="#F6EDE2"><tspan font-weight="200">S</tspan><tspan font-weight="600">P</tspan></text>
</svg><span>SelfPost — Domains</span></div>
<div class="tab off"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg><span>docs</span></div>
</div>
<div class="url">https://mail.example.org/domains</div>
<div class="cap"><b>16 px</b><br>Слева версия для мелкого размера: зубцы крупнее и реже, вместо двух строк — SP. Справа для сравнения полная иконка в том же размере: превращается в кашу.</div>
</div>
<div>
<div class="avs">
<img class="av" width="80" height="80" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
<img class="av" width="48" height="48" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.6' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='31' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='200' letter-spacing='-.4' fill='%23F6EDE2'%3ESelf%3C/text%3E%3Ctext x='32' y='45' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='14' font-weight='600' letter-spacing='-.4' fill='%23F6EDE2'%3EPost%3C/text%3E%3C/svg%3E">
<img class="av" width="32" height="32" alt="" src="data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' width='64' height='64' role='img' aria-label='SelfPost'%3E%3C!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0 Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. --%3E%3Cpath d='M7.80 5.00 L13.00 5.00 A2.8 2.8 0 0 0 18.60 5.00 L23.80 5.00 A2.8 2.8 0 0 0 29.40 5.00 L34.60 5.00 A2.8 2.8 0 0 0 40.20 5.00 L45.40 5.00 A2.8 2.8 0 0 0 51.00 5.00 L56.20 5.00 A2.8 2.8 0 0 0 59.00 7.80 L59.00 13.00 A2.8 2.8 0 0 0 59.00 18.60 L59.00 23.80 A2.8 2.8 0 0 0 59.00 29.40 L59.00 34.60 A2.8 2.8 0 0 0 59.00 40.20 L59.00 45.40 A2.8 2.8 0 0 0 59.00 51.00 L59.00 56.20 A2.8 2.8 0 0 0 56.20 59.00 L51.00 59.00 A2.8 2.8 0 0 0 45.40 59.00 L40.20 59.00 A2.8 2.8 0 0 0 34.60 59.00 L29.40 59.00 A2.8 2.8 0 0 0 23.80 59.00 L18.60 59.00 A2.8 2.8 0 0 0 13.00 59.00 L7.80 59.00 A2.8 2.8 0 0 0 5.00 56.20 L5.00 51.00 A2.8 2.8 0 0 0 5.00 45.40 L5.00 40.20 A2.8 2.8 0 0 0 5.00 34.60 L5.00 29.40 A2.8 2.8 0 0 0 5.00 23.80 L5.00 18.60 A2.8 2.8 0 0 0 5.00 13.00 L5.00 7.80 A2.8 2.8 0 0 0 7.80 5.00 Z' fill='%23F3EDE1' stroke='%2312161C' stroke-width='0.8' stroke-linejoin='round'/%3E%3Crect x='11' y='11' width='42' height='42' fill='%237A3B2E'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family=''IBM Plex Sans','Helvetica Neue',sans-serif' font-size='26' letter-spacing='-1' fill='%23F6EDE2'%3E%3Ctspan font-weight='200'%3ES%3C/tspan%3E%3Ctspan font-weight='600'%3EP%3C/tspan%3E%3C/text%3E%3C/svg%3E">
</div>
<div class="cap"><b>80 / 48 / 32 px</b><br>Аватар организации на Codeberg и GitHub. Порог перехода на версию SP — 32 px.</div>
</div>
</div>
</section>
<section>
<h2>Размеры и охранное поле</h2>
<div class="sizes">
<div class="u"><svg class="s1" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<rect x="-4" y="-12" width="338" height="174" fill="none" stroke="#7A3B2E" stroke-width="1" stroke-dasharray="4 3" opacity=".55"/><path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg><span>полная · от 280 px</span></div>
<div class="u"><svg class="" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg><span>компактная · от 100 px</span></div>
<div class="u"><svg class="" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M7.90 6.00 L10.60 6.00 A1.9 1.9 0 0 0 14.40 6.00 L17.10 6.00 A1.9 1.9 0 0 0 20.90 6.00 L23.60 6.00 A1.9 1.9 0 0 0 27.40 6.00 L30.10 6.00 A1.9 1.9 0 0 0 33.90 6.00 L36.60 6.00 A1.9 1.9 0 0 0 40.40 6.00 L43.10 6.00 A1.9 1.9 0 0 0 46.90 6.00 L49.60 6.00 A1.9 1.9 0 0 0 53.40 6.00 L56.10 6.00 A1.9 1.9 0 0 0 58.00 7.90 L58.00 10.60 A1.9 1.9 0 0 0 58.00 14.40 L58.00 17.10 A1.9 1.9 0 0 0 58.00 20.90 L58.00 23.60 A1.9 1.9 0 0 0 58.00 27.40 L58.00 30.10 A1.9 1.9 0 0 0 58.00 33.90 L58.00 36.60 A1.9 1.9 0 0 0 58.00 40.40 L58.00 43.10 A1.9 1.9 0 0 0 58.00 46.90 L58.00 49.60 A1.9 1.9 0 0 0 58.00 53.40 L58.00 56.10 A1.9 1.9 0 0 0 56.10 58.00 L53.40 58.00 A1.9 1.9 0 0 0 49.60 58.00 L46.90 58.00 A1.9 1.9 0 0 0 43.10 58.00 L40.40 58.00 A1.9 1.9 0 0 0 36.60 58.00 L33.90 58.00 A1.9 1.9 0 0 0 30.10 58.00 L27.40 58.00 A1.9 1.9 0 0 0 23.60 58.00 L20.90 58.00 A1.9 1.9 0 0 0 17.10 58.00 L14.40 58.00 A1.9 1.9 0 0 0 10.60 58.00 L7.90 58.00 A1.9 1.9 0 0 0 6.00 56.10 L6.00 53.40 A1.9 1.9 0 0 0 6.00 49.60 L6.00 46.90 A1.9 1.9 0 0 0 6.00 43.10 L6.00 40.40 A1.9 1.9 0 0 0 6.00 36.60 L6.00 33.90 A1.9 1.9 0 0 0 6.00 30.10 L6.00 27.40 A1.9 1.9 0 0 0 6.00 23.60 L6.00 20.90 A1.9 1.9 0 0 0 6.00 17.10 L6.00 14.40 A1.9 1.9 0 0 0 6.00 10.60 L6.00 7.90 A1.9 1.9 0 0 0 7.90 6.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.6" stroke-linejoin="round"/>
<rect x="11" y="11" width="42" height="42" fill="#7A3B2E"/>
<text x="32" y="31" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="200" letter-spacing="-.4" fill="#F6EDE2">Self</text>
<text x="32" y="45" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="14" font-weight="600" letter-spacing="-.4" fill="#F6EDE2">Post</text>
</svg><span>иконка · до 100 px</span></div>
</div>
<div class="note"><b>Числа не на глаз.</b> Полная версия живёт до 280 px по ширине — ниже подзаголовок опускается под 6 px и перестаёт читаться. Компактная — до 100 px. Охранное поле (пунктир) равно <b>трём шагам зубцовки</b>, ≈ 21 % высоты марки: мера взята из самого знака, а не назначена.</div>
<style>.s1{width:330px;max-width:100%;height:auto} .sizes .u:nth-child(2) svg{width:220px;max-width:100%;height:auto} .sizes .u:nth-child(3) svg{width:64px;height:64px}</style>
</section>
<section>
<h2>Чего не делать</h2>
<div class="rules"><div class="rule"><b>Не растягивать</b><span>Пропорции 1:2,3 — часть знака: зубцовка рассчитана на них и при неравном масштабе зубцы становятся овалами.</span></div><div class="rule"><b>Не менять краску</b><span>Кирпич выбран по контрасту (7,3:1) и по тому, что он не спорит со статусами панели. Другой цвет ломает оба расчёта.</span></div><div class="rule"><b>Не класть на пёстрое</b><span>Волосяной контур рассчитан на ровный фон. На фото и градиенте край пропадает.</span></div><div class="rule"><b>Не добавлять рамок и теней</b><span>Марка держит край зубцовкой и контуром. Всё остальное — уже наклейка, а не марка.</span></div><div class="rule"><b>Не набирать подзаголовок мельче 6 px</b><span>Ниже полная версия перестаёт читаться — там уже компактная.</span></div></div>
</section>
<section style="border-bottom:none">
<h2>Файлы</h2>
<div class="files">
<b>docs/brand/selfpost-stamp.svg</b> — эталон, README, документация<br>
<b>docs/brand/selfpost-stamp-compact.svg</b> — шапка панели<br>
<b>docs/brand/selfpost-icon.svg</b> — аватар, фавикон от 32 px<br>
<b>docs/brand/selfpost-icon-16.svg</b> — фавикон 16–32 px
</div>
<div class="note"><b>Осталось одно действие.</b> В контейнере нет ни IBM Plex, ни сети, поэтому текст в файлах остался текстом. Перед коммитом его надо перевести в контуры, иначе на машине без Plex набор поедет:<br><code>inkscape --actions="select-all;object-to-path;export-filename:out.svg;export-do" in.svg</code><br>Пока в файлах прописан фолбэк <code>'IBM Plex Sans','Helvetica Neue',sans-serif</code> — не спасёт вёрстку, но не даст ей развалиться совсем.</div>
</section>
</div></body></html>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 100" width="220" height="100" role="img" aria-label="SelfPost">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M10.20 8.00 L12.83 8.00 A2.2 2.2 0 0 0 17.23 8.00 L19.87 8.00 A2.2 2.2 0 0 0 24.27 8.00 L26.90 8.00 A2.2 2.2 0 0 0 31.30 8.00 L33.94 8.00 A2.2 2.2 0 0 0 38.34 8.00 L40.97 8.00 A2.2 2.2 0 0 0 45.37 8.00 L48.01 8.00 A2.2 2.2 0 0 0 52.41 8.00 L55.04 8.00 A2.2 2.2 0 0 0 59.44 8.00 L62.08 8.00 A2.2 2.2 0 0 0 66.48 8.00 L69.11 8.00 A2.2 2.2 0 0 0 73.51 8.00 L76.14 8.00 A2.2 2.2 0 0 0 80.54 8.00 L83.18 8.00 A2.2 2.2 0 0 0 87.58 8.00 L90.21 8.00 A2.2 2.2 0 0 0 94.61 8.00 L97.25 8.00 A2.2 2.2 0 0 0 101.65 8.00 L104.28 8.00 A2.2 2.2 0 0 0 108.68 8.00 L111.32 8.00 A2.2 2.2 0 0 0 115.72 8.00 L118.35 8.00 A2.2 2.2 0 0 0 122.75 8.00 L125.39 8.00 A2.2 2.2 0 0 0 129.79 8.00 L132.42 8.00 A2.2 2.2 0 0 0 136.82 8.00 L139.46 8.00 A2.2 2.2 0 0 0 143.86 8.00 L146.49 8.00 A2.2 2.2 0 0 0 150.89 8.00 L153.52 8.00 A2.2 2.2 0 0 0 157.92 8.00 L160.56 8.00 A2.2 2.2 0 0 0 164.96 8.00 L167.59 8.00 A2.2 2.2 0 0 0 171.99 8.00 L174.63 8.00 A2.2 2.2 0 0 0 179.03 8.00 L181.66 8.00 A2.2 2.2 0 0 0 186.06 8.00 L188.70 8.00 A2.2 2.2 0 0 0 193.10 8.00 L195.73 8.00 A2.2 2.2 0 0 0 200.13 8.00 L202.77 8.00 A2.2 2.2 0 0 0 207.17 8.00 L209.80 8.00 A2.2 2.2 0 0 0 212.00 10.20 L212.00 12.80 A2.2 2.2 0 0 0 212.00 17.20 L212.00 19.80 A2.2 2.2 0 0 0 212.00 24.20 L212.00 26.80 A2.2 2.2 0 0 0 212.00 31.20 L212.00 33.80 A2.2 2.2 0 0 0 212.00 38.20 L212.00 40.80 A2.2 2.2 0 0 0 212.00 45.20 L212.00 47.80 A2.2 2.2 0 0 0 212.00 52.20 L212.00 54.80 A2.2 2.2 0 0 0 212.00 59.20 L212.00 61.80 A2.2 2.2 0 0 0 212.00 66.20 L212.00 68.80 A2.2 2.2 0 0 0 212.00 73.20 L212.00 75.80 A2.2 2.2 0 0 0 212.00 80.20 L212.00 82.80 A2.2 2.2 0 0 0 212.00 87.20 L212.00 89.80 A2.2 2.2 0 0 0 209.80 92.00 L207.17 92.00 A2.2 2.2 0 0 0 202.77 92.00 L200.13 92.00 A2.2 2.2 0 0 0 195.73 92.00 L193.10 92.00 A2.2 2.2 0 0 0 188.70 92.00 L186.06 92.00 A2.2 2.2 0 0 0 181.66 92.00 L179.03 92.00 A2.2 2.2 0 0 0 174.63 92.00 L171.99 92.00 A2.2 2.2 0 0 0 167.59 92.00 L164.96 92.00 A2.2 2.2 0 0 0 160.56 92.00 L157.92 92.00 A2.2 2.2 0 0 0 153.52 92.00 L150.89 92.00 A2.2 2.2 0 0 0 146.49 92.00 L143.86 92.00 A2.2 2.2 0 0 0 139.46 92.00 L136.82 92.00 A2.2 2.2 0 0 0 132.42 92.00 L129.79 92.00 A2.2 2.2 0 0 0 125.39 92.00 L122.75 92.00 A2.2 2.2 0 0 0 118.35 92.00 L115.72 92.00 A2.2 2.2 0 0 0 111.32 92.00 L108.68 92.00 A2.2 2.2 0 0 0 104.28 92.00 L101.65 92.00 A2.2 2.2 0 0 0 97.25 92.00 L94.61 92.00 A2.2 2.2 0 0 0 90.21 92.00 L87.58 92.00 A2.2 2.2 0 0 0 83.18 92.00 L80.54 92.00 A2.2 2.2 0 0 0 76.14 92.00 L73.51 92.00 A2.2 2.2 0 0 0 69.11 92.00 L66.48 92.00 A2.2 2.2 0 0 0 62.08 92.00 L59.44 92.00 A2.2 2.2 0 0 0 55.04 92.00 L52.41 92.00 A2.2 2.2 0 0 0 48.01 92.00 L45.37 92.00 A2.2 2.2 0 0 0 40.97 92.00 L38.34 92.00 A2.2 2.2 0 0 0 33.94 92.00 L31.30 92.00 A2.2 2.2 0 0 0 26.90 92.00 L24.27 92.00 A2.2 2.2 0 0 0 19.87 92.00 L17.23 92.00 A2.2 2.2 0 0 0 12.83 92.00 L10.20 92.00 A2.2 2.2 0 0 0 8.00 89.80 L8.00 87.20 A2.2 2.2 0 0 0 8.00 82.80 L8.00 80.20 A2.2 2.2 0 0 0 8.00 75.80 L8.00 73.20 A2.2 2.2 0 0 0 8.00 68.80 L8.00 66.20 A2.2 2.2 0 0 0 8.00 61.80 L8.00 59.20 A2.2 2.2 0 0 0 8.00 54.80 L8.00 52.20 A2.2 2.2 0 0 0 8.00 47.80 L8.00 45.20 A2.2 2.2 0 0 0 8.00 40.80 L8.00 38.20 A2.2 2.2 0 0 0 8.00 33.80 L8.00 31.20 A2.2 2.2 0 0 0 8.00 26.80 L8.00 24.20 A2.2 2.2 0 0 0 8.00 19.80 L8.00 17.20 A2.2 2.2 0 0 0 8.00 12.80 L8.00 10.20 A2.2 2.2 0 0 0 10.20 8.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.7" stroke-linejoin="round"/>
<rect x="15" y="15" width="190" height="70" fill="#7A3B2E"/>
<text x="110.0" y="66" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="34" letter-spacing="-1.2" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 330 150" width="330" height="150" role="img" aria-label="SelfPost — self-hosted SMTP relay">
<!-- SelfPost · https://codeberg.org/mix/selfpost · AGPL-3.0
Шрифт: IBM Plex Sans (OFL). Перед публикацией текст перевести в контуры. -->
<path d="M24.70 14.00 L27.97 14.00 A2.7 2.7 0 0 0 33.37 14.00 L36.63 14.00 A2.7 2.7 0 0 0 42.03 14.00 L45.30 14.00 A2.7 2.7 0 0 0 50.70 14.00 L53.97 14.00 A2.7 2.7 0 0 0 59.37 14.00 L62.63 14.00 A2.7 2.7 0 0 0 68.03 14.00 L71.30 14.00 A2.7 2.7 0 0 0 76.70 14.00 L79.97 14.00 A2.7 2.7 0 0 0 85.37 14.00 L88.63 14.00 A2.7 2.7 0 0 0 94.03 14.00 L97.30 14.00 A2.7 2.7 0 0 0 102.70 14.00 L105.97 14.00 A2.7 2.7 0 0 0 111.37 14.00 L114.63 14.00 A2.7 2.7 0 0 0 120.03 14.00 L123.30 14.00 A2.7 2.7 0 0 0 128.70 14.00 L131.97 14.00 A2.7 2.7 0 0 0 137.37 14.00 L140.63 14.00 A2.7 2.7 0 0 0 146.03 14.00 L149.30 14.00 A2.7 2.7 0 0 0 154.70 14.00 L157.97 14.00 A2.7 2.7 0 0 0 163.37 14.00 L166.63 14.00 A2.7 2.7 0 0 0 172.03 14.00 L175.30 14.00 A2.7 2.7 0 0 0 180.70 14.00 L183.97 14.00 A2.7 2.7 0 0 0 189.37 14.00 L192.63 14.00 A2.7 2.7 0 0 0 198.03 14.00 L201.30 14.00 A2.7 2.7 0 0 0 206.70 14.00 L209.97 14.00 A2.7 2.7 0 0 0 215.37 14.00 L218.63 14.00 A2.7 2.7 0 0 0 224.03 14.00 L227.30 14.00 A2.7 2.7 0 0 0 232.70 14.00 L235.97 14.00 A2.7 2.7 0 0 0 241.37 14.00 L244.63 14.00 A2.7 2.7 0 0 0 250.03 14.00 L253.30 14.00 A2.7 2.7 0 0 0 258.70 14.00 L261.97 14.00 A2.7 2.7 0 0 0 267.37 14.00 L270.63 14.00 A2.7 2.7 0 0 0 276.03 14.00 L279.30 14.00 A2.7 2.7 0 0 0 284.70 14.00 L287.97 14.00 A2.7 2.7 0 0 0 293.37 14.00 L296.63 14.00 A2.7 2.7 0 0 0 302.03 14.00 L305.30 14.00 A2.7 2.7 0 0 0 308.00 16.70 L308.00 20.01 A2.7 2.7 0 0 0 308.00 25.41 L308.00 28.73 A2.7 2.7 0 0 0 308.00 34.13 L308.00 37.44 A2.7 2.7 0 0 0 308.00 42.84 L308.00 46.16 A2.7 2.7 0 0 0 308.00 51.56 L308.00 54.87 A2.7 2.7 0 0 0 308.00 60.27 L308.00 63.59 A2.7 2.7 0 0 0 308.00 68.99 L308.00 72.30 A2.7 2.7 0 0 0 308.00 77.70 L308.00 81.01 A2.7 2.7 0 0 0 308.00 86.41 L308.00 89.73 A2.7 2.7 0 0 0 308.00 95.13 L308.00 98.44 A2.7 2.7 0 0 0 308.00 103.84 L308.00 107.16 A2.7 2.7 0 0 0 308.00 112.56 L308.00 115.87 A2.7 2.7 0 0 0 308.00 121.27 L308.00 124.59 A2.7 2.7 0 0 0 308.00 129.99 L308.00 133.30 A2.7 2.7 0 0 0 305.30 136.00 L302.03 136.00 A2.7 2.7 0 0 0 296.63 136.00 L293.37 136.00 A2.7 2.7 0 0 0 287.97 136.00 L284.70 136.00 A2.7 2.7 0 0 0 279.30 136.00 L276.03 136.00 A2.7 2.7 0 0 0 270.63 136.00 L267.37 136.00 A2.7 2.7 0 0 0 261.97 136.00 L258.70 136.00 A2.7 2.7 0 0 0 253.30 136.00 L250.03 136.00 A2.7 2.7 0 0 0 244.63 136.00 L241.37 136.00 A2.7 2.7 0 0 0 235.97 136.00 L232.70 136.00 A2.7 2.7 0 0 0 227.30 136.00 L224.03 136.00 A2.7 2.7 0 0 0 218.63 136.00 L215.37 136.00 A2.7 2.7 0 0 0 209.97 136.00 L206.70 136.00 A2.7 2.7 0 0 0 201.30 136.00 L198.03 136.00 A2.7 2.7 0 0 0 192.63 136.00 L189.37 136.00 A2.7 2.7 0 0 0 183.97 136.00 L180.70 136.00 A2.7 2.7 0 0 0 175.30 136.00 L172.03 136.00 A2.7 2.7 0 0 0 166.63 136.00 L163.37 136.00 A2.7 2.7 0 0 0 157.97 136.00 L154.70 136.00 A2.7 2.7 0 0 0 149.30 136.00 L146.03 136.00 A2.7 2.7 0 0 0 140.63 136.00 L137.37 136.00 A2.7 2.7 0 0 0 131.97 136.00 L128.70 136.00 A2.7 2.7 0 0 0 123.30 136.00 L120.03 136.00 A2.7 2.7 0 0 0 114.63 136.00 L111.37 136.00 A2.7 2.7 0 0 0 105.97 136.00 L102.70 136.00 A2.7 2.7 0 0 0 97.30 136.00 L94.03 136.00 A2.7 2.7 0 0 0 88.63 136.00 L85.37 136.00 A2.7 2.7 0 0 0 79.97 136.00 L76.70 136.00 A2.7 2.7 0 0 0 71.30 136.00 L68.03 136.00 A2.7 2.7 0 0 0 62.63 136.00 L59.37 136.00 A2.7 2.7 0 0 0 53.97 136.00 L50.70 136.00 A2.7 2.7 0 0 0 45.30 136.00 L42.03 136.00 A2.7 2.7 0 0 0 36.63 136.00 L33.37 136.00 A2.7 2.7 0 0 0 27.97 136.00 L24.70 136.00 A2.7 2.7 0 0 0 22.00 133.30 L22.00 129.99 A2.7 2.7 0 0 0 22.00 124.59 L22.00 121.27 A2.7 2.7 0 0 0 22.00 115.87 L22.00 112.56 A2.7 2.7 0 0 0 22.00 107.16 L22.00 103.84 A2.7 2.7 0 0 0 22.00 98.44 L22.00 95.13 A2.7 2.7 0 0 0 22.00 89.73 L22.00 86.41 A2.7 2.7 0 0 0 22.00 81.01 L22.00 77.70 A2.7 2.7 0 0 0 22.00 72.30 L22.00 68.99 A2.7 2.7 0 0 0 22.00 63.59 L22.00 60.27 A2.7 2.7 0 0 0 22.00 54.87 L22.00 51.56 A2.7 2.7 0 0 0 22.00 46.16 L22.00 42.84 A2.7 2.7 0 0 0 22.00 37.44 L22.00 34.13 A2.7 2.7 0 0 0 22.00 28.73 L22.00 25.41 A2.7 2.7 0 0 0 22.00 20.01 L22.00 16.70 A2.7 2.7 0 0 0 24.70 14.00 Z" fill="#F3EDE1" stroke="#12161C" stroke-width="0.8" stroke-linejoin="round"/>
<rect x="31" y="23" width="268" height="104" fill="#7A3B2E"/>
<text x="165.0" y="82" text-anchor="middle" font-family="'IBM Plex Sans','Helvetica Neue',sans-serif" font-size="38" letter-spacing="-1.4" fill="#F6EDE2"><tspan font-weight="200">Self</tspan><tspan font-weight="600">Post</tspan></text>
<path d="M137.0 98 H193.0" stroke="#F6EDE2" stroke-width="1" opacity=".5"/>
<text x="165.0" y="115" text-anchor="middle" font-family="'IBM Plex Mono',monospace" font-size="7.2" letter-spacing="2.8" fill="#F6EDE2" opacity=".78">SELF-HOSTED SMTP RELAY</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+248
View File
@@ -0,0 +1,248 @@
# План документации SelfPost
**Зачем этот файл.** Документация — часть поставки (ТЗ раздел 11, пп. 5, 7, 9),
а не сопроводительный текст. Перед тегом релиза она обязана описывать **то, что
делает код**, а не то, что задумывалось: расхождение здесь — такой же дефект,
как несоответствие ТЗ, только обнаруживает его пользователь на своём проде.
План описывает, из чего состоит пакет, как он сверяется с кодом, что уже
разошлось (первый проход выполнен, результаты ниже) и что с этим делать.
**Место в общем плане:** документационный проход идёт вместе с **D.5**
([implementation-plan.md](implementation-plan.md)) — до тега следующего релиза,
после B.1–B.3 и C.4, потому что именно они изменили поведение, которое README
описывает (сессии, ротация лога, обязательность `SELFPOST_HOSTNAME`).
**Модель:** документация → Sonnet (правило [progress.md](progress.md)).
Исключение — **D6** (HEALTHCHECK/эндпоинт мониторинга) и формулировки про
`TRUSTED_PROXY_CIDR`: инфра/безопасность → Opus.
---
## 1. Состав пакета
### Поставляемое пользователю (обязательно по ТЗ)
| Артефакт | Требование | Состояние |
|---|---|---|
| [README.md](../README.md) | ТЗ 11 п. 7: установка, требования к площадке, per-domain DNS, прогрев IP, **эксплуатация**, бэкап/восстановление и экспорт/импорт домена (оба файла — секреты), репозиторий (Codeberg основной / GitHub зеркало), откуда образ (`ghcr.io`), фиксированный тег, минимальные требования к машине, лицензия в 2–3 предложениях | Есть всё, **кроме раздела «эксплуатация»**; см. находки 1–4, 10 |
| [LICENSE](../LICENSE) | ТЗ 11 п. 9: полный текст AGPL-3.0 | Полный текст на месте, правок не требует |
| [deploy/docker-compose.yml](../deploy/docker-compose.yml) + фрагменты прокси | ТЗ 11 п. 5, ТЗ 10 п. 3: Apache основной, nginx/Caddy/Traefik альтернативами; для каждого — что монтируется и в какие пути | Все четыре есть ([apache](../deploy/apache/), [nginx](../deploy/nginx/), [caddy](../deploy/caddy/), [traefik](../deploy/traefik/)), в README сведены таблицей; см. находки 7, 8 |
| [deploy/.env.example](../deploy/.env.example) | Пользовательские переменные с пояснениями | Есть 6 переменных; полного справочника нет — находка 3 |
| [CHANGELOG.md](../CHANGELOG.md) | Keep a Changelog, запись на каждом осмысленном шаге | Ведётся; `[Unreleased]` наполнен B.1–B.3, C.4 |
### Рабочие документы проекта (не поставка, но обязаны быть верны)
[specification.md](specification.md) — ТЗ v1.0, источник истины по требованиям.
[implementation-plan.md](implementation-plan.md) — открытые вопросы и линия 2.x.
[progress.md](progress.md) — живой трекер, читается первым после `/clear`.
[security.md](security.md) — принятые риски. Этот файл — план по документации.
**Границы:** отдельные `CONTRIBUTING.md`, `docs/architecture.md`, man-страницы и
сайт документации ТЗ **не требует** и в объём v1.x не входят. Dev-петля (сборка
и тесты на dev-сервере) остаётся в `progress.md` и в память проекта — выносить
её в поставляемую документацию не нужно.
---
## 2. Метод сверки с кодом
Правило: у каждого утверждения в документации есть **ровно один источник истины
в дереве**, и сверка идёт от кода к тексту (что код делает → сказано ли об
этом), а не наоборот — иначе не видно того, что забыли описать.
| Класс утверждений | Источник истины |
|---|---|
| Переменные окружения, значения по умолчанию | `loadConfig`/`envDefault`/`envInt` — [cmd/panel/main.go:80](../cmd/panel/main.go:80)–159; `${VAR:-default}` в [build/](../build/) (`postfix-config.sh`, `postfix-wrapper.sh`, `postfix-cert-reload.sh`, `logrotate-loop.sh`, `entrypoint.sh`) |
| Поведение почтового тракта (порты, TLS, SASL, анти-relay, лимиты, milter-цепочка) | [build/postfix-config.sh](../build/postfix-config.sh) |
| Экраны и действия панели (что вообще можно делать в эксплуатации) | таблица маршрутов [internal/web/web.go:133](../internal/web/web.go:133)–190 |
| Бэкап/восстановление, экспорт/импорт домена, проверка версии | [internal/backup/backup.go](../internal/backup/backup.go), [cmd/selfpost-backup/main.go](../cmd/selfpost-backup/main.go) |
| Сессии, вход, смена пароля | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go), [internal/web/handlers_account.go](../internal/web/handlers_account.go) |
| Ротация лога, периодический reload, интервалы | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) |
| Деплой: тег образа, порты, монтирования, capabilities | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) |
| Требования, а не реализация (что вообще должно быть описано) | [specification.md](specification.md) разделы 9, 10, 11 |
Порядок прохода: сначала перечислить фактическое (env-ключи, маршруты,
`postconf`-настройки), потом искать каждый пункт в README — так находится и
неверное, и **отсутствующее**.
---
## 3. Результаты первого прохода (выполнен)
Сверено: env-переменные, маршруты панели, конфигурация Postfix, бэкап/CLI,
сессии, ротация, compose/Dockerfile. Ниже — всё найденное, по убыванию
приоритета. Нумерация сквозная, на неё ссылаются задачи в разделе 4.
**Высокий (пробел против ТЗ или вводит в заблуждение)**
1. **Нет раздела «эксплуатация»** — прямое требование ТЗ 11 п. 7
([specification.md:427](specification.md)). В README нет ни слова про
`/status` (проверки PTR/hostname), `/sendlog` (журнал отправки, фильтры),
`/queue` (очередь Postfix), `/logtail` (хвост `mail.log`), `/reload`,
`/account`, `/backup` — при том что всё это реализовано
([internal/web/web.go:152](../internal/web/web.go:152)–188). Не описана и
процедура апгрейда (бамп тега → `docker compose up -d`), хотя раздел
«Fixed image tag» на неё намекает.
2. **Битая ссылка + недокументированные лимиты.**
[deploy/.env.example:13](../deploy/.env.example:13) отсылает к разделу README
«Rate limiting», которого в README нет. Сам двухуровневый лимит не описан
нигде для пользователя: L1 — `smtpd_client_message_rate_limit` +
`anvil_rate_time_unit` ([build/postfix-config.sh:110](../build/postfix-config.sh:110)),
L2 — per-domain/per-app из панели
([internal/web/web.go:165](../internal/web/web.go:165),
[:169](../internal/web/web.go:169)), с записью `rejected` в журнал.
3. **Нет справочника переменных окружения.** В `.env.example` шесть штук; код
читает заметно больше. Панель: `SELFPOST_DATA_DIR`, `SELFPOST_DB_PATH`,
`SELFPOST_SETUP_TOKEN_FILE`, `PANEL_HTTP_ADDR`, `JOURNAL_MILTER_SOCKET`,
`MAIL_LOG`, `PANEL_COOKIE_SECURE`, `TLS_CERT_FILE`, `OPENDKIM_SOCKET`,
`OPENDKIM_DIR`, `DKIM_SELECTOR_DEFAULT`, `SASL_DB_PATH`, `SASL_REALM`,
`POSTFIX_DIR` ([cmd/panel/main.go:80](../cmd/panel/main.go:80)–127).
Обвязка: `TLS_KEY_FILE`, `POSTFIX_SENDER_LOGIN_MAPS`,
`MILTER_CONNECT_TIMEOUT`/`COMMAND`/`CONTENT` (15s/15s/30s,
[build/postfix-config.sh:133](../build/postfix-config.sh:133)),
`MILTER_WAIT_TIMEOUT`, `TLS_RELOAD_INTERVAL_SECONDS` (86400,
[build/postfix-cert-reload.sh:14](../build/postfix-cert-reload.sh:14)),
`LOGROTATE_INTERVAL_SECONDS` (21600,
[build/logrotate-loop.sh:17](../build/logrotate-loop.sh:17)).
Отдельно: **`TRUSTED_PROXY_CIDR`** — переменная с последствиями для
безопасности (доверие `X-Forwarded-For` при rate-limit логина), описана
только в `.env.example`, в README её нет вообще.
*Решение, которое надо принять в задаче D2:* какие переменные публичные
(таблица в README), а какие внутренние (достаточно комментария в коде) —
документировать все 20+ вредно, они станут «поддерживаемым интерфейсом».
4. **Оговорка ТЗ 9 про прямой `tar` не отражена.**
[specification.md:375](specification.md) требует описать оба пути: «бэкап на
лету — панель/CLI» и «прямой `tar` директории безопасен, если контейнер
остановлен». В README ([Backup, restore…](../README.md)) есть только первый,
поэтому у читателя нет ответа на очевидный вопрос «а можно просто
заархивировать `./data`?».
**Средний (стало неверным после последних фаз)**
5. **Статус-баннер устарел.** [README.md:12](../README.md:12)–15: «under active
development» и ссылка на `implementation-plan.md` как на «phased build plan»
— фазы 0→14 закрыты, план теперь про открытые вопросы и линию 2.x. Перед
тегом релиза баннер переформулировать (или снять), ссылки уточнить.
6. **`implementation-plan.md` разошёлся с кодом.** Пункт B.1 говорит: «смена
пароля завершает **все** сессии, включая ту, из которой её делают → редирект
на `/login`». Код делает иначе — гасит все, **кроме** текущей
([internal/store/sessions.go:95](../internal/store/sessions.go:95)
`DeleteOtherSessions`, [internal/web/session.go:131](../internal/web/session.go:131)),
и панель так и пишет («Any other signed-in sessions were signed out»,
[internal/web/handlers_account.go:49](../internal/web/handlers_account.go:49)).
README здесь **верен**. Расхождение в плане — зафиксировать как осознанное
изменение при реализации, а не молча переписать.
7. **Комментарий в compose вводит в заблуждение.**
[deploy/docker-compose.yml:14](../deploy/docker-compose.yml:14) велит
«fill in .env (hostname, at least one strong TLS_CERT/KEY path)», но
`TLS_CERT_FILE`/`TLS_KEY_FILE` захардкожены в самом файле
([:30](../deploy/docker-compose.yml:30)–31) и в `.env.example` отсутствуют —
настраивается на деле **bind mount** `./certs`, а не переменные.
8. **Порт 587 публикуется всегда** ([deploy/docker-compose.yml:61](../deploy/docker-compose.yml:61)),
хотя listener появляется только при `SUBMISSION_ENABLE=true`
([build/postfix-config.sh:151](../build/postfix-config.sh:151)). Безвредно
(слушать некому), но выглядит как лишний открытый порт — одна строка
пояснения в README/compose снимает вопрос.
**Низкий / требует решения, а не только текста**
9. **`/healthz` есть, `HEALTHCHECK` нет.** Эндпоинт реализован
([internal/web/web.go:133](../internal/web/web.go:133)), в
[build/Dockerfile](../build/Dockerfile) объявления `HEALTHCHECK` нет (пробел
отмечен и в плане, п. B.3). Решить в D6: либо задокументировать `/healthz`
как точку внешнего мониторинга, либо добавить `HEALTHCHECK` в образ (это уже
код + строка в CHANGELOG). Документировать «здоровье контейнера» до принятия
решения нельзя — получится обещание, которого образ не даёт.
10. **Из B.1–B.3/C.4 в README попала только обязательность `SELFPOST_HOSTNAME`.**
Не описаны: сессия переживает рестарт и живёт по скользящему сроку
бездействия (`PANEL_SESSION_IDLE_DAYS`, «вкладка с автообновлением вход не
продлевает» — контринтуитивно и заслуживает строки), ротация `mail.log`
(14 файлов, проверка каждые 6 ч, суточный `postfix reload`) — README
упоминает только «kept 14 days in-image» в требованиях к машине.
11. **Мелочи, без правки кода:** Quick start тянет файлы с
`raw.githubusercontent.com` (зеркало), хотя основной репозиторий —
Codeberg; тег образа `1.0.0` в compose ([:24](../deploy/docker-compose.yml:24))
придётся бампить в тот же коммит, что и релиз; пустой каталог `docs/logo`.
**Проверено и расхождений не найдено** (важно не переделывать): требования к
площадке; DNS-раздел (совпадает с тем, что реально проверяет `/status` и
DNS-карточка домена); прогрев IP; смысл фиксированного тега; проверка версии при
восстановлении — README описывает её точно так, как ведёт себя `CheckRestore`
([internal/backup/backup.go](../internal/backup/backup.go)); форма вызова
`docker exec … selfpost-backup > …` ([cmd/selfpost-backup/main.go:7](../cmd/selfpost-backup/main.go:7));
таблица reverse-proxy и требование пропускать `Host`; секретность архива и
экспорта домена; лицензия; ретенция журнала отправки (90 дней) и то, что она —
основной драйвер роста `/data`.
---
## 4. Задачи
Порядок — как перечислены; D1–D3 самые крупные. Каждая заканчивается записью в
`[Unreleased]` [CHANGELOG.md](../CHANGELOG.md) и коммитом.
**D1. README: раздел «Operations» + «Rate limiting».** Закрывает находки 1, 2 и
часть 10. Что описать: экраны панели и зачем они нужны (`/status` и что именно
он проверяет; журнал отправки со статусами `queued/sent/rejected`; очередь;
хвост `mail.log`; `/reload`; смена учётных данных); двухуровневый лимит с
именами переменных L1 и указанием, что L2 задаётся в панели per-domain/app;
процедура апгрейда версии; поведение сессии (скользящий срок, опросы не
продлевают, смена пароля гасит остальные). Тон и объём — как в существующих
разделах: практика, а не пересказ ТЗ.
*Готово, когда:* каждый маршрут из [internal/web/web.go:152](../internal/web/web.go:152)–188,
кроме HTMX-фрагментов, либо описан, либо сознательно опущен; ссылка
«Rate limiting» из `.env.example` ведёт в существующий якорь.
**D2. Справочник переменных окружения.** Закрывает находку 3. Сначала решение о
границе публичного набора, затем таблица (имя, назначение, дефолт, где
задаётся) в README + синхронизация `.env.example` и комментариев в compose.
`TRUSTED_PROXY_CIDR` описать с прямым предупреждением: неверное значение
позволяет подделать ключ rate-limit'а логина.
*Готово, когда:* каждый ключ из `loadConfig` и из `${VAR:-…}` в `build/*.sh`
попал либо в таблицу, либо в явный список «внутренние, не интерфейс»; дефолты в
таблице совпадают с кодом дословно.
**D3. Бэкап: дописать путь «остановленный контейнер + `tar`».** Закрывает
находку 4 — по формулировке [specification.md:375](specification.md), с явным
«на живом контейнере так делать не надо, потому что SQLite в WAL». Заодно
упомянуть, что `manifest.json` после успешного восстановления потребляется.
**D4. Точечные правки.** Находки 5, 7, 8, 11: баннер статуса и ссылки; комментарий
в шапке compose про `.env`; строка про 587; бамп тега образа (делается в
релизном коммите, не раньше).
**D5. Синхронизация внутренних документов.** Находка 6 — поправить B.1 в
`implementation-plan.md` (с пометкой, что решение изменилось при реализации и
почему), заодно перечитать `progress.md` на предмет утверждений, которые код уже
опроверг. Не переписывать историю в CHANGELOG.
**D6. Решение по `HEALTHCHECK`/`/healthz` (Opus).** Находка 9: либо строка в
`Dockerfile` + описание, либо только описание эндпоинта. Влияет на код образа —
поэтому отдельной задачей и не на Sonnet.
**D7. Регресс-защита (см. раздел 5).**
---
## 5. Чтобы не разошлось снова
1. **Правило шага:** изменение, добавляющее/переименовывающее env-переменную,
маршрут панели или наблюдаемое поведение почтового тракта, закрывается
только вместе с правкой README/`.env.example` — в том же коммите, наравне с
записью в CHANGELOG (протокол закрытия шага в [progress.md](progress.md)).
2. **Дешёвая машинная проверка (D7):** тест или скрипт, сверяющий множество
ключей `loadConfig` со списком, объявленным в документации, и падающий на
новом недокументированном ключе. Ловит самый частый класс расхождений
(находка 3) без ручного прохода. Границу «внутренних» ключей задать явным
списком-исключением в самом тесте.
3. **Перед каждым тегом** — короткий проход по разделу 2 этого файла (семь
источников истины), а не полная ревизия текста.
---
## 6. Гейт релиза
Документационный проход — часть того же гейта, что e2e (C.4) и ревизия
безопасности (D.5): **D1–D6 закрыты до тега**. D7 — желателен, но тег не
блокирует. Находки, обнаруженные позже, дописываются сюда, а не исправляются
молча: этот файл — журнал состояния документации, а не одноразовый список дел.
+174
View File
@@ -0,0 +1,174 @@
# План реализации: SelfPost
**Статус:** выполненные фазы здесь не описываются — текущее состояние в
[progress.md](progress.md), история сделанного в [CHANGELOG.md](../CHANGELOG.md)
и `git log`. Ниже остаётся только то, что **ещё не сделано**: открытые вопросы
для согласования и опциональная линия 2.x.x.
**Основа:** [specification.md](specification.md) v1.0.
---
## Открытые вопросы — требует внимания и обсуждения (перед фиксацией v1.0)
Ниже — то, что **выходит за букву ТЗ**, но заслуживает решения перед тем, как
считать v1.0 «финальным». Ничего из этого **не является дефектом
соответствия**; это осознанные компромиссы и потенциальные улучшения. Каждый
пункт — решение «делаем в v1.x / откладываем в 2.x / оставляем как есть»,
принимается пользователем.
**Раздел A (принятые риски безопасности) переехал в
[security.md](security.md)** — риск не задача, а решение, и в плане несделанной
работы ему делать нечего. Буквы разделов и сквозная нумерация пунктов ниже
оставлены как были: на них ссылаются `progress.md`, коммиты и обсуждения.
### B. Надёжность и эксплуатация
1. **Сессии — решено: хранить в БД, скользящий срок бездействия.** Прежнее поведение (только в памяти, absolute TTL 12 ч) заменяется на:
- таблица `sessions` в SQLite (миграция `0002`) — вход переживает рестарт, редеплой и восстановление из полного бэкапа. В БД лежит SHA-256 от токена, а не сам токен: украденный файл БД или архив бэкапа во вход не превращается, зато браузер, у которого есть исходная cookie, работает и после восстановления;
- срок — **скользящий, 7 дней бездействия**, задаётся `PANEL_SESSION_IDLE_DAYS` (целое число дней, как `SEND_LOG_RETENTION_DAYS`). Абсолютного потолка нет **сознательно**: у админа, заходящего регулярно, сессия живёт неограниченно долго;
- **мониторинговые опросы сессию не продлевают.** Четыре фрагмента (`/status/fragment`, `/queue/body`, `/logtail/body`, `/sendlog/rows`) опрашивают сервер `every 5s`; продлевай их — и забытая открытая вкладка держала бы вход вечно, а «7 дней бездействия» означало бы «7 дней без открытой вкладки». Активностью считается переход по странице или действие, то есть всё, кроме GET-запросов с заголовком `HX-Request`;
- `Max-Age` cookie равен сроку и переставляется ровно тогда, когда продлевается строка в БД (запись в БД — не чаще раза в час, чтобы не писать на каждый клик);
- смена пароля завершает **все** сессии, включая ту, из которой её делают → редирект на `/login`.
Известное свойство, вытекающее из хранения в БД: восстановление старого бэкапа возвращает и строки сессий, поэтому сессия, разлогиненная уже после снятия бэкапа, оживёт — если её браузер всё ещё хранит cookie и срок не истёк.
2. **Ротация `mail.log` — решено: отказаться от `copytruncate` в пользу «переименовать + `postfix reload`».** Прежняя формулировка («несколько строк мониторинга, приемлемо как известное свойство») занижала проблему: тот же тейлер, что рисует экран лога, сверяет и финальные статусы доставки — `UpdateStatus` вызывается **только** из [internal/logtail](../internal/logtail/logtail.go), больше ниоткуда. Значит потерянная строка `status=sent` — это строка журнала отправки, навсегда застрявшая в `queued`, то есть тихая порча данных, а не пробел в мониторинге. Окон потери при `copytruncate` два:
- **до одного интервала опроса (1 с) строк** — записанное после последнего `drain()` и до `truncate` физически сохранено в `mail.log.1`, но дескриптор тейлера смотрит на уже обрезанный inode и это пропускает. Это доминирующее окно;
- **миллисекунды между «`cp` дочитал до EOF» и `truncate`** — эти строки не попадают никуда; для `copytruncate` устранить нельзя.
**Решение** — ротация переименованием, ровно та механика, которую применяет сам Postfix в `postfix logrotate` (`mv`, затем `HUP` мастеру): rename атомарен, postlogd продолжает писать в переименованный inode до перезапуска, а тейлер держит дескриптор на том же inode и дочитывает хвост перед переключением на новый файл. Не теряется ничего ни на стороне записи, ни на стороне чтения. Правки:
- [build/logrotate-mail.conf](../build/logrotate-mail.conf): убрать `copytruncate`, добавить `postrotate /usr/sbin/postfix reload endscript`. `rotate 14`/`compress`/`delaycompress` остаются: удержание N файлов (ТЗ 9) — за logrotate, поэтому берётся не сам `postfix logrotate` (у него нет retention, он лишь переименовывает с меткой времени и жмёт), а его механика. **`create 0644 root root`, не `nocreate`** — см. стендовую проверку ниже, предположение о `nocreate` не подтвердилось;
- `follow()` в [internal/logtail/logtail.go](../internal/logtail/logtail.go): при обнаружении смены inode дочитать старый дескриптор ещё раз перед закрытием — иначе остаётся микроокно между `drain()` и проверкой смены файла. Проверка `ni.Size() < pos` сохраняется как страховка от обрезания посторонней схемой ротации, но перестаёт быть основным механизмом;
- `readLogTail()` в [internal/web/handlers_monitor.go](../internal/web/handlers_monitor.go): `fs.ErrNotExist` — не ошибка, а пустой экран. После rename файла нет, пока Postfix не запишет в него первую строку (порядка секунды в сутки), и баннер ошибки в этот момент — шум.
**Цена:** один `postfix reload` в сутки — ровно то, что уже делает [postfix-cert-reload.sh](../build/postfix-cert-reload.sh) ради сертификатов, никакой новой машинерии.
**Проверено на живом 1.0.0 до принятия решения:** Postfix 3.7.11 (команда `postfix logrotate` есть начиная с 3.4, её реализация в `postfix-script` — это `mv` + `master -t || kill -HUP` + `sleep 1` + компрессор); postlogd работает под uid `postfix`, `/var/log` принадлежит root, `/var/log/mail.log` — `root:root 0644` и пользователю `postfix` на запись недоступен; в образе файла нет — значит создаёт его привилегированная сторона.
**Стендовая проверка при реализации (обязательная по плану) обнаружила, что предположение о `nocreate` неверно.** На живом контейнере (`selfpost.example.com`, тестовый образ) после `mv` + `postfix reload` файл действительно появляется — но не сразу и не на `0644`: сам HUP лог не пересоздаёт, это происходит лениво при следующей фактической записи, и создаётся он с режимом `0600` — непривилегированная панель (свой uid) такой файл читать не может, то есть просмотр `mail.log` в панели остаётся сломан до следующего холодного старта контейнера (там `0644` берётся из другого, не связанного с этим, пути создания). Решение — то, что план заранее указал как запасной вариант: `create 0644 root root` вместо `nocreate`. logrotate создаёт пустой файл на 644 сразу после `mv`, ещё до запуска `postrotate`, и Postfix при следующей записи просто открывает и дозаписывает уже существующий файл, не трогая его режим. Проверено многократно на стенде: после ротации файл сразу (без окна) читаем непривилегированным uid панели, и остаётся на 644 после того, как в него попадает новый трафик.
**Смежное, не решённое (тот же класс потерь, вариантом выше не лечится):** при рестарте панели `follow()` стартует с конца файла, поэтому строки, записанные пока она не читала, пропускаются; при редеплое `mail.log` исчезает вместе с контейнером — `/var/log` не в volume. В обоих случаях статусы писем, бывших в полёте, остаются `queued` навсегда — вероятно, чаще, чем при ротации. Кандидаты, если решим закрывать: переживать рестарт (запоминать позицию), вынести лог в `/data`, либо досверять зависшие строки по `postqueue`.
3. **Поведение при незаданном `SELFPOST_HOSTNAME` — решено: фатальная проверка в [entrypoint.sh](../build/entrypoint.sh) с развёрнутым текстом ошибки.** Прежнее предложение («предупреждать громко в лог, но не падать») отклонено: оба отказа мягкого fallback'а тихие и отложенные, а предупреждение в лог для таких отказов не работает — оно печатается при старте, а последствие проявляется через часы и в другом месте.
**Что на самом деле ломает мягкий fallback** (формулировка «падают в `localhost`» занижала проблему — два fallback'а расходятся между собой):
- панель берёт realm как `SASL_REALM` → `SELFPOST_HOSTNAME` → **`localhost`** ([main.go:128](../cmd/panel/main.go:128)), а `postfix-config.sh` берёт `myhostname` как `SELFPOST_HOSTNAME` → **`hostname -f`**, то есть ID контейнера ([postfix-config.sh:21](../build/postfix-config.sh:21)). При пустом `smtpd_sasl_local_domain` Cyrus резолвит голый логин против realm = `myhostname` (механика проверена на стенде в Фазе 5), значит аккаунты пишутся в один realm, а ищутся в другом → **аутентификация не работает ни для одного приложения**, при полностью зелёной панели, а видно это только как `535` в чужом приложении. Само расхождение выведено из проверенной механики, отдельно не воспроизводилось;
- `EHLO` = ID контейнера: не FQDN, не совпадает с PTR, SPF-проверка HELO падает → почта, если она всё же уходит, попадает в спам. Отказ, не видимый вообще нигде;
- побочная ловушка того же класса: `SASL_REALM` читает только панель, `postfix-config.sh` про него не знает — задать один `SASL_REALM` без `SELFPOST_HOSTNAME` ломает так же.
**Почему фатально, а не мягко.** Это не настройка с разумным умолчанием, а идентичность, обязанная одновременно совпасть с PTR/rDNS, с CN/SAN сертификата и с SASL-realm (ТЗ 5.2 п.3, 8) — значения, удовлетворяющего всем трём, угадать нельзя, поэтому **любой** fallback заведомо неверен. Ужесточением это не является: штатный деплой уже требует переменную (`${SELFPOST_HOSTNAME:?...}`, [docker-compose.yml:27](../deploy/docker-compose.yml:27)), CI контейнер не поднимает ([test.yml](../.github/workflows/test.yml) — только `vet`/`test`), а локальный запуск стоит одной явной переменной (`SELFPOST_HOSTNAME=localhost`). Меняется поведение только вне поставляемого compose (`docker run`, k8s, свой compose).
**Правки:**
- `entrypoint.sh`: проверка до `postfix-config.sh` и до `supervisord`; при пустом значении — `exit 1`. Текст ошибки развёрнутый, а не `SELFPOST_HOSTNAME is required`: что это за имя, почему обязательно (PTR + CN/SAN + SASL realm), пример значения, где задаётся (`.env`). Это и есть замена «баннера в панели» — объяснительность там, где её реально прочитают (вывод `docker compose up`);
- там же — синтаксическая проверка значения: минимум одна точка, без схемы, порта и пробелов. Ловит типовые `https://mail.example.com` и `mail.example.com:465`, которые realm не ломают (обе стороны берут одну переменную), но ломают HELO и совпадение с сертификатом, то есть дают тот же тихий спам-отказ;
- `saslRealm()` ([main.go:128](../cmd/panel/main.go:128)) и fallback в `postfix-config.sh` **оставляем как есть**: после гейта в контейнере эти ветки мертвы, а вне контейнера (запуск бинаря локально, без Postfix) расходиться не с чем. Существующие «(SELFPOST_HOSTNAME is not set)» на статус-странице и `PTR: unknown` ([server.go:21](../internal/dnscheck/server.go:21)) тоже остаются — они как раз про этот случай.
**Почему отклонён вариант «панель поднимается с баннером, фатально только для почтового тракта»** (обсуждался как более мягкий):
- он отменяет инвариант Фазы 4 — listener `crashexit` роняет весь контейнер, когда любой managed-процесс уходит в FATAL, именно чтобы не оставался «живой контейнер с мёртвым компонентом» ([crashexit.py](../build/crashexit.py)). В наивной реализации он в фатальный вариант и вырождается, только на минуту позже и с тремя циклами ретраев в логе;
- у него нет обычного оправдания degraded-режима — «починить на живую». Для TLS-сертификата degraded-режим выбран сознательно (файл можно доложить в mount, `cert-reload` подхватит без рестарта), а hostname вшивается в `myhostname` при генерации конфига, поэтому лечение всё равно = правка `.env` + пересоздание контейнера;
- главное: в таком состоянии панель остаётся полноценным писателем состояния, и состояние будет неверным. Созданные аккаунты лягут в realm `localhost`, а после задания hostname и рестарта `Secret()` и `Delete` ищут по паре (login, realm) ([sasl.go:97](../internal/app/sasl.go:97), [sasl.go:67](../internal/app/sasl.go:67)) → экспорт их не видит, удаление приложения оставляет сироту в `sasldb2` навсегда, а в панели они выглядят существующими. Чтобы это было безопасно, пришлось бы ещё блокировать записывающие действия — третий режим работы вместо одной проверки;
- канал доставки баннера испорчен ровно тем условием, о котором он предупреждает: адрес панели на первом запуске берётся из setup-ссылки, а она в этом сценарии печатается как `https://localhost/setup/<token>` ([setup.go:124](../internal/web/setup.go:124));
- `HEALTHCHECK` в образе не объявлен, поэтому «панель жива, почта мертва» для внешнего мониторинга выглядит здоровым контейнером, а crash-loop виден любой системе.
**Цена:** контейнер без `SELFPOST_HOSTNAME` не стартует — это и есть цель. **Проверка на стенде при реализации:** контейнер без переменной падает с ожидаемым текстом и не уходит в бесконечный тихий retry; обычный деплой из [deploy/docker-compose.yml](../deploy/docker-compose.yml) не меняется; значение с портом/схемой отклоняется. Обязательность отразить в [README](../README.md) и `deploy/.env.example` (там переменная уже первая в списке).
### C. CI и тесты
4. **Интеграционный e2e — решено: герметичный контейнерный прогон отдельным Go-модулем, гейт перед публикацией образа.** Контейнерные e2e каждой фазы прогонялись вручную и задокументированы в git-истории; автоматизируется по сути тот же сценарий.
**Что закрывается.** Не «интеграция вообще», а один класс отказов — **обвязка контейнера**, невидимая для `go test`: все 22 тестовых файла фейкуют границу процесса (`saslpasswd2` подменён хуком `s.run` — [sasl_test.go](../internal/app/sasl_test.go), milter гоняется против `fakeRecorder`, web — через `httptest`), реальные Postfix/OpenDKIM/supervisord/sasldb2 не стартуют нигде. Исторически ломалось ровно здесь: chroot ломал DNS ([postfix-config.sh:168](../build/postfix-config.sh:168)), Postfix не доставал до milter-сокетов (общая группа + setgid, [entrypoint.sh:62](../build/entrypoint.sh:62)), расходился SASL-realm (`535` для всех приложений), reload Postfix сигналом не работал (потребовалась one-shot program), а недостаточный `cap_add` в [docker-compose.yml](../deploy/docker-compose.yml) уронил контейнер **в проде**. Общее свойство всех пяти: панель зелёная, юнит-тесты зелёные, тракт мёртв.
**Что автоматизировать нельзя и не пытаемся:** реальные PTR/rDNS, сертификат LE, репутация IP, доставка во внешний ящик (исходящий 25 на GitHub-раннерах закрыт). Это остаётся ручной проверкой на проде.
**Форма:**
- `test/e2e/` — **отдельный Go-модуль со своим `go.mod`**: `go test ./...` не подхватывает его без build-тегов, а тестовые зависимости (проверка DKIM-подписи и прочее) не попадают в граф основного модуля, где сейчас три прямых зависимости;
- стенд — **поставляемый [deploy/docker-compose.yml](../deploy/docker-compose.yml) плюс override**, а не отдельный тестовый compose: иначе `cap_drop`/`cap_add`/`no-new-privileges` — ровно то, что сломалось в проде, — останутся непроверенными. Override задаёт самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, тестовый `SELFPOST_HOSTNAME`, заниженный `RATE_LIMIT_MESSAGES_PER_IP`, резолвер (`dns:`) и **высокие порты вместо 465/587/8080** — на dev-сервере они заняты продом, и без этого `make e2e` там падал бы на конфликте портов, работая при этом в CI;
- герметичная почта: фиктивная DNS-зона (CoreDNS/dnsmasq) + sink-MX на `smtp-sink` из пакета postfix — новых зависимостей ноль. DKIM-запись тест **берёт из панели и сам публикует в зону**, поэтому попутно проверяется, что запись, которую панель печатает пользователю, вообще рабочая;
- детерминизм обязателен: никаких `sleep N`, только опрос с таймаутом. Плавающий гейт перестают чинить, и тогда он хуже отсутствующего.
**Объём проверок.** Позитив: старт контейнера (автостартующие `opendkim`/`panel`/`postfix`/`cert-reload`/`logrotate` в `RUNNING`, `postfix-reload` — `NOT STARTED`, он `autostart=false`) → setup по токену из `/data/setup-token` → login → домен → приложение → SMTP AUTH на 465 → письмо доставлено на sink → подпись проверяется против ключа из зоны → строка журнала переходит `queued → sent` (это же покрывает `logtail`, а после B.2 — и ротацию). Негативы:
1. отправка без AUTH — отказ;
2. чужой отправитель при валидном AUTH — отказ (`reject_sender_login_mismatch`, ТЗ 5.1 п.3);
3. relay на чужой домен — отказ (`reject_unauth_destination`), прямая проверка «не open relay»;
4. L1-лимит по IP (заниженный в override) — отказ после исчерпания;
5. L2-лимит, выставленный **через панель**, с записью `rejected` — заодно путь панель→БД→milter;
6. fail-open journal-milter'а: `supervisorctl stop panel` → письмо всё равно принято, контейнер жив. Это выполнимо, потому что `crashexit` подписан только на `PROCESS_STATE_FATAL` ([supervisord.conf:122](../build/supervisord.conf:122)), а штатный stop даёт `STOPPED`;
7. пустой и синтаксически неверный `SELFPOST_HOSTNAME` — контейнер падает с ожидаемым текстом (проверка из B.3);
8. сессия переживает `docker restart` (проверка из B.1).
**Вне объёма:** «зависший», а не упавший milter — требует подставного сокета внутри контейнера, это уже chaos-тест ради одного таймаута.
**Запуск.** Основной путь — `make e2e` на dev-сервере перед тегированием (там и так идёт вся сборка) плюс `workflow_dispatch`. В CI прогон вешается **на тег `vX.Y.Z` и блокирует публикацию образа**; на обычный push не вешается — `vet`/`test` в [test.yml](../.github/workflows/test.yml) остаются как есть. Уведомления специально не настраиваются: признак провала — отсутствие образа в `ghcr` после тега, смотрится вкладкой Actions.
**Переработка [release.yml](../.github/workflows/release.yml)** — следствие требования покрыть обе архитектуры. Сейчас multi-arch собирается через qemu; гонять под эмуляцией полный стек Postfix мучительно долго, поэтому релиз переезжает на нативную сборку по архитектурам: job `prepare` (единственная точка деривации версии из тега — на ней держится инвариант ТЗ 7.5.А) → матрица `[ubuntu-latest, ubuntu-24.04-arm]`, в каждой сборка `--load` → e2e → push per-arch тега `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge`: `docker buildx imagetools create -t …:X.Y.Z`. `setup-qemu-action` уходит, `provenance: false` сохраняется. Порядок «сначала тест, потом push» выбран ради того, чтобы публиковались **ровно те байты, которые прогонялись**; альтернатива (push по digest → тест → сборка манифеста) даёт ту же гарантию, но оставляет в registry мусорные untagged-манифесты после красного прогона и требует второго пути для `workflow_dispatch`. Per-arch теги остаются в registry побочным продуктом; неизменяемость версионного тега (ТЗ 10.1) это не нарушает. Бесплатные arm-раннеры доступны, потому что зеркало `github.com/mixeme/selfpost` публичное.
**Цена:** ~10–15 минут на релиз; переработка релизного workflow, который сейчас работает; новый модуль и compose-override на сопровождении. **Проверка при реализации:** `make e2e` на dev-сервере проходит, не задевая прод-порты; красный e2e действительно не даёт опубликовать образ — проверяется одноразовым тегом на заведомо сломанном прогоне (тег и per-arch пакеты после проверки удалить).
**Порядок работ:** сначала B.1–B.3 (они полностью специфицированы, иначе харнесс пришлось бы переписывать под них), затем харнесс — и стендовые проверки B.1/B.3 переезжают в него постоянными регрессиями (пункты 7–8 выше), а не выбрасываются после однократного прогона.
### D. Ревизия безопасности
5. **Проверка на уязвимости моделью Fable — решено: отдельный проход после B.1–B.3 и C.4, до тега релиза.**
**Почему после всех четырёх, а не по ходу каждого.** Каждый пункт трогает ровно ту поверхность, которую аудит ТЗ 7.6 на v1.0 видел в другом виде: B.1 переписывает аутентификацию (сессии в SQLite, SHA-256 от токена, скользящее продление, разлогин всех при смене пароля), B.2 меняет обращение с дескриптором лога и вешает `postfix reload` на logrotate, B.3 добавляет разбор значения переменной в shell до старта supervisord, C.4 приносит переработанный релизный workflow и compose-override с **сознательно ослабленными** настройками (`PANEL_COOKIE_SECURE=false`, самоподписанный сертификат, заниженные лимиты), которому нельзя утечь в прод. Ревизия по пунктам дала бы четыре среза, а смотреть надо итоговое состояние — и заведомо один раз, а не четыре.
**Объём.** Диф от тега `v1.0.0` до состояния перед следующим тегом целиком — то есть вместе с Фазами 12–14, которых в аудите v1.0 не было, — плюс повторный проход по чек-листу ТЗ 7.6, а не только по изменённым строкам: регресс в 7.6 возможен и в нетронутом коде, если рядом поменялся вызывающий. Приоритет задаёт то, что панель публично доступна (ТЗ 2.4): аутентификация и сессии, валидация ввода, запись в конфиги и map-файлы (injection), `os/exec` без shell, права на файлы в `/data`, обращение с секретами (пароли приложений, `sasldb2`, архив бэкапа).
**Модель — Fable, и это сознательно не Opus:** B и C пишет Opus, а проверка собственной работы систематически слабее независимой. Правило progress.md «безопасность/инфра → Opus» этим не отменяется — оно про написание кода, здесь речь про ревизию. Форма прогона: `/security-review` по изменениям, пока они ещё в ветке (скилл смотрит диф), плюс отдельный ручной проход по 7.6 целиком.
**Что с находками.** Каждая закрывается явно: правка до тега либо запись в [security.md](security.md) как принятый риск с обоснованием (там уже два таких). «Посмотрели и ладно» закрытием не считается. **Гейт:** вместе с e2e из C.4 — до тега; находка класса «эксплуатируется снаружи» тег откладывает.
### E. Указатель на объём 2.x
6. **Входящий релей и pluggable-антиспам** вынесены в опциональные фазы O1+ ниже (линия 2.x.x, вне v1.0, только по согласованию — ТЗ 12.6). Здесь перечислены лишь как напоминание, что это **сознательно отложенный объём**, а не забытый.
7. **Роль администратора домена** — кандидат на 2.x, вне объёма v1.x. Прежняя формулировка пункта («2FA и несколько администраторов») заменена: 2FA снята с рассмотрения, а «несколько администраторов» уточнено до одной конкретной роли, потому что нужна не вторая копия всевластного админа, а ограниченный доступ владельца отдельного домена.
**Что это.** Сейчас в панели ровно один субъект: `requireAuth` — булев гейт, а не роль ([web.go:182](../internal/web/web.go:182)), сессия не несёт ничего, кроме факта входа. Роль выдаёт доступ к одному домену и только к нему: приложения этого домена (создание, режим отправителя, перегенерация пароля, удаление, свой L2-лимит), DKIM/DNS-статус домена и журнал отправки, отфильтрованный по домену — фильтр в журнале уже есть ([handlers_monitor.go:49](../internal/web/handlers_monitor.go:49)). Вне роли остаётся то, что глобально по своей природе: добавление и удаление доменов, `/reload`, полный бэкап (это весь `/data` вместе с `sasldb2`, то есть все домены сразу), очередь и хвост `mail.log` — они серверные и к домену не привязаны.
**Почему 2.x, а не v1.x.** ТЗ 3 относит «несколько пользователей панели, роли» к явным не-целям (панель рассчитана на одного администратора), поэтому появление второго субъекта — расширение границ проекта, как и Фаза O1: сначала согласование (ТЗ 12.6) и правка ТЗ, только потом код. Цена — уровня фазы, а не патча: таблица пользователей и их привязка к доменам, роль в сессии, авторизация в каждом хендлере (а не только на маршруте — сейчас `{id}`/`{aid}` не сверяются ни с чем, кроме существования), пересмотр первичного setup'а и смены пароля под нескольких пользователей, учёт нового субъекта в бэкапе и экспорте домена.
---
## Опциональные фазы — целевой релиз 2.x.x (вне базового объёма v1.0)
Эти фазы **не входят** в линейный базис 0→11 и не являются частью поставки v1.0 (v1.x — только исходящий релей). Они отнесены к **релизной линии 2.x.x** и добавлены в дорожную карту как согласуемые расширения. **Реализация — только после явного согласования (ТЗ 12.6):** ТЗ v1.0 раздел 3 явно исключает приём входящей почты из объёма, поэтому включение этой функциональности — сознательное расширение границ проекта (major-релиз 2.0), а не доработка по своей инициативе. Внесение в план фиксирует намерение и дизайн; кодирование начинается отдельным решением.
### Фаза O1 (→ 2.x.x) — Входящий релей (backup-MX / пересылка) — опция/плагин
**Цель:** возможность принимать почту на порт 25 для явно настроенных доменов и пересылать её на заданный вышестоящий backend (роль backup-MX / relay-forwarder), **как выключаемый по умолчанию модуль**, не затрагивающий поведение и поверхность атаки базового исходящего релея.
**Зачем это нужно (сценарии):**
- **Backup-MX** — принять почту, когда основной почтовый сервер домена временно недоступен, и передать её, когда он вернётся.
- **Фронт для сервера без внешнего IP** — у оператора есть свой почтовый сервер, который по каким-то причинам **сам не может принимать почту из интернета** (нет статического/внешнего IP, за NAT, серый адрес, закрытый порт 25 на входящую и т.п.). SelfPost с публичным IP и корректным PTR выступает публичным входным узлом для домена (MX указывает на него) и пересылает почту на этот внутренний/недоступный извне сервер.
**Граница объёма (критично — что это НЕ):**
- **ЭТО:** приём на 25 для доменов из явного списка + пересылка (relay/forward) на upstream (`relay_domains` + `transport_maps` + `relay_recipient_maps`). Postfix здесь — чистый пересыльщик, без локальной доставки.
- **ЭТО НЕ (остаётся out of scope, ТЗ 3):** локальная доставка в почтовые ящики, IMAP/POP3, webmail, Dovecot. Никаких mailbox'ов. SelfPost также **не реализует и не тянет в свой образ** движок антиспама/антивируса (rspamd/ClamAV) — но, в отличие от прежней формулировки, и **не** перекладывает фильтрацию на backend (см. блок «Антиспам» ниже): предоставляет точку подключения внешнего фильтра.
**Почему как опция/плагин:**
- Приём на порт 25 меняет модель угроз (open relay для входящей, backscatter, spam-ingress). Поэтому по умолчанию **выключено** флагом env `INBOUND_RELAY_ENABLE=false`; включение — осознанный шаг оператора.
- Изоляция: отдельные таблицы SQLite, отдельные хендлеры/страницы панели, отдельная ветка генерации конфига. При выключенном флаге входной listener, таблицы и UI отсутствуют — базовый исходящий тракт байт-в-байт неизменен.
**Что делать:**
- Env-флаг `INBOUND_RELAY_ENABLE` (default false); при `true` — генерировать входной сервис и его конфиг из состояния панели тем же путём, что остальной конфиг (`postfix-config.sh`).
- **`master.cf`:** входной `smtp inet` на 25 для приёма из интернета (сейчас 25 используется только на исходящую доставку). Отдельный от 465/587: на 25 **не** предлагается SASL и **не** разрешается отправка наружу — только приём для `relay_domains`.
- **Анти-open-relay для входящей (обязательно):** `smtpd_relay_restrictions`/`smtpd_recipient_restrictions` входного smtpd принимают почту **только** для доменов из `relay_domains` и **только** для известных получателей (`relay_recipient_maps`); всё прочее — `reject_unauth_destination`/`reject_unlisted_recipient`. Открытый релей и приём «для кого угодно» невозможны.
- **Backscatter:** предпочтительно знать валидных получателей (reject unknown recipient на этапе RCPT), чтобы не порождать bounce на несуществующие адреса.
- **Панель управляет:** список входящих доменов; для каждого — upstream destination (`host:port`, транспорт), опциональный список валидных получателей, опциональный TLS к upstream. Строгая валидация домена/хоста/порта (whitelist), injection-safe запись map-файлов (как `sender_login_maps` в Фазе 4), `os/exec` без shell (ТЗ 7.6.2–4).
- **Милтеры:** OpenDKIM на входящем тракте не нужен (чужую входящую не подписываем). journal-milter опционально переиспользовать для журнала входящих (доп. работа) либо на первом этапе оставить входящий без него; поведение fail-open сохраняется.
- **Rate-limit/размер:** грубый лимит по client IP (`anvil`, как L1) и `message_size_limit` на входном smtpd.
**Антиспам (важная, но опциональная возможность).** Это ценная опция, но она **не обязательна**: часть операторов вполне устроит **слепая пересылка без фильтрации** — например, когда backend сам умеет фильтровать по содержимому, стоит доверенный upstream, или объём/риск невелик. Поэтому антиспам-хук по умолчанию **выключен** (пустой `INBOUND_ANTISPAM_MILTER`), и входящий релей полностью работоспособен без него. Важно другое — где фильтрация возможна технически: при «слепом» relay целевой backend видит подключающимся IP адрес **SelfPost**, а не исходного отправителя, поэтому на backend'е ломается всё, что завязано на origin IP (DNSBL/репутация проверяются против IP SelfPost, SPF даёт fail — SelfPost не входит в SPF домена-отправителя). **Единственная точка, где ещё виден настоящий client IP — входной хоп на SelfPost**; поэтому тем, кому фильтрация нужна, она должна быть *подключаема именно здесь*, а не переложена на backend, который эту информацию уже потерял. Дизайн подключения:
- **Движок антиспама — отдельный опциональный контейнер** (rspamd и т.п.), который оператор запускает **только если нужна эта опция** (тот же принцип, что reverse-proxy — отдельный контейнер вне образа SelfPost). SelfPost его **не содержит и не запускает** — образ и принцип «один контейнер, три процесса» неизменны, ТЗ 3 не нарушается (SelfPost не реализует антиспам).
- **SelfPost предоставляет точку подключения:** milter-хук на входном smtpd. Адрес движка задаётся env (например, `INBOUND_ANTISPAM_MILTER=inet:antispam:11332`, пусто → хук выключен) и добавляется в `smtpd_milters` **только входного** тракта (не на 465/587). Postfix передаёт milter'у настоящий client IP/HELO/PTR — фильтр видит истинный origin. `milter_default_action` для этого milter'а — конфигурируемый (fail-open vs tempfail); дефолт определить при реализации.
- **Нативный backstop без зависимостей:** на том же входном хопе доступны средства Postfix по origin IP — `reject_rbl_client` (DNSBL), проверки HELO/PTR — работают даже без внешнего контейнера. Плюс сохранение аутентификации для downstream через ARC/`Received` там, где часть фильтрации всё же остаётся на backend.
- **docker-compose:** задокументировать опциональный фрагмент antispam-сайдкара (как альтернативные фрагменты reverse-proxy) — контейнер поднимается вместе со стеком только при включённой опции.
- **Персистентность:** новые таблицы и map-файлы под `/data` — попадают в полный бэкап автоматически (Фаза 9). Экспорт/импорт домена можно расширить входящей конфигурацией — опционально, пометить.
- **DNS-документация:** для входящего домена нужна `MX`-запись, указывающая на сервер (в отличие от исходящего, где MX не требуется) — отразить в разделе DNS README.
**Безопасность (ТЗ 7.6 распространяется полностью):** валидация ввода на сервере, экранирование записи в конфиги, `exec` без интерполяции, никакого open relay, защита от backscatter.
**Готово, когда:** при `INBOUND_RELAY_ENABLE=true` и настроенном домене письмо на порт 25 для этого домена пересылается на заданный upstream; почта для ненастроенных доменов/получателей отклоняется (не open relay, не backscatter); при заданном `INBOUND_ANTISPAM_MILTER` входящая проходит через внешний фильтр с настоящим origin IP (проверено сайдкар-контейнером), при пустом — хук не мешает; при `INBOUND_RELAY_ENABLE=false` — входной порт/таблицы/UI отсутствуют, базовый исходящий релей неизменён; `build`/`vet`/`test`/образ зелёные.
**Риски:** open relay/backscatter (снимается `relay_domains` + `relay_recipient_maps` + `reject_unauth_destination`); потеря origin IP для фильтрации на backend'е при пересылке (снимается milter-хуком антиспама + нативным DNSBL на входном хопе, где origin IP ещё виден); порт 25 на приём расширяет поверхность атаки (по умолчанию выключено). **Модель:** Opus (инфра/безопасность, риск open relay). **Внешняя зависимость деплоя:** опциональный antispam-контейнер — вне образа SelfPost, поднимается оператором при включении опции.
**Зависимости:** не является частью v1.0, зависит только от готового исходящего тракта (уже реализован) и требует отдельного согласования (ТЗ 12.6, расширение за пределы раздела 3) до кодирования.
+52
View File
@@ -0,0 +1,52 @@
# Прогресс реализации SelfPost
Живой трекер состояния. **Переживает `/clear`** — читается первым при возобновлении работы.
План (открытые вопросы + опциональные фазы): [implementation-plan.md](implementation-plan.md).
ТЗ: [specification.md](specification.md). Принятые риски безопасности: [security.md](security.md).
История релизов: [CHANGELOG.md](../CHANGELOG.md).
История сделанного по фазам (0→13, все закрыты) — в `git log` и в CHANGELOG, здесь не дублируется.
## Как возобновить после сброса контекста
1. Прочитать этот файл (текущее состояние, что дальше).
2. Открыть `implementation-plan.md` — там нерешённые вопросы и опциональная линия 2.x.x (Фаза O1+); принятые риски безопасности — в `security.md`.
3. При необходимости — детали в `specification.md`.
4. Продолжить с пункта «Следующий шаг».
## Модель по типу работы
Правило: безопасность / инфра / риск-критичное → **Opus**; UI / документация / бойлерплейт → **Sonnet**; тривиальная механика → **Haiku**.
Исключение — **ревизия** (не написание) кода: предрелизная проверка на уязвимости (пункт **D.5** плана) делается моделью **Fable**, чтобы проверял не тот, кто писал.
## Коммиты
Коммит на **каждом осмысленном шаге** (не каждое сохранение файла, но и не только конец фазы): рабочий под-функционал, зелёная сборка, конец фазы. Минимум — один коммит на закрытую фазу + промежуточные на связные под-шаги. Ветка `main` (если пользователь не попросит отдельную). Push/PR — только по явной команде. Сообщение коммита завершается трейлером `Co-Authored-By: Claude <модель> <noreply@anthropic.com>` — с той моделью, которая этот шаг делала (на момент Фазы 14 — `Claude Opus 5`).
На каждом таком шаге — запись в [CHANGELOG.md](../CHANGELOG.md) под `[Unreleased]` (формат Keep a Changelog). При явном решении зарезать версию — секция `[Unreleased]` переименовывается в `[X.Y.Z] - дата`, заводится новая пустая `[Unreleased]`. Тег/пуш образа — только по явному запросу (см. workflow release.yml).
## Протокол закрытия фазы/крупного шага
Перед `/clear` в конце каждого законченного шага Claude:
1. Обновляет этот файл: «Текущее состояние» → что изменилось, что дальше.
2. Проверяет применимые критерии «Готово, когда…».
3. Дописывает `CHANGELOG.md` под `[Unreleased]`.
4. Делает финальный коммит шага.
---
## Текущее состояние
- **Выполнено и принято:** базовый линейный план 0→11 (v1.0; аудит безопасности ТЗ 7.6 — полное соответствие), Фаза 12 (UI/UX), Фаза 13 (страница `/status`, DNS-проверки домена) и Фаза 14 (security-заголовки, проверка origin, cookie `__Host-` + обнаружение дублей, документация про `/data/setup-token`). Что именно сделано — в `git log` и `CHANGELOG.md`, здесь не дублируется.
- **B.1 реализован** (не выкачен на прод): сессии переехали в SQLite (`internal/store/migrations/0002_sessions.sql`, `internal/store/sessions.go`, `internal/web/session.go`) — хранится SHA-256 токена, не сам токен; скользящий срок бездействия `PANEL_SESSION_IDLE_DAYS` (по умолчанию 7 дней, без абсолютного потолка); запись в БД продлевается не чаще раза в час (`renewThreshold`); опросы мониторинга (`GET` с `HX-Request`) продление не триггерят (`isSessionActivity` в `internal/web/middleware.go`); `Max-Age` cookie выставляется тем же значением при логине и при продлении (`setSessionCookie`); смена пароля разлогинивает все сессии кроме текущей (уже было, теперь через БД). Проверено на стенде: логин → рестарт процесса панели → сессия жива по старой cookie; HX-Request-опрос и повторный GET внутри часового окна не шлют `Set-Cookie`. `go vet`/`go test ./...`/`gofmt -l .` чистые.
- **B.2 реализован** (не выкачен на прод): ротация `mail.log` ушла с `copytruncate` на «переименовать + `postfix reload`» — `build/logrotate-mail.conf` (`nocreate` заменён на `create 0644 root root` **не по плану, а по стендовой проверке**: после reload Postfix пересоздаёт лог сам только в момент следующей фактической записи и с режимом `0600`, недоступным непривилегированной панели, — `create` в logrotate закрывает это, отдавая файл ей же на 644 сразу после переименования); `follow()` в `internal/logtail/logtail.go` при обнаружении смены inode дочитывает старый дескриптор ещё раз перед переключением; `readLogTail()` в `internal/web/handlers_monitor.go` считает отсутствующий файл пустым экраном, а не ошибкой. Проверено на стенде (`selfpost.example.com`, отдельный контейнер `selfpost:b2test2`): цикл трафик → принудительная ротация → файл пуст и сразу читаем непривилегированным uid панели (0 читает `mail.log` сразу после rename, без окна недоступности) → новый трафик после ротации уходит в новый файл на 644, ничего не потеряно по обе стороны rename. `go vet`/`go test ./...`/`gofmt -l .` чистые (на dev-сервере; локально на Windows `TestFollowTailsAndRotates` падает — rename открытого файла запрещён ОС, к делу не относится).
- **B.3 реализован** (не выкачен на прод): `build/entrypoint.sh` проверяет `SELFPOST_HOSTNAME` до `postfix-config.sh` и до `supervisord` — при пустом значении `exit 1` с развёрнутым текстом ошибки (что это за имя, почему обязательно, пример, где задаётся); плюс синтаксическая проверка через `case`: минимум одна точка, без схемы/порта/пробелов (`*://*`, `*:*`, пробел/таб — тот же класс тихого спам-отказа, что и пустое значение). `saslRealm()` и fallback в `postfix-config.sh` не тронуты — после гейта эти ветки мертвы. Заодно отмечена обязательность переменной в `README.md` и `deploy/.env.example`. Проверено на стенде (`selfpost.example.com`, отдельный образ `selfpost:b3test`, cap-list как в поставляемом compose): без переменной — `exit 1` с ожидаемым текстом, без бесконечного тихого retry; `https://mail.example.com:465` и `localhost` отклонены с понятными сообщениями; валидный `mail.example.com` — обычный старт, все процессы supervisord поднимаются. `go vet`/`go test ./...` чистые.
- **C.4 реализован** (не выкачен на прод — это CI/тестовая инфраструктура, а не образ): герметичный контейнерный e2e отдельным Go-модулем `test/e2e/` (свой `go.mod`, не подхватывается `go test ./...` основного модуля) поверх поставляемого `deploy/docker-compose.yml` плюс `test/e2e/compose.override.yml` (самоподписанный сертификат, `PANEL_COOKIE_SECURE=false`, `SELFPOST_HOSTNAME=mail.e2e.test`, высокие порты `20465/20587/20080`, изолированный compose-проект `selfpost-e2e`, свой `--project-directory` — прод на том же хосте не задет). Герметичная почта: CoreDNS (`test/e2e/dns/Corefile` — авторитетна только для `e2e.test`, `file`-плагин с саб-директивой `reload` перечитывает `db.zone` по mtime, без сигналов) плюс `smtp-sink` из пакета postfix (`test/e2e/sink/`) как sink-MX. Сценарий (`test/e2e/*_test.go`): старт контейнера → все supervisord-программы `RUNNING` (`postfix-reload` — `STOPPED`) → токен из `/data/setup-token` → setup → login → добавление домена → DKIM-запись **скраплена со страницы панели** и опубликована в фейковую зону → добавление приложения → SMTP AUTH на 465 → письмо на sink → DKIM-подпись проверена (`go-msgauth/dkim` с кастомным `LookupTXT` через CoreDNS) против ключа **из DNS**, не из панели напрямую → send-log `queued → sent`. Негативы: без AUTH, relay на чужой домен без AUTH, sender/login mismatch (`reject_sender_login_mismatch` репортится Postfix'ом на RCPT, не MAIL — `smtpd_delay_reject=yes` по умолчанию), L1-лимит (anvil, override `RATE_LIMIT_MESSAGES_PER_IP=50` — специально высокий, чтобы остальные под-тесты не расходовали общий бюджет по IP раньше времени; сам тест шлёт до 60 раз, ждёт отказа), L2-лимит через панель (домен/приложение → `rejected`-строка в send-log), fail-open journal-milter'а (`supervisorctl stop panel`, письмо всё равно принято, контейнер жив), пустой/синтаксически неверный `SELFPOST_HOSTNAME` (отдельный один-разовый контейнер, не общий стенд), сессия переживает `docker restart` (плюс явное ожидание готовности smtps-порта после рестарта — панель и Postfix поднимаются независимо). `make e2e` — локальный/dev-server прогон. Найдено и исправлено по ходу стендовой проверки: `reload` — саб-директива `file`-плагина CoreDNS, а не отдельный топ-левел плагин (топ-левел `reload` следит за самим Corefile, не за зоной); `docker compose build.context` резолвится относительно `--project-directory`, а не относительно файла, где объявлен; `smtp-sink` отказывается стартовать от root без `-u`; `html/template` эскейпит `+` в `&#43;` даже в тексте — скрапер значений со страницы обязан `html.UnescapeString`; проверки состояния сразу после `up`/`restart` должны поллиться, а не разово опрашиваться (supervisord/postfix поднимаются не мгновенно). **Проверено на dev-сервере (`selfpost.example.com`)**: `make e2e` — зелёный (`go vet`/`gofmt -l` тоже чистые в обоих модулях). `release.yml` переработан: job `prepare` (версия из тега) → матрица `[ubuntu-latest, ubuntu-24.04-arm]` — каждая нативно собирает образ (`--load`), прогоняет e2e, пушит тег `X.Y.Z-amd64`/`X.Y.Z-arm64` → job `merge` — `docker buildx imagetools create` в единый тег `X.Y.Z`; `setup-qemu-action` убран. Не проверено вживую (нельзя без реального тега): сам workflow на GitHub Actions — синтаксис вычитан, логика идентична локальному `make e2e` пути.
- **Дальше — документация:** [documentation-plan.md](documentation-plan.md) — состав пакета (ТЗ 11), метод сверки с кодом, результаты первого прохода (11 находок: нет раздела «эксплуатация» по ТЗ 11 п.7, нет справочника env-переменных, битая ссылка на «Rate limiting» из `.env.example`, не отражена оговорка ТЗ 9 про прямой `tar`) и задачи D1–D7. Часть предрелизного гейта наравне с e2e и ревизией безопасности.
- **Дальше:** пункт **D.5** плана — предрелизная проверка на уязвимости моделью Fable по всему дифу от `v1.0.0` плюс повторный проход по ТЗ 7.6; вместе с e2e (C.4, готов) это гейт перед тегом релиза.
- **Дальше — то, что перечислено в `implementation-plan.md`:** открытые вопросы закрыты, раздел E теперь только указатель на объём 2.x (входящий релей O1+ и роль администратора домена; 2FA снята с рассмотрения); остаются принятые риски безопасности (переехали в [security.md](security.md): `POST` без `Sec-Fetch-Site`/`Origin` пропускается, CSRF-токенов нет) и опциональная **Фаза O1+** (входящий релей, линия 2.x.x, требует согласования).
- **Прод:** `selfpost.example.com`, реальный Let's Encrypt сертификат, живой e2e (DKIM/SPF pass). Контейнер там всё ещё на образе v1.0 — Фаза 14 в него не выкатывалась. При апгрейде: админа один раз разлогинит (сменилось имя cookie), а от reverse-proxy требуется передача исходного `Host` (Apache-фрагмент из `deploy/` это делает).
## Рабочая петля (dev loop) — ВАЖНО
Локально (Windows, `<repo>`) **нет Go и Docker** — только редактирование и git. Вся сборка/тесты идут на dev-сервере `selfpost.example.com` (Debian 12 bookworm, тот же, что базовый образ; провижён под разработку). Цикл: править локально → залить дерево на сервер → `go build`/`go vet`/`docker build`/тесты там. `rsync` в локальном git-bash нет, поэтому дерево едет tar'ом по ssh: `tar -czf - --exclude=.git . | ssh root@selfpost.example.com 'rm -rf /root/selfpost-src && mkdir -p /root/selfpost-src && tar -xzf - -C /root/selfpost-src'`; Go на сервере — в `/usr/local/go/bin` (не в `PATH` по умолчанию); образ — `docker build -f build/Dockerfile -t selfpost:dev --build-arg VERSION=dev .`. Источник истины и git-история — локальный репозиторий; сервер — только исполнитель сборки/тестов. Подключение: `ssh root@selfpost.example.com` (по ключу).
+39
View File
@@ -0,0 +1,39 @@
# Безопасность: принятые риски
**Что здесь.** Обязательные требования к безопасности — [ТЗ 7.6](specification.md);
соответствие им проверено полным аудитом на v1.0 и здесь не пересказывается.
Hardening сверх обязательного 7.6 (security-заголовки, проверка origin, cookie
`__Host-` с обнаружением дублей — Фаза 14) тоже закрыт, история — в
[CHANGELOG.md](../CHANGELOG.md) и `git log`. Этот документ держит третью
категорию: то, что закрыто **сознательно не было**, чтобы решение не потерялось
и не переоткрывалось заново.
Здесь, а не в [implementation-plan.md](implementation-plan.md), потому что план —
только про несделанную работу, а принятый риск — не работа, а решение: у него нет
состояния «в очереди», есть условие, при котором к нему возвращаются.
## Принятые риски
- **`POST` без `Sec-Fetch-Site` и без `Origin` пропускается.**
Клиент, не посылающий ни одного из двух — по-настоящему старый браузер или
webview с замороженным движком, — остаётся уязвим к CSRF с любого сайта.
Принято сознательно: панель однопользовательская, админ выбирает браузер
сам, а строгий режим не «защитил бы» такой клиент, а просто сломал бы в нём
панель. Ужесточение — одна строка в `originAllowed`
([internal/web/security.go](../internal/web/security.go)): вернуть `false`
вместо `true` в ветке «нет обоих заголовков».
- **CSRF-токены, привязанные к сессии, не делаются.** Проверка origin
закрывает соседний поддомен, но зависит от поведения браузера; токен — нет.
Цена — скрытое поле примерно в двух десятках форм. Триггером вернуться к
вопросу считать появление требования «устойчиво независимо от браузера».
От XSS внутри самой панели не спас бы и токен: код, исполняющийся в origin
панели, отправит запрос сам — против этого работают автоэкранирование
`html/template` (7.6.7) и CSP, поэтому шаблоны не должны содержать
inline-скриптов и inline-стилей.
## Как этот список пополняется
Предрелизная проверка на уязвимости (пункт **D.5** плана, модель Fable) закрывает
каждую находку одним из двух способов: правка до тега — либо запись сюда, с
обоснованием и условием возврата, как у двух пунктов выше. Третьего варианта
(«посмотрели и ладно») нет.
+443
View File
@@ -0,0 +1,443 @@
# Техническое задание: SelfPost
**Версия:** 1.0
**Тип проекта:** self-hosted SMTP relay с веб-панелью управления
**Формат поставки:** один Docker-образ
---
## 1. Цель и назначение
SelfPost — это self-hosted SMTP-релей для личного использования, предназначенный для **исходящей отправки** почты из скриптов, приложений и систем уведомлений. Релей отправляет почту напрямую в интернет с собственного IP, с подписью DKIM, и управляется через веб-панель.
**Ключевой сценарий использования:** пользователь настраивает релей один раз (домен, DKIM, разрешённые отправители), после чего его приложения/скрипты отправляют почту через SMTP-endpoint релея, а релей доставляет её получателям от имени настроенного домена.
---
## 2. Контекст и ограничения
Эти ограничения — результат предварительного анализа и **не подлежат пересмотру** без явного согласования:
1. **Развёртывание — VPS или домашний сервер** (одноплатники вроде Raspberry Pi пока вне рассмотрения, но не исключены в будущем). Оба варианта равноправны — образ один и тот же. Инфраструктурные предпосылки для отправки с собственного IP считаются обеспеченными оператором (см. заметку ниже).
2. **Отправка с собственного IP** (DIY), без промежуточного relay-провайдера. Требует, чтобы VPS-провайдер разблокировал порт 25 (обычно по тикету) и позволял настроить PTR/rDNS.
3. **Один контейнер.** Postfix, OpenDKIM и панель управления работают внутри одного образа под управлением process-supervisor. Это осознанное решение в пользу простоты развёртывания.
4. **Панель управления публично доступна** из интернета. Это накладывает обязательные требования к безопасности (раздел 7).
> **Предпосылки инфраструктуры (вне зоны ответственности проекта).** SelfPost исходит из того, что площадка (VPS или домашний сервер) уже обеспечивает условия для отправки с собственного IP: разблокированный исходящий порт 25, статический IP, настраиваемый PTR/rDNS и приемлемая репутация IP. Обеспечение этих условий — задача оператора при развёртывании, а не функция SelfPost. Проект не пытается их детектировать, обходить или компенсировать; при их отсутствии почта просто не будет доставляться, и это ожидаемо.
---
## 3. Что НЕ входит в объём (Out of Scope)
Явно исключено, чтобы не было scope creep:
- ❌ Приём входящей почты (IMAP/POP3, mailbox'ы, доставка в ящики)
- ❌ Веб-почта (webmail)
- ❌ Мультитенантность (организации, несколько **пользователей панели**, роли) — панель рассчитана на **одного** администратора. Управление несколькими отправляющими доменами — это НЕ мультитенантность и входит в объём (см. раздел 4.1).
- ❌ Антиспам/антивирус для входящей почты (rspamd, ClamAV)
- ❌ Реализация собственного SMTP-сервера или MTA — используется готовый Postfix
- ❌ Dovecot и любой полноценный mail-стек ради SASL — аутентификация делается на лёгком Cyrus SASL (`sasldb2`), см. раздел 5.1
---
## 4. Архитектура
Единый Docker-образ на базе **Debian slim** (например, `debian:bookworm-slim`). Выбор зафиксирован в пользу простоты поддержки: glibc даёт более предсказуемое поведение DNS-резолвера при постоянных MX-lookup'ах (в отличие от musl в Alpine), а подавляющее большинство документации и примеров конфигов Postfix/OpenDKIM ориентировано на Debian/apt — что снижает риск ошибок, особенно при генерации кода агентом. Экономия размера на Alpine здесь непринципиальна (образ всё равно тянет Python ради supervisord, а деплой идёт на VPS/сервер, не на ресурсно-ограниченный одноплатник). Внутри — три процесса под управлением `supervisord`:
```
┌───────────────────────────────────────────────┐
│ Docker-контейнер SelfPost │
│ │
│ supervisord (PID 1) │
│ ├── postfix (start-fg) │
│ ├── opendkim (foreground) │
│ └── panel (Go-бинарник, один процесс, │
│ совмещает несколько ролей: │
│ • HTTP-сервер панели :8080 │
│ • journal-milter (приём :internal │
│ From/To/Subject/SASL-user на этапе │
│ DATA, см. 7.3) │
│ • log-tailer — горутина, следит за │
│ mail.log и обновляет статус доставки) │
│ │
│ Общая файловая система: │
│ /etc/postfix/... ← панель пишет │
│ /etc/opendkim/keys ← DKIM-ключи │
│ /data/selfpost.db ← SQLite: домены, │
│ приложения, администратор, журнал │
│ отправки (см. 7.3, 9) │
│ │
│ Порты: │
│ 465 (smtps, implicit TLS — основной, только │
│ SASL-аутентификация) │
│ 587 (submission, STARTTLS — опционально, │
│ добавляется по необходимости, раздел 5) │
│ 25 (исходящий в интернет) │
│ 8080 (панель, за reverse-proxy) │
└───────────────────────────────────────────────┘
```
**Взаимодействие панели и Postfix:** панель пишет конфиг-файлы напрямую в локальную ФС (`/etc/postfix/...`) и вызывает `postfix reload` как sibling-процесс. Поскольку всё в одном контейнере, inotify-вотчер и shared volumes между контейнерами **не нужны**.
**Milter-цепочка Postfix теперь состоит из двух шагов:** OpenDKIM (подпись) и journal-milter (логирование для журнала отправки, раздел 7.3) — оба сконфигурированы в `smtpd_milters`/`non_smtpd_milters`. Это остаётся в рамках «одного контейнера, трёх процессов под supervisord»: journal-milter — не новый процесс, а дополнительная роль внутри уже существующего panel-бинарника.
**Порядок старта — обязателен, чтобы исключить ошибки соединения при холодном старте контейнера.** `priority=` в supervisord задаёт только порядок отправки команд на запуск, а не готовность сокетов — сама по себе она не гарантирует, что milter-сокеты уже слушают к моменту, когда Postfix попытается к ним подключиться. Поэтому:
1. `priority`: OpenDKIM стартует первым, затем panel (включая journal-milter listener).
2. **Postfix оборачивается стартовым скриптом**, который блокируется в цикле опроса (например, `test -S <unix-сокет>` для unix-сокетов OpenDKIM/journal-milter, с интервалом и таймаутом — например, до 30 секунд), и запускает `postfix start-fg` только после того, как **оба** milter-сокета подтверждённо готовы.
3. **Если готовность не наступает в пределах таймаута** — скрипт завершается с ошибкой (не запускает Postfix «на всякий случай»), чтобы supervisord/Docker увидели явный сбой старта в логах, а не тихо продолжили с сервисом, который будет отклонять почту из-за недоступного milter'а.
4. Это решает только **проблему холодного старта** (первые секунды жизни контейнера) и не отменяет уже принятое поведение для **runtime**-сбоев после успешного старта: если OpenDKIM или journal-milter упадёт во время работы (не при старте), продолжает действовать fail-open, описанный в разделах 7.3/7.4 — это два разных сценария с разной правильной реакцией: на старте лучше подождать и не запускать Postfix «в дырявом» состоянии, а во время работы — не ронять уже идущий приём почты из-за вспомогательного компонента.
**Требование к supervisord:** если любой из трёх процессов падает и не может быть перезапущен, контейнер должен завершаться (а не оставаться «живым» с мёртвым Postfix), чтобы Docker с `restart: unless-stopped` корректно его перезапустил.
### 4.1. Доменная модель (ядро продукта)
SelfPost — **мультидоменный** релей. Две связанные сущности:
- **Отправляющий домен** — например, `example.com`. Имеет свой DKIM-ключ/селектор.
- **Приложение (учётная запись)** — SASL-пара логин/пароль, привязанная к конкретному домену. **У одного домена может быть несколько приложений** (например, `newsletter@example.com` и `alerts@example.com`, или просто «прод-сервер» и «staging-сервер» под один и тот же домен) — каждое со своим отдельным логином/паролем, но все они имеют право слать **только от имени того домена, к которому привязаны**, никогда от имени другого домена.
**Режим адресов отправки — настройка на уровне приложения**, не домена (разные приложения одного домена могут выбирать разный режим независимо). При создании или редактировании приложения выбирается один из двух вариантов:
1. **Любой адрес домена** — приложению разрешено указывать в From любой адрес в пределах своего домена (`*@example.com`). Удобно, когда одно приложение шлёт от разных адресов (например, `noreply@`, `alerts@`, `billing@`) без необходимости заводить под каждый отдельное приложение.
2. **Список конкретных адресов** — приложению разрешены только явно перечисленные From-адреса в пределах своего домена (например, только `alerts@example.com`). Более строгий вариант; попытка отправить с адреса не из списка отклоняется, даже если адрес принадлежит тому же домену.
В обоих случаях адрес обязан принадлежать домену приложения — выйти за пределы своего домена нельзя ни в одном режиме.
Для каждого добавленного домена система управляет:
1. **DKIM-ключ и селектор** — свои для каждого домена (см. раздел 6). Общие для всех приложений этого домена.
2. **Одно или несколько приложений** — у каждого своя SASL-пара логин/пароль и свой режим адресов (см. раздел 5.1). Приложения одного домена независимы: удаление/перевыпуск/смена режима одного не затрагивает другие.
3. **Разрешённые From-адреса** — определяются режимом каждого приложения (см. выше). В любом случае приложение не может отправлять от имени чужого домена — попытка залогиниться кредами приложения из домена A и отправить с адреса домена B отклоняется (см. привязку в разделе 5.1).
**Жизненный цикл домена (что делает панель при добавлении):**
- создаёт запись домена;
- генерирует DKIM-ключ + селектор для домена;
- показывает пользователю всё, что нужно внести в DNS для этого домена (DKIM TXT, а также напоминание про SPF/DMARC — см. раздел 10);
- **не создаёт приложение автоматически** — добавление первого (и последующих) приложений для домена делается отдельным действием, см. ниже.
**Жизненный цикл приложения (что делает панель при добавлении приложения к домену):**
- генерирует SASL-пару логин/пароль (пароль показывается **один раз**, см. 7.2);
- принимает выбор режима адресов: «любой адрес домена» либо «список конкретных адресов» (если выбран список — принимает сами адреса, с валидацией, что каждый принадлежит домену приложения);
- регистрирует привязку в `smtpd_sender_login_maps` соответственно режиму — либо wildcard-запись `@example.com → логин`, либо отдельная запись на каждый разрешённый адрес `конкретный-адрес@example.com → логин` (несколько логинов и/или несколько записей могут вести к одному домену — это штатный случай, а не исключение);
- применяет изменения (`postfix reload`).
**Изменение режима существующего приложения** — доступно как отдельное действие (переключение «любой адрес» ↔ «список», редактирование списка адресов), с пересборкой соответствующих записей в `smtpd_sender_login_maps` и `postfix reload`.
**При удалении домена** — удаляются его DKIM-ключ **и все приложения**, привязанные к этому домену.
**При удалении приложения** — удаляются только его SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
Эта модель — не мультитенантность (панелью по-прежнему управляет один администратор); это управление несколькими отправляющими доменами одного владельца, каждый из которых может обслуживать несколько приложений с независимо настроенными правами на адреса отправки.
---
## 5. Компонент: Postfix
Конфигурация Postfix как исходящего релея:
1. **Приём почты — порт 465 (`smtps`, implicit TLS) как основной.** TLS устанавливается сразу при подключении (wrapper mode, `smtpd_tls_wrappermode = yes` в `master.cf` для сервиса `smtps`), в отличие от STARTTLS — клиент не отправляет ни байта в открытом виде до установления TLS-сессии. **Только для аутентифицированных клиентов (SASL, см. 5.1).** `mynetworks` не используется как способ авторизации отправки — пускают креды, а не сеть. **Категорически недопустима конфигурация open relay.**
- **Порт 587 (submission, STARTTLS) — не входит в базовую поставку, добавляется по необходимости.** Многие SMTP-библиотеки в приложениях по умолчанию ожидают именно 587 — при необходимости совместимости сервис `submission` в `master.cf` включается тем же способом, что и `smtps` (аналогичная SASL/milter/rate-limit конфигурация, единственное отличие — механизм установления TLS, см. 5.1 п.2). Это осознанно не включено в основной сценарий, чтобы не открывать лишнюю поверхность без необходимости.
2. **Отправка** — напрямую в интернет (без `relayhost`), с MX-lookup и TLS (`smtp_tls_security_level = may` минимум).
3. **DKIM** — подключён как milter к OpenDKIM (через unix-socket или inet-socket внутри контейнера). Подпись — per-domain (раздел 6).
4. **Ограничения** — `smtpd_recipient_restrictions` запрещает релей для неаутентифицированных (`permit_sasl_authenticated`, `reject_unauth_destination`). Обязательна привязка отправителя к логину (раздел 5.1).
5. **Базовый rate-limiting по client IP** — встроенный механизм Postfix (демон `anvil`): `smtpd_client_message_rate_limit` + `anvil_rate_time_unit` ограничивают число сообщений с одного client IP за окно времени. Работает на уровне приёма соединения, **независимо от journal-milter** — это базовый backstop, который продолжает действовать даже при сбое milter'а (см. раздел 7.4). Значения — конфигурируемые (раздел 8), по умолчанию консервативные, особенно уместные на этапе прогрева IP (раздел 10). Применяется одинаково к обоим портам приёма (465 и, если включён, 587).
6. Домены, приложения (креды) и привязки — управляются панелью (файлы конфигурации, которые панель редактирует, с последующим `postfix reload`).
Базовый образ-ориентир для понимания подхода: `boky/postfix` (env-driven relay), но здесь конфигурация собирается под DIY-отправку с SASL-аутентификацией и мультидоменом, а не под relay через провайдера.
### 5.1. SASL-аутентификация и привязка приложений к домену
1. **Бэкенд SASL — Cyrus SASL с локальной базой `sasldb2`** (лёгкий, без Dovecot и без полноценного mail-стека). Панель создаёт/удаляет учётные записи приложений (эквивалент `saslpasswd2`), Postfix проверяет их через `smtpd_sasl_auth_enable = yes`.
2. **TLS обязателен для аутентификации на обоих портах, но механизм различается:**
- На **465 (`smtps`)** — TLS уже установлен к моменту аутентификации по определению wrapper mode, отдельная настройка не требуется сверх `smtpd_tls_wrappermode = yes`.
- На **587 (`submission`, если включён)** — `smtpd_tls_auth_only = yes`, чтобы креды не могли быть отправлены до STARTTLS по незашифрованному каналу.
В обоих случаях результат одинаков: креды никогда не передаются в открытом виде.
3. **Привязка логина к разрешённым адресам (критично).** Настраивается `smtpd_sender_login_maps` + `reject_sender_login_mismatch` в `smtpd_sender_restrictions`. Без этого любой валидный аккаунт сможет слать от имени любого домена — это дыра, а не опция. Формат записи в карте зависит от режима адресов, выбранного для приложения (см. раздел 4.1):
- **режим «любой адрес домена»** — wildcard-запись `@example.com логин` (Postfix поддерживает доменные wildcard-записи в `sender_login_maps`);
- **режим «список конкретных адресов»** — отдельная запись на каждый разрешённый адрес: `alerts@example.com логин`, `noreply@example.com логин` и т.д.
Обе формы одновременно допустимы в карте для разных приложений/доменов. Панель поддерживает карту в актуальном состоянии при добавлении/удалении/изменении доменов и приложений, генерируя нужный тип записи в зависимости от режима.
4. **Многие-ко-одному.** У одного домена может быть несколько приложений (логинов), каждое со своим независимым режимом адресов; ни одно приложение не может получить право слать от имени домена, к которому оно не привязано.
### 5.2. TLS-сертификаты для Postfix
**SelfPost всегда работает за reverse-proxy, и именно reverse-proxy отвечает за выпуск, хранение и автообновление TLS-сертификатов** (ACME/Let's Encrypt). Проект **не** содержит собственного ACME-клиента и не выпускает сертификаты сам — он только потребляет готовые.
Модель простая:
1. **Источник** — reverse-proxy (Caddy/Traefik) кладёт PEM-файлы (цепочка + приватный ключ) для почтового hostname в директорию на хосте, смонтированную в контейнер SelfPost **bind mount'ом, только на чтение** (та же схема, что и для остального персистентного состояния — раздел 9). Пути к файлам задаются переменными окружения (см. раздел 8).
2. **Потребление** — Postfix настроен читать эти файлы (`smtpd_tls_cert_file` / `smtpd_tls_key_file`) для TLS на 465 (`smtps`, основной), 587 (`submission`, если включён) и opportunistic TLS на порту 25. Один и тот же сертификат обслуживает оба порта приёма.
3. **Соответствие hostname** — CN/SAN сертификата должен совпадать с HELO-hostname (`SELFPOST_HOSTNAME`) и PTR/rDNS. Проще всего использовать общий hostname для панели и почты, тогда это один и тот же сертификат от reverse-proxy.
4. **Обновление** — когда reverse-proxy обновляет сертификат, файлы в смонтированной директории меняются, и Postfix нужно перечитать их через `postfix reload`. Чтобы деплой оставался простым, применяется **периодический `postfix reload` (например, раз в сутки)** — этого достаточно, т.к. сертификаты обновляются раз в ~2-3 месяца, а суточная задержка применения некритична. Реализовать как отдельную периодическую задачу под supervisord (или cron внутри контейнера). Inotify-вотчер на файл сертификата допустим как альтернатива, но периодический reload проще и надёжнее.
Персистентность сертификатов (хранение, ACME-account key) — **ответственность reverse-proxy**, не SelfPost (см. раздел 9). Контейнер SelfPost хранит сертификаты только как read-only mount и не заботится об их выживании при рестарте.
---
## 6. Компонент: OpenDKIM
DKIM-подпись — **строго per-domain**. Каждый отправляющий домен имеет собственную пару ключей и селектор.
1. **Генерация при добавлении домена** — панель создаёт для нового домена пару DKIM-ключей и селектор. Все ключи должны **переживать перезапуск контейнера** (см. раздел 9).
2. **Подпись** — исходящая почта каждого домена подписывается его собственным ключом. OpenDKIM настраивается с `KeyTable` + `SigningTable`, которые панель поддерживает в актуальном состоянии (запись на каждый домен), с последующим reload OpenDKIM.
3. **Показ DNS-записи per-domain** — панель показывает публичную часть ключа в формате DNS TXT-записи **для каждого домена отдельно**, чтобы пользователь внёс её в DNS соответствующего домена.
4. **Селектор** — на домен; может быть общим значением по умолчанию (например, `selfpost`) для всех доменов, т.к. селекторы живут в пространстве имён каждого домена и не конфликтуют. Значение по умолчанию — конфигурируемое.
5. **При удалении домена** — его ключ и записи в `KeyTable`/`SigningTable` удаляются, OpenDKIM перечитывается.
---
## 7. Компонент: Панель управления
### 7.1. Технологический стек
- **Язык: Go.** Обоснование: статический бинарник (минимум рантайм-зависимостей в образе), компилятор ловит ошибки до развёртывания (важно, т.к. код пишет ИИ-агент), простая упаковка.
- **Зависимости — минимальные.** Приоритет стандартной библиотеки: `net/http`, `html/template`, `os/exec`. Внешние зависимости допускаются только для того, что не покрыто stdlib (например, bcrypt из `golang.org/x/crypto`). Каждая внешняя зависимость должна быть обоснована.
- HTTP-сервер слушает `:8080` внутри контейнера (HTTPS терминируется reverse-proxy, см. раздел 10).
**Front-end:**
- **Server-rendered** через `html/template`. Никакого SPA, JS-фреймворка (React/Vue/Svelte) или build-шага (webpack/vite/npm). Весь UI отдаётся тем же Go-бинарником — единая точка деплоя, ноль `node_modules`. Это требование, а не рекомендация: оно прямо вытекает из приоритета простоты поддержки.
- **HTMX** — единственная фронтенд-зависимость, подключается одним статическим `<script>` (вендорится в бинарник/образ, без npm). Используется для:
- частичных обновлений на страницах-настройках (добавить/удалить отправителя без полной перезагрузки);
- **автообновления экранов мониторинга** (очередь, лог) через периодический polling — `hx-trigger="every Ns"` подтягивает свежий фрагмент раз в несколько секунд (интервал сделать конфигурируемым или разумно зашитым, порядка 2–5 сек).
- **Мониторинг реализуется через HTMX-polling, НЕ через SSE/WebSocket.** Осознанный выбор в пользу максимальной простоты: обновление раз в несколько секунд достаточно для этой задачи, а polling не требует stream-инфраструктуры, управления соединениями и усложнения кода. Мгновенность не требуется.
- Эндпоинты, отдающие фрагменты для polling (например, `/queue/fragment`, `/log/fragment`), возвращают готовый HTML-кусок для замены блока на странице, а не JSON.
### 7.2. Функциональные требования
Панель предоставляет **ограниченный** набор операций:
1. **Аутентификация** — вход администратора по логину/паролю (раздел 7.6).
2. **Список доменов** — показать все настроенные отправляющие домены, их статус (есть ли DKIM-ключ, показана ли DNS-запись) и количество привязанных приложений.
3. **Добавить домен** — по имени домена панель (см. раздел 4.1):
- создаёт запись домена;
- генерирует DKIM-ключ + селектор;
- применяет изменения (reload OpenDKIM);
- показывает DKIM TXT-запись для DNS.
Добавление домена **не создаёт приложение** — это отдельное действие (п. 5).
4. **Удалить домен** — удаляет DKIM-ключ домена **и все привязанные к нему приложения** (SASL-креды и привязки), применяет изменения. Панель должна явно предупредить о каскадном удалении приложений перед подтверждением.
5. **Добавить приложение к домену** — внутри карточки домена, за одну операцию:
- генерирует SASL-пару логин/пароль;
- принимает выбор **режима адресов**: «любой адрес домена» либо «список конкретных адресов» (при выборе списка — ввод одного или нескольких адресов, каждый валидируется на принадлежность домену приложения, см. 7.6);
- регистрирует соответствующую(ие) запись(и) в `smtpd_sender_login_maps` (wildcard или по адресам — раздел 5.1);
- применяет изменения (`postfix reload`);
- **показывает сгенерированный пароль ОДИН РАЗ** (см. 7.6, п. про пароли).
6. **Список приложений домена** — показать все приложения (логины), привязанные к конкретному домену, вместе с их текущим режимом адресов (для режима «список» — сами адреса).
7. **Редактировать режим адресов приложения** — переключить «любой адрес» ↔ «список», либо изменить сам список адресов для существующего приложения; пересобирает соответствующие записи в `smtpd_sender_login_maps` и применяет изменения.
8. **Удалить приложение** — удаляет SASL-креды и все его записи в карте привязки; домен и остальные приложения не затрагиваются.
9. **Перевыпустить пароль приложения** — сгенерировать новую SASL-пару для существующего приложения (старый пароль инвалидируется, режим адресов сохраняется), показать новый пароль один раз.
10. **Просмотр DKIM DNS-записи** — для любого домена показать его TXT-запись для копирования в DNS (доступно в любой момент — это не секрет).
11. **Просмотр очереди** — вывод `mailq` / `postqueue -p` в читаемом виде (сколько писем в очереди, статусы). Экран **автообновляется** через HTMX-polling раз в несколько секунд (см. 7.1).
12. **Reload** — кнопка, применяющая изменения конфига вручную (`postfix reload` / reload OpenDKIM), если нужно.
13. **Просмотр хвоста почтового лога** для диагностики — последние N строк, с автообновлением через polling.
14. **Журнал отправки писем** — таблица отправленных писем с фильтрами по домену и приложению (подробности — раздел 7.3).
15. **Настройка лимитов отправки** — для домена и/или приложения задать привязанные IP и лимит писем за окно времени (подробности — раздел 7.4).
16. **Скачать полную резервную копию** — кнопка формирует и отдаёт архив всего персистентного состояния (подробности — раздел 7.5.А).
17. **Экспортировать домен** / **Импортировать домен** — перенос одного домена на другой экземпляр SelfPost, включая рабочие пароли приложений (перевыпуск не требуется); файл экспорта содержит секреты и должен обрабатываться как чувствительный (подробности — раздел 7.5.Б).
### 7.3. Журнал отправки (Send Log)
Отдельный от «просмотра очереди»/«просмотра лога» экран: не сырой `mailq`/hвост файла, а структурированная, фильтруемая история отправленных писем.
**Что фиксируется в журнале для каждого письма:**
- время приёма;
- домен и приложение (SASL-логин), от имени которого отправлено;
- From-адрес и To-адрес(а);
- Subject (тема);
- статус: в очереди / отправлено / отклонено получателем / отложено (bounced/deferred), с обновлением по мере продвижения по очереди Postfix;
- queue-id Postfix (для сопоставления с сырыми логами при более глубокой диагностике).
**Технический механизм получения данных:**
1. **Тема письма не попадает в стандартные логи Postfix** (`mail.log` содержит envelope-данные и статус доставки, но не заголовки письма). Чтобы получить Subject, From, To и SASL-логин на этапе приёма, panel-бинарник реализует **лёгкий milter** (например, через библиотеку `github.com/emersion/go-milter` или аналог), подключённый в `smtpd_milters` Postfix вместе с OpenDKIM. На этапе приёма письма (EOH/заголовки) milter читает нужные поля и создаёт запись в журнале со статусом «в очереди», привязанную к queue-id.
2. **Финальный статус доставки** panel получает отдельно: горутина внутри того же бинарника хвостит `mail.log` (аналогично экрану из п. 13), парсит строки со статусами `sent`/`bounced`/`deferred` по queue-id и обновляет соответствующую запись журнала.
3. Если письмо направлено нескольким получателям, Postfix может завершать доставку по каждому получателю с разным статусом и в разное время — журнал хранит **отдельную запись на пару (queue-id, получатель)**, чтобы точно отражать различающиеся статусы; в UI такие записи можно опционально визуально группировать по queue-id, но это не обязательно для первой версии.
> **⚠️ Известный риск реализации: milter на Go — менее протоптанный путь, чем остальной проект.** В отличие от конфигурации Postfix/OpenDKIM (где документации и готовых примеров множество, что снижает риск ошибок агента), библиотек реализации milter-протокола на Go немного, и по ним заметно меньше примеров и обсуждений edge-case'ов. Конкретные точки риска:
> - **Протокол milter завязан на конкретные версии** (v2/v6 и т.п.) и не всегда единообразно документирован в доступных Go-библиотеках — есть шанс несовместимости с версией Postfix в образе.
> - **Обработка ошибок/таймаутов milter'а влияет на приём почты напрямую.** Если journal-milter зависнет или упадёт некорректно, Postfix (в зависимости от `milter_default_action`) может либо пропускать письма без логирования, либо, что хуже, начать отклонять всю входящую почту. Это самый чувствительный узел: баг здесь не просто ломает журнал, а потенциально ломает сам релей.
> - **Меньше протестированных Go-примеров** milter-серверов, читающих именно Subject/From/To на этапе EOH, — агенту придётся писать эту часть с меньшей опорой на устоявшиеся паттерны, чем остальной проект.
>
> **Требование по снижению риска:** `milter_default_action` для journal-milter должен быть настроен так, чтобы **сбой journal-milter не блокировал приём и отправку почты** (fail-open для этого конкретного milter’а — в отличие от OpenDKIM, где отказ в подписи можно и нужно трактовать строже). Журнал — вспомогательная функция мониторинга; релей не должен переставать работать из-за него. Этот компонент нуждается в более внимательном тестировании (в т.ч. поведения при падении/таймауте) перед тем, как считаться готовым.
**Хранение:** таблица в той же SQLite (см. раздел 9), не файл лога — чтобы поддерживались фильтры и запросы без парсинга на лету.
**Retention (обязательно предусмотреть, иначе журнал растёт бесконечно):** конфигурируемый срок хранения записей (например, `SEND_LOG_RETENTION_DAYS`, по умолчанию 90 дней) с периодической фоновой очисткой устаревших записей. Значение по умолчанию и наличие настройки — обязательны; исполнитель может уточнить конкретную реализацию очистки (периодическая задача в той же горутине, что и log-tailer).
**UI:**
- Таблица: время, домен, приложение, From, To, Subject, статус.
- **Фильтры — обязательны:** по домену (выпадающий список из существующих доменов) и по приложению (список приложений, опционально зависящий от выбранного домена). Фильтры применяются на сервере (запрос к SQLite с `WHERE`), не на клиенте.
- Пагинация или ограничение количества строк на экран (журнал может быть большим).
- Автообновление свежих записей — через HTMX-polling, тот же подход, что у очереди и лога (раздел 7.1), не обязательно, но желательно для консистентности UX.
**Приватность и безопасность — важное замечание:** журнал хранит адреса получателей и темы писем — это метаданные переписки, потенциально чувствительные. Он доступен только через панель (за логином/паролем администратора, раздел 7.6) — отдельного уровня доступа для журнала не предусмотрено, весь объём защищён общей аутентификацией панели. Тема и адреса должны экранироваться при рендере (раздел 7.6, п.7) как и остальной вывод из логов.
### 7.4. Rate limiting (лимиты отправки)
Двухуровневая защита от «взбесившегося» приложения или скомпрометированных кред — цель — оградить репутацию IP, общего для всех доменов проекта.
**Уровень 1 — базовый, нативный Postfix, независимый от milter (backstop).** Описан в разделе 5, п. 5: `smtpd_client_message_rate_limit`/`anvil_rate_time_unit`, ключ — client IP. Работает всегда, включая ситуацию, когда journal-milter недоступен. Это гарантирует, что даже при полном отказе дифференцированного уровня 2 грубая защита от резкого всплеска отправки с одного IP не исчезает.
**Уровень 2 — дифференцированные лимиты «на домен» и «на приложение», настраиваемые в панели.** Реализованы в journal-milter (том же компоненте, что и журнал отправки, раздел 7.3), поскольку milter уже разбирает каждое соединение и имеет доступ к client IP на этапе приёма.
- **Ключ лимита — client IP, а не SASL-логин.** Причина: один логин/пароль потенциально может использоваться несколькими физическими отправителями (например, кластер серверов с общими кредами) — SASL-идентичность не гарантированно соответствует одному источнику, а IP — более надёжный практический прокси для «кто на самом деле шлёт».
- **Настройка:** при добавлении/редактировании домена и приложения (раздел 7.2) админ может указать один или несколько ожидаемых IP-адресов и лимит (N писем за скользящее окно) — отдельно на уровне домена (суммарно по всем его приложениям и IP) и отдельно на уровне приложения (для его конкретных IP). Оба необязательны — если не заданы, работает только базовый уровень 1.
- **Проверка:** при приёме письма milter сверяет client IP с зарегистрированными для соответствующего домена/приложения и запрашивает текущий счётчик из SQLite (переиспользует данные журнала отправки); при превышении — отклоняет письмо (временный отказ 4xx — семантически корректно для rate-limit, ожидается, что отправитель повторит попытку позже) и **опционально фиксирует это как отдельную запись в журнале со статусом «отклонено по лимиту»** — для видимости в UI.
- **Fail-open допустим на этом уровне.** Поскольку уровень 1 не зависит от milter и продолжает работать самостоятельно, при сбое/недоступности journal-milter уровень 2 может безопасно отключаться (fail-open, тот же принцип, что и для журнала, раздел 7.3) — теряется точность «по домену/приложению», но грубая защита от IP не пропадает.
> **⚠️ Оговорка: дифференцированный уровень требует предсказуемого IP приложения.** Если приложение шлёт из окружения с динамическими/меняющимися IP (например, serverless с ротацией адресов), привязка «IP → домен/приложение» не может быть настроена содержательно — для таких приложений реальной защитой остаётся только базовый уровень 1 (глобальный, без разбивки по домену/приложению). Панель должна явно допускать оставить IP-привязку пустой (тогда дифференцированный лимит для этого приложения просто не применяется), а не требовать её обязательно.
> **⚠️ Известный нюанс: IPv6 ослабляет точность IP-ключа.** Весь дизайн уровней 1 и 2 держится на «IP как надёжный прокси для одного отправителя» — для IPv4 это разумное допущение, но для IPv6 не совсем так: провайдеры часто выдают целый префикс (обычно /64) одному клиенту, и адрес в его пределах может меняться чаще, чем у IPv4-адреса. Формально это может ослаблять точность обоих уровней лимитов именно в IPv6-сетях. Осознанно фиксируется как известное ограничение и оставляется как есть — не требует изменения дизайна на данном этапе.
### 7.5. Резервное копирование и миграция
Два разных сценария с разной механикой — не путать друг с другом ни в реализации, ни в UI.
**А. Полный бэкап сервера (миграция на новую машину целиком).**
- **Что входит в архив:** консолидированное персистентное состояние — SQLite (`selfpost.db`: домены, приложения, администратор, журнал, настройки лимитов), DKIM-ключи всех доменов, база SASL (`sasldb2`), и **манифест с версией SelfPost**, которой создан бэкап (см. ниже). **Рекомендация исполнителю:** организовать все три под единым корневым путём (например, всё под `/data/`), чтобы бэкап буквально сводился к архивации одной директории, а не сборке путей из разных мест контейнера — это прямое следствие цели «переезд прост как архив».
- **Что НЕ входит:** TLS-сертификаты (ответственность reverse-proxy, раздел 5.2) и очередь Postfix (`/var/spool/postfix`, транзитные недоставленные письма — не переносятся; это осознанный компромисс ради простоты, а не недосмотр).
- **Версионирование бэкапа — обязательно.** Архив содержит файл-манифест (например, `manifest.json`) с версией SelfPost, зашитой в бинарник на этапе сборки (Go build-time ldflags, `-X main.version=...`, согласуется с тегом Docker-образа). Восстановление **должно происходить в тот же самый Docker-образ той же версии**, которым был создан бэкап — это устраняет риск несовместимости схемы SQLite, путей DKIM-ключей или формата `sasldb2` между версиями, а не полагается на то, что миграции схемы «как-нибудь сработают» задним числом.
- **Проверка при восстановлении:** при старте контейнера с распакованным бэкапом (или на отдельном явном шаге restore) panel сверяет версию из манифеста с версией собственного бинарника. При несовпадении — **отказ от запуска/восстановления с понятным сообщением**, каким именно тегом образа нужно воспользоваться (например: «бэкап создан версией 1.3.0, запущена версия 1.5.2 — используйте `selfpost:1.3.0` для восстановления»), а не тихая попытка продолжить с риском повреждения состояния.
- Эта версия **не про автомиграцию на лету между версиями** — если пользователь хочет перейти на более новую версию, это делается отдельно (обычное обновление образа на живом инстансе, вне контура backup/restore), а не как часть восстановления бэкапа.
- **Следствие для деплоя:** `docker-compose.yml` должен использовать **фиксированный тег версии образа**, не `:latest` — иначе невозможно достоверно определить, какой версией был создан бэкап, и вся эта защита теряет смысл. Отметить это явно в разделе 10 и README.
- **Создание бэкапа — двумя равнозначными способами:**
1. Кнопка в панели «Скачать резервную копию» — аутентифицированное действие администратора, формирует архив на лету и отдаёт на скачивание.
2. Эквивалентная CLI-утилита внутри контейнера (например, `selfpost-backup`, вызываемая через `docker exec`) — для скриптовых/cron-бэкапов без захода в веб-интерфейс.
- **Восстановление на новом сервере:** поднять пустой контейнер SelfPost **той же версии образа**, что указана в манифесте бэкапа, с теми же путями bind mount, распаковать архив в них **до первого старта** (либо тем же шагом, что и обычная инициализация — специального «режима восстановления» не требуется), затем запустить контейнер как обычно. Panel/Postfix/OpenDKIM конфигурация перегенерируется из восстановленного SQLite-состояния тем же механизмом, что и при каждом обычном старте (не отдельная ветка кода для restore) — не нужно заново проходить secret-link, заново заводить домены или получать новые DKIM-ключи.
- **Прямое следствие для DNS:** поскольку DKIM-ключи переносятся побитово, **DKIM TXT-записи в DNS не нужно менять** после переезда — только A/PTR-записи на новый IP сервера. Это существенно упрощает миграцию по сравнению с «начать с нуля».
- **Безопасность архива:** архив содержит крайне чувствительные данные — приватные DKIM-ключи, хэш пароля администратора, хэши SASL-кредов. Скачивание через панель уже защищено аутентификацией (раздел 7.6), но сам файл после скачивания нужно хранить и передавать как секрет (не по HTTP, удалять после успешного восстановления) — отметить это в документации.
**Б. Экспорт/импорт отдельного домена (перенос одного домена между двумя независимо работающими экземплярами SelfPost).**
- **Экспорт домена** формирует файл с: именем домена, DKIM-ключом и селектором, режимом адресов, списком приложений (логинами и их режимом/списком адресов) и **соответствующими записями `sasldb2` для приложений этого домена**. Технически это возможно, потому что `sasldb2` (в отличие от bcrypt-хэша пароля администратора, раздел 7.6) хранит секрет в форме, допускающей challenge-response механизмы (CRAM-MD5/DIGEST-MD5) — это обратимая/эквивалентная паролю форма, а не строгий необратимый хэш, и её записи можно выборочно переносить между экземплярами так же, как это уже происходит с файлом `sasldb2` целиком при полном бэкапе (раздел 7.5.А). Не путать эту сущность с bcrypt-хэшем администратора — они устроены принципиально по-разному, и только последний действительно невосстановим.
- **Импорт на другом экземпляре** восстанавливает домен, DKIM-ключ (DNS-запись остаётся той же — менять не нужно) и приложения **с рабочими паролями без перевыпуска** — по тому же принципу, что и полный бэкап.
- **Безопасность экспортного файла — как у полного бэкапа.** Поскольку файл теперь содержит секреты приложений (записи `sasldb2`) и приватный DKIM-ключ домена, он **настолько же чувствителен**, как архив полного бэкапа (раздел 7.5.А), и должен передаваться/храниться так же — не по HTTP, удаляться после использования, не рассматриваться как «просто конфиг».
### 7.6. Нефункциональные требования — БЕЗОПАСНОСТЬ (обязательно)
Поскольку панель публична, следующее — **не опционально**:
1. **Первичная инициализация администратора — через одноразовую секретную ссылку**, не через env-переменную с готовым хэшем:
- При первом запуске (в персистентном состоянии ещё нет ни одного администратора) панель генерирует криптографически случайный токен и выводит ссылку вида `https://<host>/setup/<token>` в лог/stdout контейнера. Токен дополнительно пишется в файл в смонтированной директории (например, `/data/setup-token`) — на случай, если удобнее прочитать файл, чем логи.
- **Энтропия токена — не менее 128 бит** (например, 16+ случайных байт из `crypto/rand`, представленные в hex/base64url). Это единственное, что делает подбор математически неосуществимым в принципе — комбинаторное пространство ~3.4×10^38 вариантов; остальные меры (короткое окно, rate limit) — defense-in-depth поверх этого, а не замена ему.
- **Срок жизни токена — 10 минут** (сокращено с изначально предложенного часа). Если контейнер стартовал, а установка не завершена за это время, токен истекает; при следующем обращении к `/setup` (после истечения) или при рестарте без завершённой настройки панель перегенерирует токен и заново выводит его в лог.
- **Rate limiting на маршрут `/setup/<token>`** — обязателен, отдельно от общего rate limiting на логин (п. 5): ограниченное число попыток обращения в единицу времени по IP (например, несколько в минуту), с отклонением/задержкой сверх лимита. При заданной энтропии токена это не является единственной защитой, но снижает шум в логах и защищает от тривиального автоматического перебора.
- **Сравнение токена — константное по времени** (`crypto/subtle.ConstantTimeCompare` или аналог), чтобы исключить timing-атаку, которая могла бы подсказывать правильные префиксы токена по разнице во времени ответа.
- **Неудачные попытки НЕ инвалидируют и не перегенерируют токен досрочно.** Это осознанное решение: если бы ошибочные попытки заставляли токен перевыпускаться, атакующий получил бы возможность DoS'ить легитимную настройку, постоянно обнуляя токен раньше, чем администратор успеет им воспользоваться. Раз энтропия уже делает подбор неосуществимым, довешивать авто-инвалидацию на неудачу — риск без пользы.
- Переход по ссылке открывает одноразовую форму создания администратора (логин + пароль).
- После успешного создания администратора токен **инвалидируется навсегда** (флаг в персистентном состоянии), маршрут `/setup/*` перестаёт быть доступен (404).
- Пароль администратора, введённый на этой форме, хранится только в виде bcrypt-хэша (или argon2) в персистентном состоянии. Никакого plaintext, MD5, SHA1-без-соли.
- `PANEL_USERNAME`/`PANEL_PASSWORD_HASH` как env-переменные **не используются** для основного сценария — см. обновлённый раздел 8.
- **SASL-пароли приложений** — отдельная сущность (не путать с паролем администратора панели). Панель **генерирует** сильный случайный пароль сама при создании/перевыпуске приложения, показывает его пользователю **ровно один раз**, и НЕ хранит его в открытом виде для повторного показа (в `sasldb2` он лежит в хэшированном виде, как того требует механизм SASL). Если пароль утерян — только перевыпуск (п. 8 раздела 7.2).
2. **Валидация ввода на стороне сервера** (не только в UI). Для email/доменов — строгий whitelist допустимых символов (буквы, цифры, `.`, `-`, `@`). Клиентская валидация не считается защитой. Для режима «список конкретных адресов» (раздел 4.1) — отдельная обязательная проверка: **каждый вводимый адрес должен строго принадлежать домену того приложения, к которому он добавляется** (совпадение части после `@` с доменом); адрес из чужого домена отклоняется до записи в конфиг, а не полагается на то, что это отловит `smtpd_sender_login_maps` уже во время доставки.
3. **`postfix reload` и любые вызовы `os/exec`** — БЕЗ интерполяции пользовательского ввода в команду. Аргументы передаются как отдельные элементы (`exec.Command("postfix", "reload")`), никогда через shell-строку. Пользовательский ввод не должен попадать в аргументы команд вообще; он идёт только в конфиг-файлы (после валидации).
4. **Запись в конфиг-файлы** — с экранированием/санитизацией, чтобы инъекция спецсимволов (перенос строки и т.п.) не могла добавить произвольную директиву в конфиг Postfix.
5. **Rate limiting** на эндпоинт логина (защита от brute-force). Простая реализация (счётчик попыток по IP с временной блокировкой) достаточна.
6. **Сессии** — токен криптографически случайный, cookie с флагами `HttpOnly`, `Secure`, `SameSite`.
7. **Экранирование вывода** — данные из очереди/логов (тема письма, адреса) рендерятся через `html/template` с автоэкранированием (защита от XSS).
8. **Процесс панели не должен работать от root** (в supervisord запускать под непривилегированным пользователем, с доступом только к нужным путям через группу/права).
---
## 8. Конфигурация (переменные окружения)
Минимальный набор env-переменных для настройки (исполнитель может расширить):
- `SELFPOST_HOSTNAME` — hostname самого сервера (должен совпадать с PTR/rDNS и с CN/SAN TLS-сертификата). Это hostname сервера, **не** отправляющий домен — домены добавляются через панель динамически.
- `DKIM_SELECTOR_DEFAULT` — селектор DKIM по умолчанию для новых доменов (например, `selfpost`)
- `TLS_CERT_FILE` — путь к PEM-файлу сертификата (цепочка), поставляемому reverse-proxy через read-only bind mount
- `TLS_KEY_FILE` — путь к PEM-файлу приватного ключа, поставляемому reverse-proxy
- `SEND_LOG_RETENTION_DAYS` — срок хранения записей журнала отправки (раздел 7.3), по умолчанию 90
- `RATE_LIMIT_MESSAGES_PER_IP` — базовый лимит сообщений с одного client IP за окно (уровень 1, раздел 5 п.5 и 7.4), консервативное значение по умолчанию
- `RATE_LIMIT_WINDOW_SECONDS` — окно времени для базового лимита (`anvil_rate_time_unit`), по умолчанию 3600 (час)
**Администратор панели не задаётся через env-переменные.** Создаётся один раз через одноразовую secret-ссылку при первом запуске (см. раздел 7.6, п. 1). Это сделано осознанно: пароль/хэш в env-переменных виден через `docker inspect`, оркестраторы и логи окружения — secret-link избегает этой поверхности и не требует от пользователя вручную считать bcrypt-хэш до старта.
Отправляющие домены, их SASL-креды, DKIM-ключи и привязки — тоже **не** в env-переменных, а в персистентном состоянии, управляемом панелью (см. разделы 4.1 и 9).
---
## 9. Персистентность (bind mount на хосте)
Должны переживать перезапуск/пересоздание контейнера:
- **DKIM-ключи всех доменов** (`/etc/opendkim/keys` + `KeyTable`/`SigningTable`) — критично, иначе при рестарте подписи перестанут совпадать с DNS.
- **SASL-база** (`sasldb2`) — учётные записи всех приложений всех доменов; без неё после рестарта приложения не смогут аутентифицироваться.
- **Конфиги Postfix**, изменяемые панелью: список доменов, карта привязки `smtpd_sender_login_maps` (логины приложений → домены) и т.п.
- **База состояния панели — SQLite** (единый файл, например `/data/selfpost.db`), содержит: реестр доменов и приложений (домены, привязанные приложения, режим адресов, селекторы, метаданные), учётную запись администратора (логин + bcrypt-хэш), флаг «первичная настройка завершена» / текущий setup-токен, **журнал отправки писем** (раздел 7.3) с retention-политикой, и **настройки дифференцированных лимитов отправки** — привязанные IP и лимиты на домен/приложение (раздел 7.4). Формат зафиксирован как SQLite (не «на усмотрение исполнителя», так как журнал и лимиты требуют фильтруемых запросов). Должна переживать рестарт — иначе при каждом перезапуске контейнера пришлось бы заново создавать администратора, терялась бы история отправки и настройки лимитов.
- **Очередь Postfix** (`/var/spool/postfix`) — чтобы недоставленные письма не терялись при рестарте.
**TLS-сертификаты в этот список не входят** — они поставляются reverse-proxy через read-only bind mount, и их хранение/выживание при рестарте — ответственность reverse-proxy (см. раздел 5.2).
**Ротация `mail.log` — обязательна.** В отличие от структурированного журнала отправки в SQLite (раздел 7.3), у которого есть retention-политика (`SEND_LOG_RETENTION_DAYS`), сырой лог Postfix (`mail.log`, который читает log-tailer для панели, раздел 7.2 п.13) ничем не ограничен по умолчанию и будет расти неограниченно на протяжении месяцев/лет работы — на небольшом диске (раздел 10) это реальный риск исчерпания места, в отличие от остального состояния, которое ограничено by design. Настроить `logrotate` внутри контейнера (ежедневная/еженедельная ротация, ограниченное число хранимых файлов, например 7–14) как часть образа.
**Механизм — bind mount на хосте, не именованный Docker volume.** Все перечисленные выше пути монтируются из директории на файловой системе хоста (например, `./data` рядом с `docker-compose.yml`, или зафиксированный абсолютный путь вроде `/opt/selfpost/data`) в консолидированный корень внутри контейнера (тот же `/data`, что уже рекомендован в разделе 7.5.А). Причина — та же цель простоты бэкапа и миграции: с именованным volume для доступа к данным нужно либо идти через `docker volume inspect`/`docker cp`, либо временно монтировать volume в служебный контейнер; с bind mount данные — это просто директория на диске, видимая и доступная напрямую средствами хоста (`tar`, `rsync`, `scp`) без обращения к Docker вообще. `docker-compose.yml` должен использовать синтаксис bind mount (`./data:/data`), а не секцию `volumes:` с именованным томом.
**Оговорка про прямое копирование данных хостовым `tar` (в обход панели):** риск неконсистентного снимка SQLite (WAL-режим, незавершённая запись) существует, только если копировать директорию **во время работы контейнера** — тогда возможна гонка между записью и чтением файла. Если контейнер на момент копирования **остановлен**, наивный `tar` полностью безопасен и эквивалентен встроенному механизму — писать в SQLite в этот момент физически некому. Встроенный бэкап через кнопку панели/CLI-утилиту (раздел 7.5.А) остаётся предпочтительным способом именно потому, что не требует останавливать сервис — он использует корректный снимок SQLite (`VACUUM INTO`/Backup API) и безопасен на живом контейнере. В документации отразить оба варианта: «бэкап на лету — через панель/CLI» и «прямой `tar` директории — безопасен, если сервис перед этим остановлен».
---
## 10. Развёртывание
1. Поставка — `Dockerfile` + `docker-compose.yml` + документация.
2. **Reverse-proxy обязателен** и является единым источником TLS-сертификатов. Он:
- терминирует HTTPS для панели (HTTPS **не** реализуется в коде панели);
- выпускает и автообновляет сертификаты через ACME/Let's Encrypt;
- поставляет PEM-файлы сертификата в контейнер SelfPost через **bind mount с хоста** (read-only для SelfPost, тот же принцип, что и для остального состояния — раздел 9), откуда их читает Postfix для TLS на портах 465/25 (и 587, если включён) — см. раздел 5.2.
3. **Проект не привязан к конкретному reverse-proxy.** Документация должна давать примеры интеграции для нескольких распространённых вариантов, а не навязывать один. Основной (по умолчанию) — **Apache**; остальные — как альтернативные фрагменты:
- **Apache (httpd)** — *основной сценарий, готовый `docker-compose.yml` из коробки.* Терминация HTTPS для панели через `mod_ssl` + `mod_proxy`/`mod_proxy_http`. Сертификаты — через `certbot` (Apache-плагин) либо встроенный `mod_md`. При использовании certbot PEM-файлы лежат готовыми в `/etc/letsencrypt/live/<hostname>/` на хосте и монтируются в SelfPost bind mount'ом напрямую (read-only) — прозрачный путь для потребления Postfix'ом, без промежуточного извлечения. Для `mod_md` показать, как отдать сертификат в PEM в смонтированную директорию.
- **nginx** (+ certbot/acme.sh) — PEM-файлы также лежат на диске хоста готовыми, монтируются напрямую bind mount'ом. Близкий по прозрачности к Apache+certbot.
- **Caddy** — простейшая автоматика ACME. Пишет сертификаты как PEM в своём data-каталоге на хосте; смонтировать его bind mount'ом read-only и указать пути в `TLS_CERT_FILE`/`TLS_KEY_FILE`. Нюанс: путь включает внутреннюю раскладку хранилища Caddy (с именем ACME-CA) — исполнителю **проверить актуальный путь хранения в текущей версии Caddy**.
- **Traefik** — сертификаты в `acme.json`, потребуется шаг извлечения PEM.
Для каждого варианта показать: связку в `docker-compose.yml` (или фрагмент конфига), какая директория хоста монтируется в SelfPost bind mount'ом и по каким путям (`TLS_CERT_FILE`/`TLS_KEY_FILE`). Отличия форматов хранения сертификатов у разных прокси — ключевой практический момент, который документация обязана прояснить.
4. При использовании общего hostname для панели и почты это **один сертификат**, обслуживающий оба тракта — самый простой вариант, его стоит показать как основной сценарий в каждом примере.
5. **Рекомендуемый дефолт — Apache** (готовый `docker-compose.yml`, заводящийся «из коробки»); остальные варианты — как альтернативные фрагменты. Обоснование: целевая площадка пользователя уже использует Apache, а связка Apache+certbot даёт готовые PEM-файлы без промежуточных шагов извлечения — минимум движущихся частей в потреблении сертификата Postfix'ом.
6. В docker-compose для контейнера панели/приложения заложить hardening: непривилегированный запуск, при возможности `cap_drop`, ограничение доступной ФС.
7. Документация должна включать раздел **«Требования к площадке»** — краткий чеклист инфраструктурных предпосылок (разблокированный порт 25, статический IP, PTR/rDNS), которые оператор обеспечивает до развёртывания. Без подробного разбора ограничений — только чеклист «что должно быть готово».
8. Документация должна включать раздел **«Настройка DNS»**, явно разделяя записи уровня сервера и уровня домена:
- **Уровень сервера (один раз):** PTR/rDNS для IP сервера.
- **Уровень домена (для КАЖДОГО добавленного отправляющего домена):** SPF-запись, указывающая на этот сервер; DKIM TXT-запись (берётся из панели, своя на каждый домен); DMARC-запись. Без корректных per-domain записей почта соответствующего домена будет попадать в спам. Подчеркнуть, что при добавлении нового домена в панели пользователь обязан внести его DNS-записи.
9. Документация должна включать раздел **«Прогрев IP»** — предупреждение, что свежий IP требует постепенного наращивания объёма отправки и проверки блоклистов (Spamhaus и т.п.).
10. **`docker-compose.yml` должен использовать фиксированный тег версии образа** (например, `selfpost:1.3.0`), не `:latest`. Это прямое следствие требования версионирования бэкапов (раздел 7.5.А) — без явного тега невозможно достоверно определить, какой версией был создан конкретный бэкап, и проверка совместимости при восстановлении теряет смысл.
11. Документация должна указывать **ориентировочные минимальные требования к машине**: 1 vCPU, ~512МБ–1ГБ RAM (при простое стек занимает ориентировочно 100–150МБ, с запасом под нагрузку), 8–10ГБ диска — с оговоркой, что диск растёт в первую очередь за счёт журнала отправки (ограничен `SEND_LOG_RETENTION_DAYS`) и ротируемого `mail.log` (раздел 9), а не самого приложения. Отдельно упомянуть рекомендацию настроить небольшой swap на машинах с малым объёмом RAM — дешёвая страховка на случай одновременного всплеска (бэкап + фильтрация журнала + несколько TLS-хендшейков одновременно).
### 10.1. Сборка и публикация образа (CI)
Образ собирается автоматически в CI и публикуется в container registry как **неизменяемый артефакт под тегом версии**. Именно это делает реально работающими два уже принятых требования: проверку совместимости при восстановлении бэкапа (раздел 7.5.А) и фиксированный тег образа в `docker-compose.yml` (п. 10 выше). Если бы пользователь пересобирал образ локально из `Dockerfile`, две сборки «одной и той же версии» на разных машинах/в разное время не были бы идентичны (дрейф базового `debian:bookworm-slim` и версий apt-пакетов), и гарантия «восстанавливай в ту же версию» держалась бы на честном слове. Публикуемый по тегу immutable-образ снимает это: любой инстанс тянет побитово один и тот же образ.
**Механика:**
1. **Триггер — git-тег вида `vX.Y.Z`.** Push тега запускает CI-сборку. Обычные коммиты в ветку образ не публикуют.
2. **Версия выводится из тега в одном месте** и прокидывается в сборку двумя связанными способами: в бинарник панели через `-ldflags "-X main.version=X.Y.Z"` (раздел 11.1) и в тег самого образа. Это структурно гарантирует инвариант «тег образа == вшитая версия бинарника», на который опирается 7.5.А, — версия не задаётся вручную в двух местах, где легко разойтись.
3. **Публикация** — `<registry>/<owner>/selfpost:X.Y.Z`, тег иммутабельный. `:latest` для потребления в `docker-compose.yml` не используется (п. 10 выше).
**CI и registry — GitHub.** Workflow (GitHub Actions) лежит в самом репозитории (`.github/workflows/`), зеркалится на GitHub вместе с остальным кодом (раздел 11.7) и **выполняется там**; образ публикуется в **GitHub Container Registry (`ghcr.io`)**. Обоснование: для public-образов `ghcr` бесплатен и **без rate-limit на анонимный pull** — в отличие от Docker Hub, где анонимный pull лимитирован, а образ тянут на голом VPS при деплое, часто без `docker login`. Связка Actions + `ghcr` не добавляет внешних сервисов сверх уже используемого GitHub-зеркала.
**Альтернатива — `Quay.io` (задокументировать, но по умолчанию не реализовывать).** Нейтральный registry, тоже без лимитов на анонимный pull для public-образов, со встроенным сканированием уязвимостей, и не завязанный на конкретного CI-вендора (пушить в него можно из любого CI, включая тот же GitHub Actions). Зафиксирован как готовый путь отхода на случай, если в будущем захочется отвязать хранение образа от GitHub или уйти от моновендорности, — переезд не затрагивает остальной дизайн (меняется только адрес registry в workflow и в примере `docker-compose.yml`). На текущем этапе выбран `ghcr` ради минимума движущихся частей.
---
## 11. Deliverables (что должно быть на выходе)
1. `Dockerfile` — сборка единого образа (Debian slim). Версия SelfPost зашивается в бинарник панели на этапе сборки (`go build -ldflags "-X main.version=..."`) и должна совпадать с тегом самого Docker-образа — используется для проверки совместимости при восстановлении бэкапа (раздел 7.5.А).
2. `supervisord.conf` — конфигурация процессов (Postfix, OpenDKIM, панель) с корректным `priority=` и стартовым скриптом-обёрткой для Postfix, ожидающим готовности milter-сокетов перед запуском (раздел 4).
3. Конфигурационные шаблоны Postfix (включая SASL, `smtpd_sender_login_maps`, milter-цепочку `smtpd_milters` с OpenDKIM + journal-milter) и OpenDKIM (`KeyTable`/`SigningTable`).
4. Исходный код панели на Go (структурированный проект), с вендоренным HTMX. Включает: HTTP-сервер панели, journal-milter (приём From/To/Subject/SASL-user), log-tailer (обновление статусов доставки), rate-limit проверку, работу с SQLite, логику полного бэкапа/восстановления и экспорта/импорта домена (раздел 7.5).
5. `docker-compose.yml` — основной с Apache как reverse-proxy; альтернативные фрагменты для nginx/Caddy/Traefik.
6. **CLI-утилита резервного копирования** (например, `selfpost-backup`) внутри образа, вызываемая через `docker exec`, — эквивалент кнопки бэкапа в панели, для скриптовых/cron-сценариев (раздел 7.5.А).
7. `README.md` — установка, требования к площадке, per-domain настройка DNS, прогрев IP, эксплуатация, **процедура полного бэкапа/восстановления и процедура экспорта/импорта домена** (с явным указанием, что оба типа файлов содержат секреты и требуют бережного обращения, как пароль). Также ссылка на репозиторий: **Codeberg — основной, GitHub — зеркало** (зеркалирование кода настраивается push-зеркалированием средствами Codeberg; поверх зеркала на стороне GitHub работает CI-workflow сборки и публикации образа — см. раздел 10.1 и п. 10 ниже). README также указывает, откуда тянуть образ (`ghcr.io`), и что `docker-compose.yml` использует фиксированный тег версии (раздел 10, п. 10).
8. Первичная инициализация — реализована как secret-link при первом запуске (раздел 7.6, п. 1), отдельного скрипта для задания пароля администратора не требуется. DKIM-ключи генерируются панелью per-domain при добавлении домена, а не на этом шаге.
9. **`LICENSE` — AGPL-3.0.** Выбор осознанный: в отличие от GPL, AGPL закрывает «SaaS-лазейку» — обязывает раскрывать исходники изменённой версии, если её разворачивают как сервис, доступный через сеть, а не только при распространении копии кода. Файл лицензии — полный текст AGPL-3.0, без сокращений. В `README.md` — явное упоминание лицензии и её смысла в двух-трёх предложениях.
10. **CI-workflow сборки и публикации образа (GitHub Actions)** — файл в `.github/workflows/`, запускаемый по git-тегу `vX.Y.Z`: собирает образ, прокидывает версию из тега в `-ldflags "-X main.version=..."` и в тег образа, публикует в `ghcr.io` как `ghcr.io/<owner>/selfpost:X.Y.Z` (раздел 10.1). Workflow зеркалится на GitHub и выполняется там. Публикация в `Quay.io` — как задокументированная альтернатива (раздел 10.1), не как основной путь.
---
## 12. Инструкции исполняющему агенту
1. **Не делать git-коммитов без явной инструкции в промпте.** Изменения вносятся в рабочее дерево; коммит — только по прямому указанию.
2. После написания Go-кода — обязательно выполнять `go build` и `go vet`; исправлять все ошибки компиляции и предупреждения до завершения задачи. При наличии тестов — `go test`.
3. Проверять, что образ собирается (`docker build`) и контейнер стартует, прежде чем считать задачу выполненной.
4. Двигаться итеративно: сначала минимальный работающий скелет (сборка образа, запуск трёх процессов, пустая панель с логином), затем наращивать функциональность.
5. Требования безопасности из раздела 7.6 — не откладывать «на потом», закладывать сразу при написании соответствующих эндпоинтов.
6. Любое отклонение от ограничений раздела 2 или добавление сущностей из раздела 3 (Out of Scope) — согласовывать, не реализовывать по своей инициативе.
7. **Перед тем как приступать к реализации, агент должен предложить конкретный план (список задач/этапов) на основе этого ТЗ** и дать возможность свериться/скорректировать план до начала написания кода — не начинать кодить сразу по первому сообщению без явного подтверждения плана.
8. **Лицензии внешних Go-зависимостей** (в частности, библиотеки для milter-протокола, раздел 7.3) — проверять перед добавлением. Postfix/OpenDKIM/Cyrus SASL/supervisord запускаются отдельными процессами и не ограничивают лицензию проекта (раздел 11, п.9, AGPL-3.0) независимо от своих лицензий. Библиотеки, которые компилируются непосредственно в Go-бинарник панели, должны быть permissive (MIT/BSD/Apache-2.0) либо GPL-семейства (GPL/LGPL — совместимы с AGPL-3.0 по построению); избегать зависимостей с иными, не проверенными на совместимость лицензиями.