[1.0.0]
Added:
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
- A **History** block on the same page: the journal's two timestamps stated as
Fixed:
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
- Postfix config: allow `maillog_file` under `/data` via
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
… (3 more in CHANGELOG)
Changed:
- Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
- Monitoring screens (status, mail queue, system log, deliveries) use adaptive
- Documentation package consolidated into `docs/development.md`: Documentation
… (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
- The panel's **Account** entry is now called **Settings** — nav link, page
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
- [docs/product.md](docs/product.md) reframes the future line: the inbound
- [docs/security.md](docs/security.md) is now in English, matching the rest of
- The README documentation table now points at `SECURITY.md` for reporting a
… (2 more in CHANGELOG)
Fixed:
- The panel's static assets are served with a content ETag and
Squashed CHANGELOG sections: [1.0.0], [1.0.1].
Full detail: CHANGELOG.md (1.0.0, 1.0.1).
143 lines
4.2 KiB
Go
143 lines
4.2 KiB
Go
package e2e
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"time"
|
|
)
|
|
|
|
// smtpsAddr is where compose.override.yml publishes the primary implicit-TLS
|
|
// submission port (465 in the shipped compose, remapped here to avoid clashing
|
|
// with a local deployment on the same host).
|
|
const smtpsAddr = "127.0.0.1:20465"
|
|
|
|
// sendAttempt is one SMTP transaction against the stand. An empty authLogin
|
|
// means "connect without AUTH" (negative checks 1/3); from/to are full
|
|
// mailbox addresses.
|
|
type sendAttempt struct {
|
|
authLogin, authPassword string
|
|
from, to string
|
|
subject, body string
|
|
}
|
|
|
|
// sendResult records the error (if any) at each stage the transaction
|
|
// reached. Only the fields up to and including the first failure are set —
|
|
// the rest are nil because that stage was never attempted, not because it
|
|
// succeeded silently.
|
|
type sendResult struct {
|
|
dialErr error
|
|
authErr error
|
|
mailErr error
|
|
rcptErr error
|
|
dataErr error
|
|
}
|
|
|
|
// ok reports whether the message was fully accepted (DATA closed cleanly).
|
|
func (r sendResult) ok() bool {
|
|
return r.dialErr == nil && r.authErr == nil && r.mailErr == nil && r.rcptErr == nil && r.dataErr == nil
|
|
}
|
|
|
|
// firstErr returns whichever stage failed first, or nil if none did.
|
|
func (r sendResult) firstErr() error {
|
|
for _, e := range []error{r.dialErr, r.authErr, r.mailErr, r.rcptErr, r.dataErr} {
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// attemptSend drives one SMTP transaction over the implicit-TLS port, exactly
|
|
// as an application client library would: TLS connect, EHLO, optional AUTH
|
|
// PLAIN, MAIL/RCPT/DATA. The certificate is the stand's throwaway self-signed
|
|
// one (see stage.go), so verification is skipped — Postfix's
|
|
// smtpd_tls_security_level is "may" here just like production, this is purely
|
|
// about the test client trusting an unknown CA.
|
|
func attemptSend(a sendAttempt) sendResult {
|
|
var res sendResult
|
|
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", smtpsAddr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
ServerName: selfpostHostname,
|
|
})
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("dial %s: %w", smtpsAddr, err)
|
|
return res
|
|
}
|
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
|
if err != nil {
|
|
res.dialErr = fmt.Errorf("smtp handshake: %w", err)
|
|
_ = conn.Close()
|
|
return res
|
|
}
|
|
defer c.Close()
|
|
|
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
|
res.dialErr = err
|
|
return res
|
|
}
|
|
|
|
if a.authLogin != "" {
|
|
auth := smtp.PlainAuth("", a.authLogin, a.authPassword, selfpostHostname)
|
|
if err := c.Auth(auth); err != nil {
|
|
res.authErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
}
|
|
|
|
if err := c.Mail(a.from); err != nil {
|
|
res.mailErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
if err := c.Rcpt(a.to); err != nil {
|
|
res.rcptErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
w, err := c.Data()
|
|
if err != nil {
|
|
res.dataErr = err
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
msg := buildMessage(a)
|
|
if _, err := w.Write([]byte(msg)); err != nil {
|
|
res.dataErr = err
|
|
} else if err := w.Close(); err != nil {
|
|
res.dataErr = err
|
|
}
|
|
_ = c.Quit()
|
|
return res
|
|
}
|
|
|
|
// waitForSMTPSReady polls the implicit-TLS port until a bare TCP connect
|
|
// succeeds. Used after a container (re)start: the panel's HTTP port and
|
|
// Postfix's smtpd come up independently (postfix-wrapper.sh additionally
|
|
// waits on both milter sockets before starting Postfix at all), so a caller
|
|
// that only waited for the panel could still race a not-yet-listening smtpd.
|
|
func waitForSMTPSReady(timeout time.Duration) error {
|
|
return waitFor("smtps port to accept connections", timeout, 300*time.Millisecond, func() (bool, error) {
|
|
conn, err := net.DialTimeout("tcp", smtpsAddr, 2*time.Second)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_ = conn.Close()
|
|
return true, nil
|
|
})
|
|
}
|
|
|
|
// buildMessage renders a minimal, valid RFC 5322 message. subject is expected
|
|
// to carry a unique token so the harness can find this exact message in the
|
|
// sink-MX's dump directory afterwards.
|
|
func buildMessage(a sendAttempt) string {
|
|
return fmt.Sprintf(
|
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nDate: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n%s\r\n",
|
|
a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body,
|
|
)
|
|
}
|