release: 1.9.2: Review pass: port-25 milters, DMARC validation, rate-limit fix

Review pass over `1.4.0`…`1.9.1`: port 25 no longer shares the submission milter chain, DMARC reports are validated before they are stored, and the auto rate limit is correct for a non-default level-1 window. Schema migration `0010` (index only). Upgrading is a tag bump.
Added:
  - SQLite migration `0010_send_log_app_login_index.sql` —
  - DMARC aggregate reports are accepted as **zip** as well as gzip or plain XML
  - e2e: `checkPort25ConfigCombinations` regenerates the Postfix configuration
Changed:
  - README hero line and a **Positioning** block in `docs/product.md` record the
Fixed:
  - The setup account's password is hashed at bcrypt cost `12` like every other
  - Login/setup rate limiter: protecting blocked buckets from eviction is now
  - `postqueue -p` output is bounded to 4 MiB (the timeout alone left the size of
  - [schema-migrations.md](docs/schema-migrations.md): the schema section was
  - postqueue reader is now time-bounded: `postqueue -p` runs under a 5-second
  … (11 more in CHANGELOG)
Changed:
  - [roadmap.md](docs/roadmap.md) now carries everything the 2026-08-19 code
  - Extracted `internal/configsafe` — the empty-value and forbidden-character
  - Build, deploy and migration comments no longer cite the archived
  - Dropped the `parseDomainRateLimitForm` / `parseAppRateLimitForm` pass-through
  - Extracted shared helpers: `internal/atomicfile` (atomic temp-write+rename) and
  … (7 more in CHANGELOG)
Removed:
  - Unused legacy asset `docs/assets/icon.png` (superseded by the four brand SVGs
  - Plan documents for already-shipped features: `domain-stats-auto-ratelimit`,

Full detail: CHANGELOG.md (1.9.2).
This commit is contained in:
mix
2026-09-22 15:24:53 +03:00
parent 967d3d95de
commit 7ef7f75246
179 changed files with 11558 additions and 1738 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Design-contract lint for the panel mockups, and the source of outlines.json.
The contract (docs/plans/panel-redesign.md) is only worth something if a
machine can say "no". This script is that machine for the mockups; the same
rules are ported to a Go test over internal/web templates when the redesign is
implemented, and that test compares rendered pages with outlines.json.
python docs/assets/panel-redesign/panel/check.py # lint
python docs/assets/panel-redesign/panel/check.py --write # lint + rewrite outlines.json
Exit code 1 on any violation.
"""
import json
import pathlib
import re
import sys
from html.parser import HTMLParser
HERE = pathlib.Path(__file__).parent
VOID = {"input", "img", "br", "hr", "meta", "link"}
# The subset of Bulma the panel uses. Reaching for a Bulma class outside this
# list is a design decision, not an implementation detail: add it here and to
# the components page in the same change, or do without it.
BULMA = set("""
box button buttons checkbox column columns control field file file-cta file-input file-label
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list notification progress select table table-container tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
""".split())
# Classes the shell (build.py, later layout.html) owns; screens never write them.
SHELL = {"sp-page", "sp-perf", "sp-subnav", "sp-current", "sp-foot"}
# The signed-out screens have no shell, so they carry their own footer.
SIGNED_OUT = {"sp-signin", "sp-signin-brand", "sp-signin-form", "sp-foot"}
# Selectors allowed to set a width. Everything else gets its width from the
# layout it sits in — that is the whole point of the redesign.
WIDTH_OK = (".navbar-brand img", ".sp-head .sp-lead", ".sp-head .sp-grow", ".sp-postmark", ".table td.sp-clip",
".sp-h-icon", ".sp-signin", ".sp-timeline li::before", ".sp-perf")
class Node:
def __init__(self, tag, attrs, parent):
self.tag, self.attrs, self.parent, self.kids, self.text = tag, dict(attrs), parent, [], ""
@property
def classes(self):
return (self.attrs.get("class") or "").split()
def has(self, cls):
return cls in self.classes
def walk(self):
yield self
for k in self.kids:
yield from k.walk()
def find(self, cls):
return [n for n in self.walk() if n.has(cls)]
class Tree(HTMLParser):
def __init__(self):
super().__init__()
self.root = Node("root", [], None)
self.cur = self.root
def handle_starttag(self, tag, attrs):
n = Node(tag, attrs, self.cur)
self.cur.kids.append(n)
if tag not in VOID:
self.cur = n
def handle_endtag(self, tag):
n = self.cur
while n is not self.root and n.tag != tag:
n = n.parent
if n is not self.root:
self.cur = n.parent
def handle_data(self, data):
self.cur.text += data
def parse(text):
t = Tree()
t.feed(text)
return t.root
def text_of(node):
return re.sub(r"\s+", " ", "".join(n.text for n in node.walk())).strip()
def css_rules(css):
css = re.sub(r"/\*.*?\*/", "", css, flags=re.S)
css = re.sub(r"@media[^{]*\{", "", css)
for sel, body in re.findall(r"([^{}]+)\{([^{}]*)\}", css):
yield sel.strip(), body
def box_kind(box):
kinds = []
for k in box.kids:
if k.has("sp-box-head") or k.has("sp-box-foot"):
continue
for cls, name in (("sp-record", "record"), ("sp-health", "health"), ("sp-empty", "empty"),
("sp-log", "log"), ("sp-help-topic", "help"), ("sp-box-body", "body")):
if k.has(cls):
kinds.append(name)
break
else:
kinds.append("table" if k.tag == "table" or k.has("table-container") else k.tag)
out = []
for k in kinds:
if not out or out[-1].split("×")[0] != k:
out.append(k)
else:
n = int(out[-1].split("×")[1]) + 1 if "×" in out[-1] else 2
out[-1] = f"{k}×{n}"
return "+".join(out)
def outline(node):
"""The component skeleton of a page: what a reviewer sees with the text blurred."""
out = []
for k in node.kids:
if k.has("sp-head"):
parts = ["head"] + [p for p, c in (("postmark", "sp-postmark"), ("actions", "buttons")) if k.find(c)]
if not k.find("buttons") and any(n.tag in ("a", "form") and n.parent is k for n in k.walk()):
parts.append("actions")
out.append("+".join(parts))
elif k.has("box"):
variant = next((v for v in ("sp-danger-zone", "sp-credential") if k.has(v)), "")
head = k.find("sp-box-head")
title = text_of(head[0].find("sp-no")[0].parent.kids[1]) if head else "?"
out.append(f"box{'.' + variant if variant else ''}[{title}]:{box_kind(k)}")
elif k.has("columns"):
cols = []
for c in k.kids:
size = next((x for x in c.classes if re.fullmatch(r"is-\d+", x)), "auto")
cols.append({size: outline(c)})
out.append({"columns": cols})
elif k.tag == "form" or k.has("menu") or k.tag == "aside":
out.append({"form": outline(k)} if k.tag == "form" else "side_menu")
elif k.has("buttons"):
out.append("submit_row")
return out
def main():
errors = []
css = (HERE / "theme.css").read_text(encoding="utf-8")
defined = set(re.findall(r"\.(sp-[a-z0-9-]+)", css))
for sel, body in css_rules(css):
if "!important" in body:
errors.append(f"theme.css: !important in `{sel}`")
if re.search(r"(^|[\s,>+~])#[a-z]", sel):
errors.append(f"theme.css: id selector `{sel}` — style components, not one element on one page")
# The type floor: 13px. Icons (.ti) and the postmark imprint are not running text.
for value, unit in re.findall(r"font-size\s*:\s*([\d.]+)(rem|em|px)", body):
floor = {"rem": 0.8125, "em": 0.85, "px": 13}[unit]
if float(value) < floor and ".sp-postmark" not in sel and ".ti" not in sel:
errors.append(f"theme.css: `{sel}` sets font-size {value}{unit}, below the 13px floor — use --sp-fs-xs or --sp-fs-s")
# `min-width: 0` sizes nothing: it only lets a flex or grid child shrink so its text can be clipped.
sized = re.sub(r"min-width\s*:\s*0\s*(;|$)", "", body)
if re.search(r"(?<![-\w])(max-width|min-width|width)\s*:", sized) and not any(sel.startswith(w) or w in sel for w in WIDTH_OK):
errors.append(f"theme.css: `{sel}` sets a width; widths come from the page layout (WIDTH_OK)")
used, documented, outlines = set(), set(), {}
for src in sorted((HERE / "src").glob("*.html")):
text = src.read_text(encoding="utf-8")
name = src.stem
bare = "shell: bare" in text.split("-->")[0]
for bad in ("style=", "<style", "<script", " onclick=", " id=\"page-"):
if bad in text:
errors.append(f"{name}: `{bad}` is forbidden in a screen")
root = parse(text)
for n in root.walk():
for c in n.classes:
if c.startswith("sp-"):
(documented if name == "components" else used).add(c)
if c in SHELL and not (bare and c in SIGNED_OUT):
errors.append(f"{name}: `{c}` belongs to the shell")
elif c not in defined:
errors.append(f"{name}: `{c}` is not defined in theme.css")
elif c == "ti" or c.startswith("ti-"):
continue
elif c == "is-small" and "progress" not in n.classes:
errors.append(f"{name}: `is-small` on <{n.tag}> — controls keep the normal size; only a progress bar is small")
elif c not in BULMA:
errors.append(f"{name}: class `{c}` is neither in the Bulma subset nor an sp- component")
if bare:
continue
top = [k for k in root.kids if k.tag != "root"]
if not top or not top[0].has("sp-head"):
errors.append(f"{name}: the page must open with page_head (.sp-head)")
if len([n for n in root.walk() if n.tag == "h1"]) != 1:
errors.append(f"{name}: exactly one <h1>")
marks = root.find("sp-postmark")
if name != "components" and (len(marks) > 1 or any(not m.parent.has("sp-head") for m in marks)):
errors.append(f"{name}: at most one postmark, inside page_head")
for tbl in (n for n in root.walk() if n.tag == "table"):
if not tbl.parent.has("table-container"):
errors.append(f"{name}: a <table> must sit in a table-container so a wide one can scroll")
for box in root.find("box"):
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
# A flash is conditional, so it is allowed at the top level but never part of an outline.
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
outlines[name] = outline(root)
for c in sorted(used - documented - SIGNED_OUT):
errors.append(f"components page does not show `{c}` — document it or stop using it")
shell_used = set(re.findall(r"sp-[a-z0-9-]+", (HERE / "build.py").read_text(encoding="utf-8")))
for c in sorted(defined - used - documented - shell_used):
errors.append(f"theme.css defines `{c}` but nothing uses it")
if "--write" in sys.argv and not errors:
(HERE / "outlines.json").write_text(json.dumps(outlines, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8", newline="\n")
golden = HERE / "outlines.json"
if golden.exists() and "--write" not in sys.argv:
if json.loads(golden.read_text(encoding="utf-8")) != outlines:
errors.append("outlines.json is stale — a screen's structure changed; re-run with --write and say so in the commit")
for e in errors:
print("FAIL", e)
print(f"{len(outlines) + 1} screens, {len(used | documented)} sp- classes, {len(errors)} violation(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())