Compare commits
6
Commits
546a1aa461
...
4ff032a463
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ff032a463 | ||
|
|
f2ff8b4fea | ||
|
|
8f0ccf90cb | ||
|
|
75f97c9915 | ||
|
|
e3b25010b2 | ||
|
|
e92769f833 |
@@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.12.0] - 2026-10-17
|
||||
|
||||
A test message from Server › Health. No migration; schema 12.
|
||||
|
||||
### Added
|
||||
|
||||
- **A test message from Server › Health** (test-email): one message to an
|
||||
address you type, from `postmaster@` the hostname, the way notifications
|
||||
go. The page that opens is its row in the Outbound log, which follows it
|
||||
to the receiving server. It tests the server's own path, not a domain's.
|
||||
Global role, ten an hour.
|
||||
|
||||
## [2.11.0] - 2026-10-16
|
||||
|
||||
Health checks the certificate's name, Postfix's greeting and the reverse
|
||||
|
||||
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
> local image and pulls nothing:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.11.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.11.0 .
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.12.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.12.0 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:2.11.0
|
||||
ghcr.io/mixeme/selfpost:2.12.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.11.0
|
||||
image: ghcr.io/mixeme/selfpost:2.12.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
|
||||
@@ -590,6 +590,19 @@ inbound journal's. The tail of a line is the far end's words (a reply, a HELO
|
||||
name), and a pattern that could start there let a stranger's HELO name file a
|
||||
delivery (audit 2026-10-12, M12).
|
||||
|
||||
**The test message** (test-email, `2.12.0`) is the one row the panel writes
|
||||
itself: Server › Health hands it to `sendmail(1)`, which tells nothing of the
|
||||
queue id, and the journal-milter does not see local mail. The row is written
|
||||
first, queued and with no queue id, and its Message-ID is awaited
|
||||
(`logtail.AwaitMessageID`, in memory, ten minutes); cleanup's line
|
||||
`<id>: message-id=<…>` (Postfix 3.10.13, `src/cleanup/cleanup_message.c:639`)
|
||||
gives the row its queue id, and from then on its delivery lines update it as
|
||||
any row's. A restart in the second between leaves it without one, and the
|
||||
reconcile sweep closes it once the id is no longer awaited
|
||||
(`CloseUnmatchedTestMessages`); an id awaited longer than that is not taken
|
||||
any more. sendmail out of time keeps the row and the id - postdrop may have
|
||||
queued the message all the same.
|
||||
|
||||
**The upstream's answer** on the inbound journal (inbound-upstream-result,
|
||||
`2.10.0`). Mail accepted on port 25 is handed to its upstream by the same
|
||||
`smtp` client as outbound mail, and its delivery lines look alike; what tells
|
||||
|
||||
@@ -173,6 +173,19 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="test-message" data-order="9">
|
||||
<div class="sp-box-head"><span class="sp-no">09</span><h2>Test message</h2><div class="sp-end">Shows what the receiving server answers</div><a class="sp-help" href="help.html#test-message" title="What the test proves"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="out-message.html">
|
||||
<div class="field"><label class="label" for="rcpt">Recipient</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
|
||||
</div>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, signed with the server's own key, through Postfix as its notifications go. It opens in the <a href="out-log.html">Outbound log</a>, where its page follows it to the receiving server. Ten an hour.</p></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
|
||||
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
|
||||
<li><a href="#test-message"><i class="ti ti-send"></i>Test message</a></li>
|
||||
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Outbound</p>
|
||||
@@ -119,6 +120,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total. <em>Your address</em>, in the Configuration card, is the address the panel sees your browser at: behind a reverse proxy it must be your own, taken from <code>X-Forwarded-For</code>; when it is the proxy's, every visitor shares one sign-in limit, and Health warns and names the address to put in <code>TRUSTED_PROXY_CIDR</code>.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (<code>SELFPOST_HOSTNAME</code>) - the tag beside <em>Names</em> says which: a client that verifies the name refuses it however valid it is.</p></div>
|
||||
<div class="sp-help-topic" id="test-message"><h3>Test message</h3><p>A message from <code>postmaster@</code> the server's hostname to one address you type, sent the way notifications are: through Postfix, signed with the DKIM key of the server's own name. It proves the path from this server - TLS, its address's reputation, the SPF and DKIM records of its name - not your domains', which only mail from an application does. The page that opens is its row in the Outbound log: it shows whether the receiving server took it, put it off or refused it, with the lines of <code>mail.log</code>. Whether it then reached the inbox or the spam folder only the mailbox can say. The global role sends it, at most ten an hour.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. <em>Greeting</em> is the line Postfix opens every connection with, <code>220</code> and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or <code>localhost</code> there is a warning.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
@@ -90,7 +90,8 @@
|
||||
"box[Milter sockets]:table",
|
||||
"box[TLS certificate]:body",
|
||||
"box[Notification mail]:table+body",
|
||||
"box[Configuration]:body"
|
||||
"box[Configuration]:body",
|
||||
"box[Test message]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -107,7 +108,7 @@
|
||||
},
|
||||
{
|
||||
"is-9": [
|
||||
"box[Overview]:help×3",
|
||||
"box[Overview]:help×4",
|
||||
"box[Outbound]:help×7",
|
||||
"box[Inbound]:help×6",
|
||||
"box[Server]:help×4",
|
||||
|
||||
@@ -107,5 +107,18 @@
|
||||
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" id="test-message" data-order="9">
|
||||
<div class="sp-box-head"><span class="sp-no">09</span><h2>Test message</h2><div class="sp-end">Shows what the receiving server answers</div><a class="sp-help" href="help.html#test-message" title="What the test proves"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<form class="sp-form" method="post" action="out-message.html">
|
||||
<div class="field"><label class="label" for="rcpt">Recipient</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
|
||||
</div>
|
||||
<p class="help">From <span class="sp-mono">postmaster@mail.example.org</span>, signed with the server's own key, through Postfix as its notifications go. It opens in the <a href="out-log.html">Outbound log</a>, where its page follows it to the receiving server. Ten an hour.</p></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<ul class="menu-list">
|
||||
<li><a class="is-active" href="#checks"><i class="ti ti-activity-heartbeat"></i>Health checks</a></li>
|
||||
<li><a href="#certificate"><i class="ti ti-certificate"></i>TLS certificate</a></li>
|
||||
<li><a href="#test-message"><i class="ti ti-send"></i>Test message</a></li>
|
||||
<li><a href="#rdns"><i class="ti ti-arrows-exchange"></i>Reverse DNS</a></li>
|
||||
</ul>
|
||||
<p class="menu-label">Outbound</p>
|
||||
@@ -53,6 +54,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Overview</h2></div>
|
||||
<div class="sp-help-topic" id="checks"><h3>Health checks</h3><p>Six checks, and the worst of them is the postmark. <span class="tag is-success is-light">ok</span> needs nothing; <span class="tag is-warning is-light">warn</span> still delivers mail but will stop doing so if ignored; <span class="tag is-danger is-light">fail</span> means mail is being lost or refused now. <a href="health.html">Health</a> shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total. <em>Your address</em>, in the Configuration card, is the address the panel sees your browser at: behind a reverse proxy it must be your own, taken from <code>X-Forwarded-For</code>; when it is the proxy's, every visitor shares one sign-in limit, and Health warns and names the address to put in <code>TRUSTED_PROXY_CIDR</code>.</p></div>
|
||||
<div class="sp-help-topic" id="certificate"><h3>TLS certificate</h3><p>The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (<code>SELFPOST_HOSTNAME</code>) - the tag beside <em>Names</em> says which: a client that verifies the name refuses it however valid it is.</p></div>
|
||||
<div class="sp-help-topic" id="test-message"><h3>Test message</h3><p>A message from <code>postmaster@</code> the server's hostname to one address you type, sent the way notifications are: through Postfix, signed with the DKIM key of the server's own name. It proves the path from this server - TLS, its address's reputation, the SPF and DKIM records of its name - not your domains', which only mail from an application does. The page that opens is its row in the Outbound log: it shows whether the receiving server took it, put it off or refused it, with the lines of <code>mail.log</code>. Whether it then reached the inbox or the spam folder only the mailbox can say. The global role sends it, at most ten an hour.</p></div>
|
||||
<div class="sp-help-topic" id="rdns"><h3>Hostname and reverse DNS</h3><p>The hostname (<code>SELFPOST_HOSTNAME</code>) must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. <em>Greeting</em> is the line Postfix opens every connection with, <code>220</code> and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or <code>localhost</code> there is a warning.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
+2
-1
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||
[§ Release image](#release-image)).
|
||||
|
||||
**Planned version cuts** (from pin `2.11.0`; adjust if semver changes mid-track):
|
||||
**Planned version cuts** (from pin `2.12.0`; adjust if semver changes mid-track):
|
||||
|
||||
| Stage | ID | Cut |
|
||||
|---|---|---|
|
||||
@@ -148,6 +148,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
| 35 | backup-reauth | `2.10.3` (PATCH) |
|
||||
| 36 | origin-strict | `2.10.4` (PATCH) |
|
||||
| 37 | health-setup-checks | `2.11.0` |
|
||||
| 38 | test-email | `2.12.0` |
|
||||
|
||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||
commit rule but **no** version cut until the next product stage ships.
|
||||
|
||||
+12
-4
@@ -204,14 +204,14 @@ cat ./data/setup-token
|
||||
#### Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `2.11.0`. **No 2.x image is
|
||||
deliberately never `:latest`. The current pin is `2.12.0`. **No 2.x image is
|
||||
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
||||
it from the checkout under the pinned name, and Compose uses the local image
|
||||
instead of pulling:
|
||||
|
||||
```sh
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.11.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.11.0 .
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.12.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.12.0 .
|
||||
```
|
||||
|
||||
`VERSION` must equal the tag: it is the version the backup check compares
|
||||
@@ -514,6 +514,14 @@ readings, in the order of what stops mail first:
|
||||
if the daemons drifted from what the panel shows after manual edits under
|
||||
`/data`. A step that fails is named on the page, and the panel's log says
|
||||
why.
|
||||
- **Test message** — sends one message to an address you type, from
|
||||
`postmaster@` the hostname, the way notifications go (through Postfix,
|
||||
signed with the key of the server's own name). The page that opens is its
|
||||
row in **Outbound › Log**, which follows it to the receiving server: sent,
|
||||
put off or refused, with the lines of `mail.log`. It tests the server's
|
||||
own path - TLS, its address, the records of its name - not a domain's,
|
||||
which only an application's mail does; and whether it reached the inbox
|
||||
only the mailbox can say. Ten an hour, global role.
|
||||
|
||||
### Queue and System log
|
||||
|
||||
@@ -962,7 +970,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||
the panel exits with a message naming the tag to use, e.g.:
|
||||
|
||||
```
|
||||
backup: this backup was created by SelfPost 2.10.4 but this image is 2.11.0 — restore into the matching image (selfpost:2.10.4)
|
||||
backup: this backup was created by SelfPost 2.11.0 but this image is 2.12.0 — restore into the matching image (selfpost:2.11.0)
|
||||
```
|
||||
|
||||
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
|
||||
|
||||
+8
-1
@@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
|---|---|---|---|---|
|
||||
| inbound-upstream-result | Inbound › Log shows what the upstream answered: sent, deferred, bounced, with its last reply | shipped `2.10.0` | 7/7 | [plans/inbound-upstream-result.md](plans/inbound-upstream-result.md) |
|
||||
| health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | shipped `2.11.0` | — | — |
|
||||
| test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — |
|
||||
| test-email | Send a test message from the panel (was preflight) | shipped `2.12.0` | — | — |
|
||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||
| origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | shipped `2.10.4` | — | — |
|
||||
| backup-reauth | Ask for the account password before a backup or a domain export is downloaded | shipped `2.10.3` | — | — |
|
||||
@@ -211,6 +211,13 @@ the mockup; mockup-first.
|
||||
rate-limited and refuses a recipient list, and it passes a security review -
|
||||
a form that sends mail is a new surface.
|
||||
|
||||
**Done (2026-10-17):** from `postmaster@` the hostname (the owner,
|
||||
2026-10-16: "Да, адрес годится"), card 09 on Health in the static stack
|
||||
(mockups `e92769f`, `e3b2501`, `8f0ccf9`). The row is written before the
|
||||
hand-over and learns its queue id from cleanup's Message-ID line
|
||||
(`logtail.AwaitMessageID`); the browser lands on it. One address
|
||||
(`validate.AccountEmail`), ten an hour from the send log, global role.
|
||||
|
||||
**Version:** `2.x` MINOR; `agreed`.
|
||||
|
||||
---
|
||||
|
||||
@@ -686,6 +686,22 @@ deferred item from the roadmap.
|
||||
a dozen times a day at most by someone who flaps the zone; a domain's
|
||||
limits once a day; and no more than 60 messages leave in any hour whatever
|
||||
the rules arrive at.
|
||||
- **The test message is a form that sends mail** (test-email, `2.12.0`):
|
||||
Server › Health sends one message to an address a global user types. It is
|
||||
bounded as notifications are - the same `sendmail(1)` call, a fixed argument
|
||||
list, the address after `--` - and further: the global role only; one
|
||||
address, held by `validate.AccountEmail` (no list, no separator, nothing
|
||||
that reads as an option, any public host); a fixed subject and body the
|
||||
panel composes; ten an hour, counted from the send log whoever asks; the
|
||||
origin check of every form. It is written to the Outbound log before it
|
||||
goes, so each one is on record with its recipient and who asked is in the
|
||||
panel's log. It cannot be made to say anything but the panel's own text,
|
||||
and the sender is always `postmaster@` the hostname. Its row learns its
|
||||
queue id from the head of cleanup's `message-id=` line; cleanup logs a
|
||||
client's own Message-ID the same way, but the panel's is 96 random bits,
|
||||
known to nobody before the line is read, taken once and not after ten
|
||||
minutes - a message of someone else's cannot take the row (security
|
||||
review, 2026-10-17).
|
||||
- **Whatever is submitted with sendmail(1) is signed as the server.** The
|
||||
signer's local socket, the whole of `non_smtpd_milters`, signs every
|
||||
message submitted locally with the key of the server's own name, whatever
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The panel's own test message (Server › Health, test-email) is handed to
|
||||
// Postfix through sendmail, which says nothing of the queue id it gets; the
|
||||
// journal-milter does not see local mail (non_smtpd_milters is the signer
|
||||
// alone). Its row is written first, with no queue id, and its Message-ID is
|
||||
// awaited here: cleanup logs each message's Message-ID with its queue id,
|
||||
//
|
||||
// postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=<9f3c....1760600000@mail.example.org>
|
||||
//
|
||||
// (Postfix 3.10.13, src/cleanup/cleanup_message.c:639, msg_info("%s:
|
||||
// message-id=%s")), and the tailer puts that queue id on the row. From then on
|
||||
// the row is any row: the delivery lines of its queue id give it its status.
|
||||
// The awaited ids live in memory: a restart between the send and cleanup's
|
||||
// line, a matter of a second, leaves the row without a queue id, and the
|
||||
// reconcile sweep closes it once awaitedTTL has passed
|
||||
// (StatusStore.CloseUnmatchedTestMessages).
|
||||
|
||||
// messageIDRe matches cleanup's Message-ID line from the head, after the time
|
||||
// stamp: the id is the client's text, so only a whole line of this shape counts.
|
||||
var messageIDRe = regexp.MustCompile(`^\S+ \S*/cleanup\[\d+\]: ([0-9A-Za-z]+): message-id=(<[^<>\s]+>)\s*$`)
|
||||
|
||||
// awaitedTTL is how long an awaited Message-ID is kept: far longer than
|
||||
// sendmail's hand-over takes, and short enough that a lost one goes.
|
||||
const awaitedTTL = 10 * time.Minute
|
||||
|
||||
type awaitedIDs struct {
|
||||
mu sync.Mutex
|
||||
ids map[string]awaitedRow
|
||||
}
|
||||
|
||||
type awaitedRow struct {
|
||||
row int64
|
||||
since time.Time
|
||||
}
|
||||
|
||||
var awaited = &awaitedIDs{ids: make(map[string]awaitedRow)}
|
||||
|
||||
// AwaitMessageID registers the send-log row of a message about to be handed
|
||||
// to Postfix under its Message-ID ("<...>"), for the tailer to give it its
|
||||
// queue id. Call it before the message is sent.
|
||||
func AwaitMessageID(id string, row int64) {
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, v := range awaited.ids {
|
||||
if now.Sub(v.since) > awaitedTTL {
|
||||
delete(awaited.ids, k)
|
||||
}
|
||||
}
|
||||
awaited.ids[id] = awaitedRow{row: row, since: now}
|
||||
}
|
||||
|
||||
// ForgetMessageID drops an awaited id: the message was not handed over.
|
||||
func ForgetMessageID(id string) {
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
delete(awaited.ids, id)
|
||||
}
|
||||
|
||||
// takeAwaited returns the row awaiting a Message-ID line's id, if any, and
|
||||
// stops awaiting it.
|
||||
func takeAwaited(rest string) (queueID string, row int64, ok bool) {
|
||||
m := messageIDRe.FindStringSubmatch(rest)
|
||||
if m == nil {
|
||||
return "", 0, false
|
||||
}
|
||||
awaited.mu.Lock()
|
||||
defer awaited.mu.Unlock()
|
||||
a, ok := awaited.ids[m[2]]
|
||||
if !ok {
|
||||
return "", 0, false
|
||||
}
|
||||
delete(awaited.ids, m[2])
|
||||
// An id awaited too long belongs to a row the sweep has closed by now;
|
||||
// whoever received the message knows the id, and a line of his with it
|
||||
// must not reach that row.
|
||||
if time.Since(a.since) > awaitedTTL {
|
||||
return "", 0, false
|
||||
}
|
||||
return m[1], a.row, true
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package logtail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The test message's row learns its queue id from cleanup's Message-ID line
|
||||
// - once, for the id awaited, and from no other program's line or shape.
|
||||
func TestAwaitedMessageID(t *testing.T) {
|
||||
id := "<9f3c01.1760600000@mail.example.org>"
|
||||
AwaitMessageID(id, 42)
|
||||
for _, line := range []string{
|
||||
"selfpost postfix/smtpd[52]: 4j5qN53nwRz791P4: message-id=" + id,
|
||||
"selfpost postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=" + id + " trailing",
|
||||
"selfpost postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=<other@mail.example.org>",
|
||||
} {
|
||||
if q, row, ok := takeAwaited(line); ok {
|
||||
t.Fatalf("%q took row %d with %s", line, row, q)
|
||||
}
|
||||
}
|
||||
q, row, ok := takeAwaited("selfpost postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=" + id)
|
||||
if !ok || q != "4j5qN53nwRz791P4" || row != 42 {
|
||||
t.Fatalf("cleanup's line = %q, %d, %v", q, row, ok)
|
||||
}
|
||||
if _, _, ok := takeAwaited("selfpost postfix/cleanup[52]: 4j5qN53nwRz791P4: message-id=" + id); ok {
|
||||
t.Fatal("the same id was taken twice")
|
||||
}
|
||||
AwaitMessageID(id, 43)
|
||||
ForgetMessageID(id)
|
||||
if _, _, ok := takeAwaited("selfpost postfix/cleanup[52]: X: message-id=" + id); ok {
|
||||
t.Fatal("a forgotten id was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Run puts the queue id on the row as the line is read.
|
||||
func TestRunGivesTheTestMessageItsQueueID(t *testing.T) {
|
||||
old := pollInterval
|
||||
pollInterval = 10 * time.Millisecond
|
||||
t.Cleanup(func() { pollInterval = old })
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cs := &captureStore{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Run(ctx, path, cs, func() int { return 90 }, nil, nil) }()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
AwaitMessageID("<t1@mail.example.org>", 7)
|
||||
appendLine(t, path, "Oct 17 09:00:00 host postfix/cleanup[3]: Q7long: message-id=<t1@mail.example.org>")
|
||||
waitFor(t, func() bool { return contains(cs.snapshot(), "7|Q7long|queue-id") })
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Run did not return after cancel")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ import (
|
||||
// far into mail.log it has read. *store.Store satisfies it.
|
||||
type StatusStore interface {
|
||||
UpdateStatus(queueID, recipient, status string) (int64, error)
|
||||
// SetSendLogQueueID gives the panel's test message its queue id
|
||||
// (awaited.go); CloseUnmatchedTestMessages closes the test messages
|
||||
// that never got one.
|
||||
SetSendLogQueueID(id int64, queueID string) error
|
||||
CloseUnmatchedTestMessages(before time.Time) (int64, error)
|
||||
ExpireMessage(queueID string) (int64, error)
|
||||
ListOpenOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
|
||||
CloseUnreported(id int64) (int64, error)
|
||||
@@ -256,6 +261,14 @@ func Run(ctx context.Context, path string, st StatusStore, retention RetentionDa
|
||||
}
|
||||
keeper.add(line, now, catchingUp)
|
||||
refusals.handle(line, now, Place{FP: tr.line.FP, End: tr.lineStart})
|
||||
// The panel's test message learns its queue id from cleanup's line.
|
||||
if _, rest := SplitTimestamp(line); rest != "" {
|
||||
if qid, row, ok := takeAwaited(rest); ok {
|
||||
if err := st.SetSendLogQueueID(row, qid); err != nil {
|
||||
log.Printf("log-tailer: test message %d -> %s: %v", row, qid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A delivery or expiry line no send-log row takes may be of mail
|
||||
// accepted on port 25: the inbound journal's rows take its result.
|
||||
queueID, recipient, status, ok := parseDelivery(line)
|
||||
@@ -313,6 +326,9 @@ func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}
|
||||
now := time.Now().UTC()
|
||||
reconcile(st, now.Add(-reconcileGrace), path)
|
||||
countStuck(st, now, overLifetime)
|
||||
if _, err := st.CloseUnmatchedTestMessages(now.Add(-awaitedTTL)); err != nil {
|
||||
log.Printf("log-tailer: close test messages with no queue id: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,16 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (c *captureStore) CloseUnmatchedTestMessages(time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
// SetSendLogQueueID records a test message's queue id as "row|queue-id|queue-id".
|
||||
func (c *captureStore) SetSendLogQueueID(id int64, queueID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.calls = append(c.calls, strconv.FormatInt(id, 10)+"|"+queueID+"|queue-id")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpireMessage records an expiry line's close as "queue-id|*|expired".
|
||||
func (c *captureStore) ExpireMessage(queueID string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -19,6 +19,10 @@ func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *retentionProbeStore) SetSendLogQueueID(int64, string) error { return nil }
|
||||
|
||||
func (s *retentionProbeStore) CloseUnmatchedTestMessages(time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
func (s *retentionProbeStore) ListOpenOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os/exec"
|
||||
@@ -38,6 +39,21 @@ func From(hostname string) string { return "postmaster@" + hostname }
|
||||
|
||||
// Send submits one message to one recipient.
|
||||
func (m *Mailer) Send(to, subject, body string) error {
|
||||
id, err := NewMessageID(m.hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.SendWithID(to, subject, body, id)
|
||||
}
|
||||
|
||||
// NewMessageID makes a Message-ID of the server's own name, for a message the
|
||||
// caller wants to know again in mail.log (the panel's test message).
|
||||
func NewMessageID(hostname string) (string, error) { return messageID(hostname) }
|
||||
|
||||
// SendWithID is Send with the Message-ID given: cleanup logs it with the
|
||||
// message's queue id, which is how the panel finds its test message in
|
||||
// mail.log (logtail.AwaitMessageID).
|
||||
func (m *Mailer) SendWithID(to, subject, body, id string) error {
|
||||
// The address is a panel user's, validated when it was saved. It goes into
|
||||
// a header and onto a command line all the same, so what cannot be part of
|
||||
// an address is refused here as well (security.md).
|
||||
@@ -47,10 +63,6 @@ func (m *Mailer) Send(to, subject, body string) error {
|
||||
return fmt.Errorf("not an address a notification can go to: %q", to)
|
||||
}
|
||||
from := From(m.hostname)
|
||||
id, err := messageID(m.hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
fmt.Fprintf(&b, "From: SelfPost <%s>\r\n", from)
|
||||
@@ -81,6 +93,10 @@ func messageID(hostname string) (string, error) {
|
||||
return fmt.Sprintf("<%s.%d@%s>", hex.EncodeToString(raw[:]), time.Now().Unix(), hostname), nil
|
||||
}
|
||||
|
||||
// ErrTimeout is sendmail not answering in time: postdrop may have queued the
|
||||
// message all the same.
|
||||
var ErrTimeout = errors.New("sendmail timed out")
|
||||
|
||||
// sendmail submits msg through Postfix's sendmail command. The argument list is
|
||||
// fixed apart from the two addresses, which follow "--" so that neither can be
|
||||
// read as an option; nothing goes through a shell (security.md). -i keeps a
|
||||
@@ -95,7 +111,7 @@ func sendmail(from, to string, msg []byte) error {
|
||||
cmd.WaitDelay = 5 * time.Second
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("sendmail timed out after %s", sendmailTimeout)
|
||||
return fmt.Errorf("%w after %s", ErrTimeout, sendmailTimeout)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("sendmail: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
|
||||
@@ -76,6 +76,71 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertTestMessage records the panel's own test message (Server › Health,
|
||||
// test-email) as queued, before it is handed to Postfix: from postmaster@ the
|
||||
// hostname, under the hostname as its domain, with no application and no
|
||||
// queue id yet - cleanup assigns one, and SetSendLogQueueID puts it on the
|
||||
// row once the tailer reads it. It returns the row's id.
|
||||
func (s *Store) InsertTestMessage(domain, from, to, subject string) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO send_log (queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||
VALUES ('', ?, '', ?, ?, ?, ?, ?, ?)`,
|
||||
domain, from, to, subject, StatusQueued, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert test message: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SetSendLogQueueID gives a row that has none the queue id Postfix assigned
|
||||
// its message. A row that has one keeps it.
|
||||
func (s *Store) SetSendLogQueueID(id int64, queueID string) error {
|
||||
if _, err := s.db.Exec(`UPDATE send_log SET queue_id = ? WHERE id = ? AND queue_id = ''`, queueID, id); err != nil {
|
||||
return fmt.Errorf("set queue id of send_log %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseUnmatchedTestMessages closes the test messages written before then
|
||||
// that never learned a queue id - the panel restarted between the hand-over
|
||||
// and cleanup's line, or the line was missed: nothing will ever report on
|
||||
// them. They are closed as the sweep closes a row (bounced, reason
|
||||
// reconcile), so their page says the panel decided it.
|
||||
func (s *Store) CloseUnmatchedTestMessages(before time.Time) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE send_log SET status = ?, reason = ?, updated_at = ?
|
||||
WHERE queue_id = '' AND app_login = '' AND status = ? AND created_at < ?`,
|
||||
StatusBounced, ReasonReconcile, now, StatusQueued, before.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("close test messages with no queue id: %w", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// DeleteSendLogRow removes one row: a test message Postfix did not take.
|
||||
func (s *Store) DeleteSendLogRow(id int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM send_log WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete send_log %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountTestMessagesSince counts the test messages sent from addr since then:
|
||||
// rows from that address with no application, the shape InsertTestMessage
|
||||
// writes.
|
||||
func (s *Store) CountTestMessagesSince(addr string, since time.Time) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log WHERE from_addr = ? AND app_login = '' AND created_at >= ?`,
|
||||
addr, since.UTC().Format(time.RFC3339)).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count test messages: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// InsertRejected records a refused message (StatusRejected), so the refusal is
|
||||
// visible in the send-log UI with its reason. The journal-milter writes its
|
||||
// MAIL FROM refusals with only the fields known there (domain, sender, app
|
||||
|
||||
@@ -500,3 +500,63 @@ func TestALoginTakenAgainStartsWithNoNumbers(t *testing.T) {
|
||||
t.Fatalf("level-2 count of the new news = %d, want its own one", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The test message's row: written queued with no queue id, given the one
|
||||
// cleanup assigned (and only once), counted per sender and hour, removed
|
||||
// when Postfix did not take it.
|
||||
func TestTestMessageRows(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
from := "postmaster@mail.example.org"
|
||||
id, err := st.InsertTestMessage("mail.example.org", from, "ops@elsewhere.example", "SelfPost test message")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSendLogQueueID(id, "4j5qN53nwRz791P4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSendLogQueueID(id, "other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := st.GetSendLog(id)
|
||||
if err != nil || row.QueueID != "4j5qN53nwRz791P4" || row.Status != StatusQueued || row.AppLogin != "" || row.Domain != "mail.example.org" {
|
||||
t.Fatalf("row = %+v, %v", row, err)
|
||||
}
|
||||
if n, err := st.UpdateStatus("4j5qN53nwRz791P4", "ops@elsewhere.example", StatusSent); err != nil || n != 1 {
|
||||
t.Fatalf("its delivery line = %d, %v", n, err)
|
||||
}
|
||||
if n, err := st.CountTestMessagesSince(from, time.Now().Add(-time.Hour)); err != nil || n != 1 {
|
||||
t.Fatalf("count = %d, %v", n, err)
|
||||
}
|
||||
if n, _ := st.CountTestMessagesSince(from, time.Now().Add(time.Minute)); n != 0 {
|
||||
t.Fatalf("count from the future = %d", n)
|
||||
}
|
||||
if err := st.DeleteSendLogRow(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := st.CountTestMessagesSince(from, time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("a deleted row is counted")
|
||||
}
|
||||
}
|
||||
|
||||
// A test message that never learned its queue id is closed by the sweep once
|
||||
// it is no longer awaited; one that did, and any application's row, is not.
|
||||
func TestCloseUnmatchedTestMessages(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
lost, _ := st.InsertTestMessage("mail.example.org", "postmaster@mail.example.org", "a@elsewhere.example", "SelfPost test message")
|
||||
found, _ := st.InsertTestMessage("mail.example.org", "postmaster@mail.example.org", "b@elsewhere.example", "SelfPost test message")
|
||||
if err := st.SetSendLogQueueID(found, "Q1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := st.CloseUnmatchedTestMessages(time.Now().Add(-time.Hour)); err != nil || n != 0 {
|
||||
t.Fatalf("a fresh row was closed: %d, %v", n, err)
|
||||
}
|
||||
if n, err := st.CloseUnmatchedTestMessages(time.Now().Add(time.Minute)); err != nil || n != 1 {
|
||||
t.Fatalf("closed %d, %v; want the one with no queue id", n, err)
|
||||
}
|
||||
if r, _ := st.GetSendLog(lost); r.Status != StatusBounced || r.Reason != ReasonReconcile {
|
||||
t.Errorf("the lost row = %+v", r)
|
||||
}
|
||||
if r, _ := st.GetSendLog(found); r.Status != StatusQueued {
|
||||
t.Errorf("the row with a queue id = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ var routes = struct{ always, inbound, dmarc []string }{
|
||||
"GET /{$}",
|
||||
"GET /overview", "GET /overview/fragment",
|
||||
"GET /server/health", "GET /server/health/fragment",
|
||||
"POST /server/health/recheck", "POST /server/health/reload",
|
||||
"POST /server/health/recheck", "POST /server/health/reload", "POST /server/health/test-message",
|
||||
"GET /outbound/domains", "POST /outbound/domains",
|
||||
"GET /outbound/domains/{id}", "POST /outbound/domains/{id}/dns-recheck",
|
||||
"GET /outbound/domains/{id}/settings",
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
@@ -74,6 +75,14 @@ type Handlers struct {
|
||||
dns *dnscheck.Checker
|
||||
machine *health.MachineSampler
|
||||
auth *auth.Module
|
||||
// testMail sends the panel's test message (notify.Mailer; tests
|
||||
// substitute one that records).
|
||||
testMail testMailer
|
||||
}
|
||||
|
||||
// testMailer is what sends the test message: notify.Mailer.SendWithID.
|
||||
type testMailer interface {
|
||||
SendWithID(to, subject, body, id string) error
|
||||
}
|
||||
|
||||
// New builds authenticated panel handlers.
|
||||
@@ -102,6 +111,8 @@ func New(
|
||||
dns: dns,
|
||||
machine: machine,
|
||||
auth: a,
|
||||
// The test message goes as notifications go (test-email).
|
||||
testMail: notify.NewMailer(cfg.Hostname),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -12,8 +13,10 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
@@ -139,12 +142,94 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// testMessagesPerHour is how many test messages the panel sends in an hour,
|
||||
// counted from the send log, whoever asked: a form that sends mail must not
|
||||
// become a way to send a lot of it.
|
||||
const testMessagesPerHour = 10
|
||||
|
||||
// HandleTestMessage sends the panel's test message (test-email; owner
|
||||
// 2026-10-13, its sender his choice of 2026-10-16): from postmaster@ the
|
||||
// hostname to the one address typed, as notifications go - through
|
||||
// sendmail, signed by the signer's local socket with the key of the
|
||||
// server's own name. The row is written first, with the Message-ID the
|
||||
// tailer awaits to give it its queue id, and the browser goes to it: its
|
||||
// page follows the message to the receiving server. Global role only; one
|
||||
// address (validate.AccountEmail refuses a list, as it does a name that
|
||||
// reads as an option); ten an hour.
|
||||
func (h *Handlers) HandleTestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := h.requireGlobal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from := notify.From(h.cfg.Hostname)
|
||||
to := ""
|
||||
fail := func(status int, msg string) {
|
||||
page := h.healthView(r, "")
|
||||
page.WithTestMessage(from, to, msg)
|
||||
h.view.Render(w, status, "health", page)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
to = strings.TrimSpace(r.PostFormValue("recipient"))
|
||||
if to == "" || validate.AccountEmail(to) != nil {
|
||||
fail(http.StatusBadRequest, "Enter one e-mail address.")
|
||||
return
|
||||
}
|
||||
n, err := h.store.CountTestMessagesSince(from, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
if n >= testMessagesPerHour {
|
||||
fail(http.StatusTooManyRequests, "Ten test messages have gone in the last hour. Please try again later.")
|
||||
return
|
||||
}
|
||||
id, err := notify.NewMessageID(h.cfg.Hostname)
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
const subject = "SelfPost test message"
|
||||
now := time.Now().UTC()
|
||||
body := "A test message from SelfPost at " + h.cfg.Hostname + ", sent from Server › Health on " +
|
||||
now.Format("2006-01-02 at 15:04 UTC") + ".\n\nIt came from this server, signed with the DKIM key of its own name. " +
|
||||
"Its page in the panel's Outbound log shows what this mail server answered.\n"
|
||||
row, err := h.store.InsertTestMessage(h.cfg.Hostname, from, to, subject)
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
logtail.AwaitMessageID(id, row)
|
||||
if err := h.testMail.SendWithID(to, subject, body, id); errors.Is(err, notify.ErrTimeout) {
|
||||
// postdrop may have queued it after all: the row and the awaited id
|
||||
// stay, and the row's page shows what became of it - or the sweep
|
||||
// closes the row once the id is no longer awaited.
|
||||
logf("panel: test message to %s (send log %d): %v", to, row, err)
|
||||
} else if err != nil {
|
||||
logtail.ForgetMessageID(id)
|
||||
if derr := h.store.DeleteSendLogRow(row); derr != nil {
|
||||
logf("panel: test message: %v", derr)
|
||||
}
|
||||
logf("panel: test message to %s: %v", to, err)
|
||||
fail(http.StatusBadGateway, "Postfix did not take the message; the container log says why.")
|
||||
return
|
||||
}
|
||||
logf("panel: user %d sent a test message to %s (send log %d)", p.ID, to, row)
|
||||
http.Redirect(w, r, "/outbound/log/"+strconv.FormatInt(row, 10), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// healthView is the Health page for the request: the checks behind the cards
|
||||
// of Overview and the Notification mail card, which only Health shows.
|
||||
func (h *Handlers) healthView(r *http.Request, flash string) *view.Health {
|
||||
page := healthPage(h.shellMeta(r), h.healthChecks(), flash)
|
||||
seen := h.auth.Seen(r)
|
||||
page.WithClientAddress(seen.Client, seen.Peer, seen.FromHeader, seen.HeaderIgnored)
|
||||
page.WithTestMessage(notify.From(h.cfg.Hostname), "", "")
|
||||
mail := h.serverMail(false)
|
||||
page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
stats, err := h.store.SenderCheckStats(time.Now())
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
)
|
||||
|
||||
// recordingMailer stands in for notify.Mailer: what was sent, or the error to
|
||||
// answer with.
|
||||
type recordingMailer struct {
|
||||
to, subject, body, id []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *recordingMailer) SendWithID(to, subject, body, id string) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.to, m.subject, m.body, m.id = append(m.to, to), append(m.subject, subject), append(m.body, body), append(m.id, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The test message (test-email): one address, from postmaster@ the hostname,
|
||||
// written to the send log before it goes and opened there; refused for a
|
||||
// list or a non-address with the form kept; not left in the log when Postfix
|
||||
// does not take it; ten an hour.
|
||||
func TestTestMessage(t *testing.T) {
|
||||
h, _, _ := serverMailStand(t)
|
||||
mail := &recordingMailer{}
|
||||
h.testMail = mail
|
||||
send := func(to string) (int, string, string) {
|
||||
rec := postFormAs(h.HandleTestMessage, globalPrincipal, "/server/health/test-message", nil, url.Values{"recipient": {to}})
|
||||
return rec.Code, rec.Header().Get("Location"), rec.Body.String()
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", "a@example.net, b@example.net", "not an address", "-oQ@example.net"} {
|
||||
code, _, body := send(bad)
|
||||
if code != http.StatusBadRequest || !strings.Contains(body, `<p class="help is-danger">Enter one e-mail address.</p>`) {
|
||||
t.Fatalf("recipient %q = %d:\n%.800s", bad, code, body)
|
||||
}
|
||||
}
|
||||
if len(mail.to) != 0 {
|
||||
t.Fatalf("something was sent for a refused recipient: %q", mail.to)
|
||||
}
|
||||
|
||||
code, loc, _ := send("ops@elsewhere.example")
|
||||
if code != http.StatusSeeOther || !strings.HasPrefix(loc, "/outbound/log/") {
|
||||
t.Fatalf("send = %d to %q", code, loc)
|
||||
}
|
||||
if len(mail.to) != 1 || mail.to[0] != "ops@elsewhere.example" || mail.subject[0] != "SelfPost test message" ||
|
||||
!strings.HasPrefix(mail.id[0], "<") || !strings.HasSuffix(mail.id[0], "@"+notifyHost+">") {
|
||||
t.Fatalf("sent %+v", mail)
|
||||
}
|
||||
out := getBody(t, h.HandleDelivery, loc)
|
||||
if !strings.Contains(out, "ops@elsewhere.example") || !strings.Contains(out, "postmaster@"+notifyHost) {
|
||||
t.Errorf("the test message's page:\n%.2000s", out)
|
||||
}
|
||||
|
||||
// Postfix does not take it: nothing stays in the log, the form says so.
|
||||
mail.err = errors.New("sendmail: exit status 75")
|
||||
code, _, body := send("ops@elsewhere.example")
|
||||
if code != http.StatusBadGateway || !strings.Contains(body, "Postfix did not take the message") ||
|
||||
!strings.Contains(body, `value="ops@elsewhere.example"`) {
|
||||
t.Fatalf("a refused hand-over = %d:\n%.800s", code, body)
|
||||
}
|
||||
if n, _ := h.store.CountTestMessagesSince("postmaster@"+notifyHost, timeAgo()); n != 1 {
|
||||
t.Fatalf("%d test messages in the log, want the one Postfix took", n)
|
||||
}
|
||||
|
||||
// Ten an hour, whoever asks.
|
||||
mail.err = nil
|
||||
for i := 0; i < 9; i++ {
|
||||
if code, _, _ := send("ops@elsewhere.example"); code != http.StatusSeeOther {
|
||||
t.Fatalf("message %d of ten = %d", i+2, code)
|
||||
}
|
||||
}
|
||||
if code, _, body := send("ops@elsewhere.example"); code != http.StatusTooManyRequests || !strings.Contains(body, "Ten test messages have gone in the last hour.") {
|
||||
t.Fatalf("the eleventh = %d:\n%.600s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func timeAgo() time.Time { return time.Now().Add(-time.Hour) }
|
||||
|
||||
// sendmail out of time may have queued the message all the same: the row
|
||||
// stays and the browser goes to it.
|
||||
func TestTestMessageTimeoutKeepsTheRow(t *testing.T) {
|
||||
h, _, _ := serverMailStand(t)
|
||||
h.testMail = &recordingMailer{err: fmt.Errorf("%w after 20s", notify.ErrTimeout)}
|
||||
rec := postFormAs(h.HandleTestMessage, globalPrincipal, "/server/health/test-message", nil, url.Values{"recipient": {"ops@elsewhere.example"}})
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/outbound/log/") {
|
||||
t.Fatalf("a timed-out hand-over = %d to %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if n, _ := h.store.CountTestMessagesSince("postmaster@"+notifyHost, timeAgo()); n != 1 {
|
||||
t.Fatalf("%d rows, want the one kept", n)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
|
||||
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
|
||||
}
|
||||
}
|
||||
if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
|
||||
if want := map[string]int{"health": 9, "kit": 3}[name]; found != want {
|
||||
t.Errorf("%s: %d numbered boxes found in the cards layout, want %d", name, found, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,25 @@ type Health struct {
|
||||
// reverse proxy's own.
|
||||
ConfigFacts []Fact
|
||||
ConfigProblem Text
|
||||
|
||||
// TestMessage is the card that sends the panel's test message
|
||||
// (test-email): TestFrom is its sender, TestTo the address typed and
|
||||
// TestErr why it was not sent, both kept when the form comes back.
|
||||
TestMessage Box
|
||||
TestAction string
|
||||
TestFrom string
|
||||
TestTo string
|
||||
TestErr string
|
||||
}
|
||||
|
||||
// TestMessageAction is where the Test message card posts.
|
||||
const TestMessageAction = "/server/health/test-message"
|
||||
|
||||
// WithTestMessage fills the Test message card: from is the sender, to and
|
||||
// problem what the form comes back with when it was refused.
|
||||
func (h *Health) WithTestMessage(from, to, problem string) *Health {
|
||||
h.TestFrom, h.TestTo, h.TestErr = from, to, problem
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClientAddress says where the panel sees the viewer's request from:
|
||||
@@ -184,6 +203,9 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
Configuration: Box{No: "08", InCards: true, Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
TestMessage: Box{No: "09", InCards: true, Title: "Test message", ID: "test-message",
|
||||
End: Plain("Shows what the receiving server answers"), Help: topicLink(HelpTestMessage, "What the test proves")},
|
||||
TestAction: TestMessageAction,
|
||||
}
|
||||
if flash != "" {
|
||||
h.Flash = &Flash{Text: Plain(flash)}
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
HelpChecks = "checks"
|
||||
HelpCertificate = "certificate"
|
||||
HelpRDNS = "rdns"
|
||||
HelpTestMessage = "test-message"
|
||||
// Outbound
|
||||
HelpDNS = "dns"
|
||||
HelpApps = "apps"
|
||||
@@ -99,6 +100,7 @@ func NewHelp(m Meta, inbound bool) *Help {
|
||||
add("Overview", overviewTopics, []MenuItem{
|
||||
{Label: "Health checks", Icon: "ti-activity-heartbeat", Href: "#" + HelpChecks},
|
||||
{Label: "TLS certificate", Icon: "ti-certificate", Href: "#" + HelpCertificate},
|
||||
{Label: "Test message", Icon: "ti-send", Href: "#" + HelpTestMessage},
|
||||
{Label: "Reverse DNS", Icon: "ti-arrows-exchange", Href: "#" + HelpRDNS},
|
||||
})
|
||||
}
|
||||
@@ -153,6 +155,10 @@ var overviewTopics = []HelpTopic{
|
||||
Rich("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (",
|
||||
Code("SELFPOST_HOSTNAME"), ") - the tag beside ", Em("Names"), " says which: a client that verifies the name refuses it however valid it is."),
|
||||
}},
|
||||
{ID: HelpTestMessage, Title: "Test message", Body: []Text{
|
||||
Rich("A message from ", Code("postmaster@"), " the server's hostname to one address you type, sent the way notifications are: through Postfix, signed with the DKIM key of the server's own name. It proves the path from this server - TLS, its address's reputation, the SPF and DKIM records of its name - not your domains', which only mail from an application does. The page that opens is its row in the Outbound log: it shows whether the receiving server took it, put it off or refused it, with the lines of ",
|
||||
Code("mail.log"), ". Whether it then reached the inbox or the spam folder only the mailbox can say. The global role sends it, at most ten an hour."),
|
||||
}},
|
||||
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
|
||||
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. ",
|
||||
Em("Greeting"), " is the line Postfix opens every connection with, ", Code("220"), " and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or ",
|
||||
|
||||
@@ -346,3 +346,16 @@ func TestHealthSaysWhereItSeesTheViewer(t *testing.T) {
|
||||
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithClientAddress("fd00::1", "fd00::1", false, true))
|
||||
pageHas(t, "Health", out, `<code>fd00::1/128</code>`)
|
||||
}
|
||||
|
||||
// Health's Test message card posts one address and keeps it, with the reason,
|
||||
// when it comes back refused.
|
||||
func TestHealthTestMessageCard(t *testing.T) {
|
||||
out := renderSignedIn(t, "health", NewHealth(admin(), "").WithTestMessage("postmaster@mail.example.org", "", ""))
|
||||
pageHas(t, "Health", out, `<form class="sp-form" method="post" action="/server/health/test-message">`,
|
||||
`name="recipient" type="email" placeholder="you@elsewhere.example" value="" required>`,
|
||||
`From <span class="sp-mono">postmaster@mail.example.org</span>, signed with the server's own key`)
|
||||
pageLacks(t, "Health", out, `help is-danger`)
|
||||
|
||||
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithTestMessage("postmaster@mail.example.org", "a@x, b@y", "Enter one e-mail address."))
|
||||
pageHas(t, "Health", out, `class="input is-danger" id="rcpt"`, `value="a@x, b@y"`, `<p class="help is-danger">Enter one e-mail address.</p>`)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{template "box_open" .TestMessage}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="{{.TestAction}}">
|
||||
<div class="field"><label class="label" for="rcpt">Recipient</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input{{if .TestErr}} is-danger{{end}}" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" value="{{.TestTo}}" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
|
||||
</div>
|
||||
{{if .TestErr}}<p class="help is-danger">{{.TestErr}}</p>
|
||||
{{end}}<p class="help">From <span class="sp-mono">{{.TestFrom}}</span>, signed with the server's own key, through Postfix as its notifications go. It opens in the <a href="/outbound/log">Outbound log</a>, where its page follows it to the receiving server. Ten an hour.</p></div>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
{{template "cards_close"}}
|
||||
{{end}}
|
||||
|
||||
@@ -284,6 +284,7 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
||||
server("GET /server/health/fragment", h.HandleHealthFragment)
|
||||
server("POST /server/health/recheck", h.HandleHealthRecheck)
|
||||
server("POST /server/health/reload", h.HandleReload)
|
||||
server("POST /server/health/test-message", h.HandleTestMessage)
|
||||
server("GET /server/log", h.HandleSystemLog)
|
||||
server("GET /server/log/fragment", h.HandleSystemLogBody)
|
||||
server("GET /server/backup", h.HandleBackupPage)
|
||||
|
||||
@@ -377,6 +377,9 @@ func TestE2E(t *testing.T) {
|
||||
run("expired_message_is_closed", func(t *testing.T) {
|
||||
testExpiredMessageIsClosed(t, sc)
|
||||
})
|
||||
run("test_message_reaches_the_sink", func(t *testing.T) {
|
||||
testTestMessage(t, sc)
|
||||
})
|
||||
run("full_backup_restore", func(t *testing.T) {
|
||||
testFullBackupRestore(t, sc)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testTestMessage: the test message of Server › Health (test-email) goes to
|
||||
// the sink from postmaster@ the stand's hostname. The browser lands on its
|
||||
// row in the Outbound log, which learns its queue id from cleanup's
|
||||
// Message-ID line and then its status from the delivery line, as any row;
|
||||
// the sink has it, signed with the key of the stand's own name.
|
||||
func testTestMessage(t *testing.T, sc *scenario) {
|
||||
before := sinkMessagesWith(t, h.stageDir, "Subject: SelfPost test message")
|
||||
resp, body, err := sc.panel.postForm("/server/health/test-message", url.Values{"recipient": {recipient}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`^/outbound/log/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
|
||||
if resp.StatusCode != 200 || m == nil {
|
||||
t.Fatalf("send a test message: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||
}
|
||||
page := resp.Request.URL.Path
|
||||
if err := waitFor("the test message to be sent", 60*time.Second, time.Second, func() (bool, error) {
|
||||
_, body, err := sc.panel.get(page)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
qid := regexp.MustCompile(`<dt>Queue id</dt><dd class="sp-mono">([^<]+)</dd>`).FindStringSubmatch(body)
|
||||
if strings.Contains(body, `<span class="tag is-success is-light">sent</span>`) && qid != nil && longQueueID.MatchString(qid[1]) {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}); err != nil {
|
||||
_, body, _ := sc.panel.get(page)
|
||||
t.Fatalf("%v\n%.3000s\n%s", err, body, mailLogTail(h, "postfix/"))
|
||||
}
|
||||
if err := waitFor("the sink to have it", 30*time.Second, time.Second, func() (bool, error) {
|
||||
return sinkMessagesWith(t, h.stageDir, "Subject: SelfPost test message") > before, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := findSinkMessage(h.stageDir, "Subject: SelfPost test message", time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "d="+selfpostHostname) || !strings.Contains(string(raw), "From: SelfPost <postmaster@"+selfpostHostname+">") {
|
||||
t.Fatalf("the test message at the sink is not the server's own, signed with its name:\n%.2000s", raw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user