diff --git a/.cursor/rules/panel-ui-system.mdc b/.cursor/rules/panel-ui-system.mdc new file mode 100644 index 0000000..06b8a32 --- /dev/null +++ b/.cursor/rules/panel-ui-system.mdc @@ -0,0 +1,22 @@ +--- +description: Panel UI mockups use the design system primitives, not a reskin of live templates. +globs: docs/assets/panel-ui/** +alwaysApply: false +--- + +# Panel UI system + +Source of truth: [docs/assets/panel-ui/system.html](docs/assets/panel-ui/system.html) and `system.css`. + +Do not copy `internal/web/view/templates` into mockups. Do not mark `
` as `ops`/`form` to pick width. Do not fix empty columns, Host/Type height, or split Save/Delete with one-off CSS. + +Compose screens from `stack`, `pair`, `measure`, `fill`, `field-row`, `actions-row`: +- Two peer jobs → `pair` (one child shrinks to `measure`). +- Tables/logs/DNS → `fill` (nowrap + overflow-x on the card). +- Confirm/login/user form → `measure`. +- Host ‖ Type and two equal inputs → `field-row` (shared grid row, not two `.code` paddings). +- Save + Delete → `actions-row` inside the card; never submit inside `
` and danger after `
`. +- Danger zone is the second column of the last `pair`, never a full-width `fill`. +- Card chrome is title + optional `?` + body + actions. Help is not sprinkled later. +- Phone: `pair` stacks; `field-row` does not; tables become lists. +- One screen is one HTML file. Shared chrome is `shell.js`. Do not put every screen in one hash-SPA. diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b51b6..38d7ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version row; help «?» on domain cards; Host/name ‖ Type height matched to the live panel (`b0ebe06`). +- docs: panel UI design system and mockups rebuilt as separate pages + (`system.html`, `status.html`, `domain.html`, …), not one hash sheet. + Regions are `stack` / `pair` / `measure` / `fill`; Host ‖ Type is + `field-row`; Save + Delete is `actions-row`. Shared chrome is `shell.js`. + ## [1.3.0] - 2026-08-14 Security and quality after 1.2.5: domain-admin send-log authorization, diff --git a/docs/assets/panel-ui/app.html b/docs/assets/panel-ui/app.html index 1e6c6a2..e9c5195 100644 --- a/docs/assets/panel-ui/app.html +++ b/docs/assets/panel-ui/app.html @@ -5,1288 +5,37 @@ SelfPost — panel mockups - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
-
- - - - Status - Domains - example.com - Deliveries - Message - Mail queue - System log - Inbound - lists.example.com - backup.example.net - DMARC - Backup - Users - Help - Settings - - -
- - -
-
-

Status

- -
- -
-

Overall warn

-

Running, with warnings below.

-
- -
-
-

Mail queue warn

-

3 Kbytes in 3 Requests.

- View queue -
-
-

TLS certificate ok

- - 2026-11-02 12:00 UTC -

Valid for another 78 day(s).

-
-
- -
-
-

Milter sockets ok

- - - - - - - - - - - - - - -
MilterStateDetail
OpenDKIMokListening
send-logokListening
-
-
-

Hostname and reverse DNS ok

- - mail.example.org - - 203.0.113.10 → mail.example.org -

mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.

-
-
-
- -
-

Inbound warn

-

INBOUND_RELAY_ENABLE is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.

-

One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.

- Inbound domains -
- -
-
-

Machine ok

- - - - - - - - - - - - - - - - - - - -
ResourceUsageDetail
CPU12% 12%4 cores · 4 threads
Memory41% 41%1.6 GiB used of 4.0 GiB.
Network↓ 2.0 KiB/s
↑ 1.0 KiB/s
-
eth0: 1.0 MiB in, 512.0 KiB out
-
-
-
-

Processes ok

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ProgramStateDetail
opendkimRUNNINGpid 21, uptime 3 days, 4:12:01
panelRUNNINGpid 18, uptime 3 days, 4:12:03
postfixRUNNINGpid 42, uptime 3 days, 4:11:58
postfix-reloadSTOPPEDNot started
cert-reloadSTOPPEDNot started
logrotateSTOPPEDNot started
-
-
- -
-

Configuration

-

Regenerates the OpenDKIM and Postfix configuration from the - database and reloads both daemons. Use it if you edited the files by hand, - restored a backup, or the running configuration looks out of step with the - domain and application lists. It does not touch the mail queue or the TLS - certificate, and it is safe to run at any time.

-
- -
-
-

SelfPost 1.2.3 · © Mixeme · License (AGPL-3.0)

-
- - -
- SelfPost -

Sign in

-
-
- - - - - -
-
-

© Mixeme · License (AGPL-3.0)

-
- - -
- SelfPost -

Create administrator

-
-

This one-time link creates the single panel administrator. After you submit, the link stops working for good.

-
- - - - - - - -
-
-
- - -
-

Domains

-
-

Add a sending domain

-
- -
- - -
-
-
-
-

Domains

- - - - - - - - - - - - - - - - - - -
DomainDNSSelectorApps
example.comokmail2Delete
alerts.example.comwarnmail1Delete
- -

The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.

-
-
- - -
-

example.com

- ← All domains -
-
-

New application password

- -
-

Shown once only and not stored. Copy it now.

- -
newsletter
- -
xK.9fQ2m-pL7wR
-
-
-
-

DNS status ok

- -
-
-
- -
mail._domainkey.example.com
TXT
-
-
- -
example.com
TXT
-
-
- -
_dmarc.example.com
TXT
-

p=none; rua points at SelfPost ingest.

-
-
- -

Not required (rua= is on a domain SelfPost accepts).

-
-
-
-
-
-
-
-

DKIM and SPF records

- -
-

DKIM

-
mail._domainkey.example.com
TXT
- -
v=DKIM1; k=rsa; p=MIIBIjANBgkqh…
-

SPF

-
v=spf1 ip4:203.0.113.10 -all
-
-
-
-

DMARC record

- -
-
_dmarc.example.com
TXT
- -
v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org
-

Open DMARC reports for this domain.

-
-
-
-
-
-

Connection settings

- -
- -
mail.example.org
- - 465 — SSL/TLS (implicit) -587 — STARTTLS (submission) -
-
-
-

Add an application

- -
-
- - - - - -
-
-
-
-
-

Applications

- -
-
    -
  • - -

    Any address of the domain — *@example.com

    -
    - - - - -
    -
    -
    -

    Address mode

    - -
    -
    -

    Trusted-IP override active

    - -
    -
    -
    -
    -
    -
    -
    -
    -
  • -
  • - -

    Fixed list — invoices@example.com

    -
    - - - -
    -
  • -
-
-
-
-

Domain settings

- -
-
-
-

DMARC reports

- - - -
-
-

Level-2 rate limit active

-
-
-
-
- -
-
-
-
-
-
-

Export domain

- -
-

Secret file — transfer securely, or encrypt as .spde.

-
- -
- - -
-
- -
-
-

Danger zone

-

Deletes the DKIM key and every application on this domain.

- Delete domain -
-
-
- - -
-

Delete example.com

- ← Back to example.com -
-

Confirm deletion

-

You are about to delete example.com. This will:

-
    -
  • permanently delete its DKIM signing key;
  • -
  • delete all 2 bound applications, including their SASL credentials;
  • -
  • reload OpenDKIM so the domain is no longer signed.
  • -
-

This cannot be undone.

- -
-
- - -
-

Deliveries

-
-
- -
-
- -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TimeFromToSubjectStatus
2026-08-15 20:14:02billing@example.comada@example.netInvoice #4412deferredDetails
2026-08-15 20:11:40news@example.comlist-bounces@example.netAugust digestdeliveredDetails
2026-08-15 19:02:11alerts@alerts.example.comnoreply@blocked.exampleDisk 92% on web-3bouncedDetails
2026-08-15 18:44:09news@example.comsam@example.orgAugust digestdeliveredDetails
- -

Page 1 of 4 · Older →

-
-
- - -
-

Invoice #4412

-

- billing@example.com - - ada@example.net - deferred -

- ← Back to deliveries -
-
-

Message

-
-
Domainexample.com
-
Applicationbilling
-
Accepted2026-08-15 20:14:02 UTC
-
Status reported2026-08-15 20:14:08 UTC
-
Queue id4C3A1E2F1A
-
Journal id1842
-
-
-
-

History

-
    -
  1. -

    2026-08-15 20:14:02 UTC

    -

    accepted Received by the relay

    -

    SASL login billing, queued as 4C3A1E2F1A.

    -
  2. -
  3. -

    2026-08-15 20:14:08 UTC

    -

    deferred Receiving MX asked to try later

    -

    Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.

    -
  4. -
  5. -

    not yet

    -

    delivery Waiting on the next retry

    -
  6. -
-
-
-
-

Delivery log

- - - - - - - -
TimeMessage
20:14:02postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing
20:14:02postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com>
20:14:08postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)
-
-

20:14:08

- status=deferred (450 4.2.1 mailbox busy) -

20:14:02

- client=203.0.113.40, sasl_username=billing -
-
-
- - -
-

Mail queue

-
-

How delivery retries work

-

This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.

-
-
First retry5 minutes
-
Later retriesdoubling, cap 1 h 7 min
-
Kept in queue5 days
-
Thenbounced
-
-
-
-

Pending messages

- - - - - - - -
Queue idAgeFromToSize
4C3A1E2F1A18 minbilling@example.comada@example.net12 KiB
4C3A1E301011 minnews@example.compat@slow.example48 KiB
4C3A1E31024 minbilling@example.comada@example.net9 KiB
- - - -Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient------- -4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com - ada@example.net -4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com - pat@slow.example --- 3 Kbytes in 3 Requests. -
-
- - -
-

System log

-
-

Recent log entries

- Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy) -Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active) -Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing -Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok) -Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected) -
-
- - -
-

Backup & migration

-
-
-

Full backup

-

Download a full backup of all persistent state — the database, every domain’s DKIM key and the application credentials. Restore into a container of the same SelfPost version, with the same data mount, before first start. TLS certificates and the mail queue are not included.

-

The backup file is a secret. Encrypting it is the simplest way to store it: the download is then a .spbk that only the password opens.

-
- -
- - -

Keep this password: without it the file cannot be opened.

-
-
- -
-
-

Import a domain

-

Move a single domain here from another SelfPost instance — plain .json or encrypted .spde. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.

- - - - -

Needed for a .spde file. Leave empty for plain .json.

- -
-
-
- - -
-

Users

-
-

Create user

- - - - - - -
UsernameRoleDomains
adminGlobalAllEdit
ops-alertsDomain adminalerts.example.comEdit
-
-
- - -
-

Edit user

- ← Back to users -
-
- - - - - - -
- Assigned domains -

Required for domain administrators.

- - -
-
- - Delete user -
-
-
-
- - -
-

Delete ops-alerts

- ← Back to ops-alerts -
-

Confirm deletion

-

You are about to delete the panel user ops-alerts. A signed-in session for this user stops working immediately.

- -
-
- - -
-

Settings

-
-
-
-

Panel credentials

-

These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.

- - - - -
-
-

DMARC aggregate reports

-

Default rua= for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.

- - -

When rua= points at another domain, that hub must publish a report-authorisation record. DMARC reports in the panel.

-
-
- -
mail.example.org._report._dmarc.example.com
-
-
- - TXT -
-
- -
v=DMARC1;
- -

Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.

-
-
- -

Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.

-
-
-

Sending rate limits

-

Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.

-
-
-

Level 1 — per client IP

- 100 messages / 60 seconds -

RATE_LIMIT_MESSAGES_PER_IP / RATE_LIMIT_WINDOW_SECONDS. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.

-
-
-

Level 2 — domain

-

Optional ceiling for all senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.

-
-
-

Level 2 — application

-

Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).

-
-
-
-
- - -
-
-

Inbound 1.x

-
-

Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.

-
-

Add inbound domain

-
- -
- - -
-
-
-
-

Forwarding

- - - - - - - - - - - - - - - - - - - - -
DomainDNSUpstreamRecipientsTLS
lists.example.comok10.0.0.8:2512 listedrequiredDelete
backup.example.neterror192.0.2.20:25anyoffDelete
- -

The DNS badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a Re-check button.

-
-
- - -
-

lists.example.com

- ← All inbound domains - -
-

DNS status ok

-

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

-
-
- -
-
- - lists.example.com -
-
- - MX -
-
- - 10 mail.example.org. -20 mail.primary.example.net. -

An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.

-
-
-
-
- -
-
-

Upstream

-

Where accepted mail is handed off. Not a mailbox.

- - - - - -
-
-

MX record to publish

-

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

-
-
- -
lists.example.com
-
-
- - MX -
-
- -
10 mail.example.org.
-
-
-
-
-

Valid recipients

-

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

- - -
- - -

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

-
-

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

- -
-
-

Danger zone

-

Stops accepting mail for this domain. Does not touch outbound sending domains.

- Delete inbound domain -
-
-
- - -
-

backup.example.net

- ← All inbound domains - -
-

DNS status error

-

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

-
-
- -
-
- - backup.example.net -
-
- - MX -
-
- - 10 mail.primary.example.net. -

No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.

-
-
-
-
- -
-
-

Upstream

-

Where accepted mail is handed off. Not a mailbox.

- - - - - -
-
-

MX record to publish

-

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

-
-
- -
backup.example.net
-
-
- - MX -
-
- -
20 mail.example.org.
-
-
-
-
-

Valid recipients

-

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

- - -
- - -

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

-
-

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

- -
-
-

Danger zone

-

Stops accepting mail for this domain. Does not touch outbound sending domains.

- Delete inbound domain -
-
-
- - -
-

Delete lists.example.com

- ← Back to lists.example.com -
-

Confirm deletion

-

You are about to stop accepting inbound mail for lists.example.com. This will:

-
    -
  • remove it from relay_domains and the recipient map;
  • -
  • stop forwarding to 10.0.0.8:25;
  • -
  • leave outbound sending domains untouched.
  • -
-

This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.

-
- -
-
-
- - -
-
-

DMARC reports candidate

-
-

Aggregate reports SelfPost accepted for rua=. Forensic (ruf=) is out of scope.

-
-
-

Ingest ok

-

Last report 6 hours ago. 14 kept, 0 parse failures this week.

-
-
-

example.com pass

-

98% aligned last 7 days. Tightening p= looks reasonable.

-
-
-

alerts.example.com fail

-

A third-party sender is not in SPF/DKIM. See sources.

-
-
-
-

Sources · last 7 days

- - - - - - - - -
DomainSourcePassFailDisposition
example.com203.0.113.10 (this relay)4122none
example.comgoogle.com / 66.102.0.0/2006none
alerts.example.com203.0.113.10 (this relay)880none
alerts.example.comunknown / 198.51.100.80019none
- -
-
- - -
-
-

Help candidate

-
-

Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.

-
-

On this panel

-
    -
  • Machine — kernel counters and the rate window
  • -
  • TLS certificate — port 465, reverse-proxy mount
  • -
  • Hostname / reverse DNS — forward-confirmed PTR at the hosting provider
  • -
  • Mail queue retries — time-based, no attempt budget
  • -
  • Inbound — not mailboxes; listed recipients or any address at the domain
  • -
  • Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)
  • -
-

The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.

-
-
-

Machine

-

CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.

-
-
-

TLS certificate

-

Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.

-
-
-

Hostname / reverse DNS

-

Forward-confirmed reverse DNS: the A/AAAA for SELFPOST_HOSTNAME must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.

-
-
- -
-
- - - - + + +

Макеты собраны отдельными страницами. Откройте Status или оглавление.

diff --git a/docs/assets/panel-ui/backup.html b/docs/assets/panel-ui/backup.html new file mode 100644 index 0000000..13c12c5 --- /dev/null +++ b/docs/assets/panel-ui/backup.html @@ -0,0 +1,43 @@ + + + + + +Backup & migration — SelfPost mockups + + + + + +
+

Backup & migration

+
+
+

Full backup

+

Download a full backup of all persistent state — the database, every domain’s DKIM key and the application credentials. Restore into a container of the same SelfPost version, with the same data mount, before first start. TLS certificates and the mail queue are not included.

+

The backup file is a secret. Encrypting it is the simplest way to store it: the download is then a .spbk that only the password opens.

+
+ +
+ + +

Keep this password: without it the file cannot be opened.

+
+
+
+
+
+

Import a domain

+

Move a single domain here from another SelfPost instance — plain .json or encrypted .spde. Its DKIM key and application passwords come across, so the published DNS record needs no change. The export file is a secret, like a full backup.

+ + + + +

Needed for a .spde file. Leave empty for plain .json.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/deliveries.html b/docs/assets/panel-ui/deliveries.html new file mode 100644 index 0000000..6004baa --- /dev/null +++ b/docs/assets/panel-ui/deliveries.html @@ -0,0 +1,91 @@ + + + + + +Deliveries — SelfPost mockups + + + + + +
+

Deliveries

+
+
+ +
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TimeFromToSubjectStatus
2026-08-15 20:14:02billing@example.comada@example.netInvoice #4412deferredDetails
2026-08-15 20:11:40news@example.comlist-bounces@example.netAugust digestdeliveredDetails
2026-08-15 19:02:11alerts@alerts.example.comnoreply@blocked.exampleDisk 92% on web-3bouncedDetails
2026-08-15 18:44:09news@example.comsam@example.orgAugust digestdeliveredDetails
+ +

Page 1 of 4 · Older →

+
+
+
+ + + diff --git a/docs/assets/panel-ui/delivery.html b/docs/assets/panel-ui/delivery.html new file mode 100644 index 0000000..16fef89 --- /dev/null +++ b/docs/assets/panel-ui/delivery.html @@ -0,0 +1,77 @@ + + + + + +Invoice #4412 — SelfPost mockups + + + + + +
+
+

Invoice #4412

+

+ billing@example.com + + ada@example.net + deferred +

+ ← Back to deliveries +
+
+
+

Message

+
+
Domainexample.com
+
Applicationbilling
+
Accepted2026-08-15 20:14:02 UTC
+
Status reported2026-08-15 20:14:08 UTC
+
Queue id4C3A1E2F1A
+
Journal id1842
+
+
+
+

History

+
    +
  1. +

    2026-08-15 20:14:02 UTC

    +

    accepted Received by the relay

    +

    SASL login billing, queued as 4C3A1E2F1A.

    +
  2. +
  3. +

    2026-08-15 20:14:08 UTC

    +

    deferred Receiving MX asked to try later

    +

    Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.

    +
  4. +
  5. +

    not yet

    +

    delivery Waiting on the next retry

    +
  6. +
+
+
+
+
+

Delivery log

+ + + + + + + +
TimeMessage
20:14:02postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40, sasl_username=billing
20:14:02postfix/cleanup[224]: 4C3A1E2F1A: message-id=<4412@example.com>
20:14:08postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, status=deferred (450 4.2.1 mailbox busy)
+
+

20:14:08

+ status=deferred (450 4.2.1 mailbox busy) +

20:14:02

+ client=203.0.113.40, sasl_username=billing +
+
+
+
+ + + diff --git a/docs/assets/panel-ui/dmarc.html b/docs/assets/panel-ui/dmarc.html new file mode 100644 index 0000000..c4c2b9b --- /dev/null +++ b/docs/assets/panel-ui/dmarc.html @@ -0,0 +1,54 @@ + + + + + +DMARC reports — SelfPost mockups + + + + + +
+
+

DMARC reports candidate

+
+

Aggregate reports SelfPost accepted for rua=. Forensic (ruf=) is out of scope.

+
+
+

Ingest ok

+

Last report 6 hours ago. 14 kept, 0 parse failures this week.

+
+
+

example.com pass

+

98% aligned last 7 days. Tightening p= looks reasonable.

+
+
+
+
+

alerts.example.com fail

+

A third-party sender is not in SPF/DKIM. See sources.

+
+
+
+
+

Sources · last 7 days

+ + + + + + + + +
DomainSourcePassFailDisposition
example.com203.0.113.10 (this relay)4122none
example.comgoogle.com / 66.102.0.0/2006none
alerts.example.com203.0.113.10 (this relay)880none
alerts.example.comunknown / 198.51.100.80019none
+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/domain-delete.html b/docs/assets/panel-ui/domain-delete.html new file mode 100644 index 0000000..1a08e1f --- /dev/null +++ b/docs/assets/panel-ui/domain-delete.html @@ -0,0 +1,33 @@ + + + + + +Delete example.com — SelfPost mockups + + + + + +
+
+

Delete example.com

+ ← Back to example.com +
+
+
+

Confirm deletion

+

You are about to delete example.com. This will:

+
    +
  • permanently delete its DKIM signing key;
  • +
  • delete all 2 bound applications, including their SASL credentials;
  • +
  • reload OpenDKIM so the domain is no longer signed.
  • +
+

This cannot be undone.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/domain.html b/docs/assets/panel-ui/domain.html new file mode 100644 index 0000000..80a472b --- /dev/null +++ b/docs/assets/panel-ui/domain.html @@ -0,0 +1,260 @@ + + + + + +example.com — SelfPost mockups + + + + + +
+
+

example.com

+ ← All domains +
+
+
+
+

New application password

+ +
+

Shown once only and not stored. Copy it now.

+ +
newsletter
+ +
xK.9fQ2m-pL7wR
+
+
+
+
+
+

DNS status ok

+ +
+
+
+
+ +
+
+ + mail._domainkey.example.com +
+
+ + TXT +
+
+
+
+ +
+
+ + example.com +
+
+ + TXT +
+
+
+
+
+
+ +
+
+ + _dmarc.example.com +
+
+ + TXT +
+
+

p=none; rua points at SelfPost ingest.

+
+
+ +

Not required (rua= is on a domain SelfPost accepts).

+
+
+
+
+
+
+
+
+
+

DKIM and SPF records

+ +
+

DKIM

+
+
+ +
mail._domainkey.example.com
+
+
+ + TXT +
+
+ +
v=DKIM1; k=rsa; p=MIIBIjANBgkqh…
+

SPF

+
v=spf1 ip4:203.0.113.10 -all
+
+
+
+

DMARC record

+ +
+
+
+ +
_dmarc.example.com
+
+
+ + TXT +
+
+ +
v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org
+

Open DMARC reports for this domain.

+
+
+
+
+
+

Connection settings

+ +
+ +
mail.example.org
+ + 465 — SSL/TLS (implicit) +587 — STARTTLS (submission) +
+
+
+

Add an application

+ +
+
+ + + + +
+
+
+
+
+
+
+

Applications

+ +
+
    +
  • + +

    Any address of the domain — *@example.com

    +
    + + + + +
    +
    +
    +

    Address mode

    + +
    +
    +

    Trusted-IP override active

    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    +
  • +
  • + +

    Fixed list — invoices@example.com

    +
    + + + +
    +
  • +
+
+
+
+
+
+

DMARC reports

+ +
+ + +
+
+
+
+

Level-2 rate limit active

+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+

Export domain

+ +
+

Secret file — transfer securely, or encrypt as .spde.

+
+ +
+ + +
+
+
+
+
+

Danger zone

+

Deletes the DKIM key and every application on this domain.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/domains.html b/docs/assets/panel-ui/domains.html new file mode 100644 index 0000000..a96d19b --- /dev/null +++ b/docs/assets/panel-ui/domains.html @@ -0,0 +1,58 @@ + + + + + +Domains — SelfPost mockups + + + + + +
+

Domains

+
+
+

Add a sending domain

+
+ +
+ + +
+
+
+
+
+
+

Domains

+ + + + + + + + + + + + + + + + + + +
DomainDNSSelectorApps
example.comokmail2Delete
alerts.example.comwarnmail1Delete
+ +

The DNS badge is the worst of DKIM, SPF and DMARC. Open a domain for details.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/help.html b/docs/assets/panel-ui/help.html new file mode 100644 index 0000000..9d64b90 --- /dev/null +++ b/docs/assets/panel-ui/help.html @@ -0,0 +1,46 @@ + + + + + +Help — SelfPost mockups + + + + + +
+
+

Help candidate

+
+

Short operator notes inside the panel — not a second copy of the full guide. Seeded from the Status explanations that do not belong on the cards (what a kernel counter is, why PTR is set at the host, what Reload does not touch). The cards themselves keep their readings, Detail columns, and the Configuration control.

+
+
+

On this panel

+
    +
  • Machine — kernel counters and the rate window
  • +
  • TLS certificate — port 465, reverse-proxy mount
  • +
  • Hostname / reverse DNS — forward-confirmed PTR at the hosting provider
  • +
  • Mail queue retries — time-based, no attempt budget
  • +
  • Inbound — not mailboxes; listed recipients or any address at the domain
  • +
  • Domain page — DNS, records, connection, applications, export (drawer from each card’s «?»)
  • +
+

The same texts open in the drawer from Status’s «?» — so a card can stay a reading, not a paragraph, without throwing the reading away.

+
+
+

Machine

+

CPU and memory are the container’s own readings, not the host’s spare capacity. Network is a short window, not a daily total. High CPU with an empty queue usually means something else on the box — not SelfPost “being slow to send”.

+
+
+

TLS certificate

+

Port 465 presents the certificate the reverse proxy (or the image) mounted. The panel does not issue certificates. A warn here is “expires soon”; an error is “missing or unreadable”, and clients will refuse submission.

+
+
+

Hostname / reverse DNS

+

Forward-confirmed reverse DNS: the A/AAAA for SELFPOST_HOSTNAME must reverse to that same name. PTR is set at the hosting provider, not in this panel. Receiving networks use this pair as a cheap reputation check.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-backup.html b/docs/assets/panel-ui/inbound-backup.html new file mode 100644 index 0000000..bf0b1f6 --- /dev/null +++ b/docs/assets/panel-ui/inbound-backup.html @@ -0,0 +1,92 @@ + + + + + +backup.example.net — SelfPost mockups + + + + + +
+
+

backup.example.net

+ ← All inbound domains +
+
+
+

DNS status error

+

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

+ +
+
+ + backup.example.net +
+
+ + MX +
+
+ + 10 mail.primary.example.net. +

No MX points at mail.example.org (this server). Publish the record below, or wait for DNS to propagate and Re-check.

+
+
+
+
+
+

Upstream

+

Where accepted mail is handed off. Not a mailbox.

+ + + + +
+
+
+

MX record to publish

+

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

+
+
+ +
backup.example.net
+
+
+ + MX +
+
+ +
20 mail.example.org.
+
+
+
+
+

Valid recipients

+

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

+ + +
+ + +

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

+
+

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

+
+
+
+

Danger zone

+

Stops accepting mail for this domain. Does not touch outbound sending domains.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-delete.html b/docs/assets/panel-ui/inbound-delete.html new file mode 100644 index 0000000..b469692 --- /dev/null +++ b/docs/assets/panel-ui/inbound-delete.html @@ -0,0 +1,33 @@ + + + + + +Delete lists.example.com — SelfPost mockups + + + + + +
+
+

Delete lists.example.com

+ ← Back to lists.example.com +
+
+
+

Confirm deletion

+

You are about to stop accepting inbound mail for lists.example.com. This will:

+
    +
  • remove it from relay_domains and the recipient map;
  • +
  • stop forwarding to 10.0.0.8:25;
  • +
  • leave outbound sending domains untouched.
  • +
+

This cannot be undone from a backup of inbound maps alone unless you restore one. Remove the MX if you do not plan to re-add the domain.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound-domain.html b/docs/assets/panel-ui/inbound-domain.html new file mode 100644 index 0000000..199d8f1 --- /dev/null +++ b/docs/assets/panel-ui/inbound-domain.html @@ -0,0 +1,94 @@ + + + + + +lists.example.com — SelfPost mockups + + + + + +
+
+

lists.example.com

+ ← All inbound domains +
+
+
+

DNS status ok

+

Cached a few minutes — use Re-check after publishing. Unlike outbound, inbound needs an MX pointing at this server.

+ +
+
+ + lists.example.com +
+
+ + MX +
+
+ + 10 mail.example.org. +20 mail.primary.example.net. +

An MX points at mail.example.org (this server). Other MX values are the domain’s own primaries — they are not an error.

+
+
+
+
+
+

Upstream

+

Where accepted mail is handed off. Not a mailbox.

+ + + + +
+
+
+

MX record to publish

+

Add this MX so the internet delivers here. Keep any existing primary MX if this is backup-MX.

+
+
+ +
lists.example.com
+
+
+ + MX +
+
+ +
10 mail.example.org.
+
+
+
+
+

Valid recipients

+

Who this domain accepts on port 25. Same idea as an application’s address mode: a list, or any address at the domain.

+ + +
+ + +

Unknown recipients are rejected at RCPT so this relay does not generate backscatter.

+
+

Every address at this domain is accepted and forwarded. Prefer a list unless the upstream rejects unknowns — otherwise this relay may generate backscatter.

+
+
+
+

Danger zone

+

Stops accepting mail for this domain. Does not touch outbound sending domains.

+ +
+
+
+ + + diff --git a/docs/assets/panel-ui/inbound.html b/docs/assets/panel-ui/inbound.html new file mode 100644 index 0000000..9519d9a --- /dev/null +++ b/docs/assets/panel-ui/inbound.html @@ -0,0 +1,63 @@ + + + + + +Inbound — SelfPost mockups + + + + + +
+
+

Inbound 1.x

+
+

Backup-MX / forwarder. Accepts on port 25 only for listed domains. Recipients are either an allow-list or any address at that domain. Off by default in Compose.

+
+
+

Add inbound domain

+
+ +
+ + +
+
+
+
+
+
+

Forwarding

+ + + + + + + + + + + + + + + + + + + + +
DomainDNSUpstreamRecipientsTLS
lists.example.comok10.0.0.8:2512 listedrequiredDelete
backup.example.neterror192.0.2.20:25anyoffDelete
+ +

The DNS badge is the MX check: at least one MX must point at this server. Results are cached for a few minutes; open a domain for the lookup and a Re-check button.

+
+
+
+ + + diff --git a/docs/assets/panel-ui/index.html b/docs/assets/panel-ui/index.html index 235e69c..fd76f9e 100644 --- a/docs/assets/panel-ui/index.html +++ b/docs/assets/panel-ui/index.html @@ -13,19 +13,25 @@

макеты · не панель

SelfPost — полное обновление интерфейса

-

Click-through прототип всех экранов: текущая панель, согласованный roadmap и кандидаты. Знак и палитра из листа утверждения не меняются. Это HTML в docs/assets/panel-ui/, не вёрстка internal/web.

+

Проект системы — как собирать экраны, чтобы не чинить пустые колонки, высоту полей и ряды кнопок по одному. Каждый экран — отдельная страница, не простыня с якорями. Знак и палитра из листа утверждения не меняются. HTML в docs/assets/panel-ui/, не вёрстка internal/web.

+
+

Система, не рескин

+

Первый click-through повторил сетку живой панели, поэтому те же сбои чинились по одному. Грамматика задаёт регионы (measure / pair / fill), карточку со слотом справки, field-row (Host и Type одной высоты) и actions-row. Макеты собраны только из этого: один экран — один HTML-файл.

+
+

Ширина окна

Сейчас оболочка центрируется, колонка контента упирается в 64rem, формы ещё уже — 48rem. На широком мониторе поля пустые. Растянуть поля на 100% окна нельзя — они становятся нечитаемыми. Две независимые карточки рядом — можно.

Гибрид

-

Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до 90rem (~1440px). Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Одиночные формы (Users, подтверждения) остаются 42rem и выровнены влево. Login и setup — по-прежнему узкий центрированный блок.

+

Навигация прижата влево. Ops-страницы (Status, Deliveries, очередь, лог, Backup, Settings, DMARC, inbound) занимают остаток до 90rem (~1440px). Backup — полный бэкап ‖ импорт. Settings — учётные данные ‖ DMARC (у domain-admin остаётся одна узкая карточка). Таблица Users — fill. Одиночные формы (подтверждения, форма пользователя) остаются 42rem и выровнены влево. Login и setup — по-прежнему узкий центрированный блок.

сейчас, окно ~1600px
@@ -122,51 +128,55 @@

Экраны

+

Система

+

Вход

Текущая панель

Roadmap

Аварийный телефон

Как смотреть

-

Откройте app.html в браузере. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.

+

Откройте любой экран из списка — это отдельная страница, не якорь в одном файле. Шапка прототипа — не часть панели: роль, Desktop/Phone, inbound, светлая/тёмная. Копирайт экранов английский, как в продукте. Drawer Help открывается с «?» на Status или с карточек домена; на карточках остаются показания и колонка Detail.

После утверждения макетов вёрстка panel.css и шаблонов — отдельная задача. CSP и progressive enhancement в этом HTML не воспроизводятся один в один: здесь допустимы вещи, которые в панели останутся в файле стилей.

diff --git a/docs/assets/panel-ui/login.html b/docs/assets/panel-ui/login.html new file mode 100644 index 0000000..0f5b2a0 --- /dev/null +++ b/docs/assets/panel-ui/login.html @@ -0,0 +1,28 @@ + + + + + +Sign in — SelfPost mockups + + + + + +
+ SelfPost +

Sign in

+
+
+ + + + +
+
+
+

© Mixeme · License (AGPL-3.0)

+
+ + + diff --git a/docs/assets/panel-ui/mail-queue.html b/docs/assets/panel-ui/mail-queue.html new file mode 100644 index 0000000..b2a234e --- /dev/null +++ b/docs/assets/panel-ui/mail-queue.html @@ -0,0 +1,54 @@ + + + + + +Mail queue — SelfPost mockups + + + + + +
+

Mail queue

+
+
+

How delivery retries work

+

This Postfix’s policy, read once at panel start. There is no maximum attempt count — only time.

+
+
First retry5 minutes
+
Later retriesdoubling, cap 1 h 7 min
+
Kept in queue5 days
+
Thenbounced
+
+
+
+
+
+

Pending messages

+ + + + + + + +
Queue idAgeFromToSize
4C3A1E2F1A18 minbilling@example.comada@example.net12 KiB
4C3A1E301011 minnews@example.compat@slow.example48 KiB
4C3A1E31024 minbilling@example.comada@example.net9 KiB
+ + + -Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient------- +4C3A1E2F1A* 12288 Sat Aug 15 20:14:02 billing@example.com + ada@example.net +4C3A1E3010 49152 Sat Aug 15 20:21:18 news@example.com + pat@slow.example +-- 3 Kbytes in 3 Requests. +
+
+
+ + + diff --git a/docs/assets/panel-ui/mock.css b/docs/assets/panel-ui/mock.css index 25046ee..ede9a48 100644 --- a/docs/assets/panel-ui/mock.css +++ b/docs/assets/panel-ui/mock.css @@ -171,52 +171,28 @@ body:has(#theme-dark:checked) { background: #16181b; color: #e9e6e0; } font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 var(--accent-fill); } -html:not(:has(.screen:target)) .n-status, -body:has(#status:target) .n-status, -body:has(#domains:target) .n-domains, -body:has(#domain:target) .n-domains, -body:has(#domain-delete:target) .n-domains, -body:has(#deliveries:target) .n-deliveries, -body:has(#delivery:target) .n-deliveries, -body:has(#mail-queue:target) .n-queue, -body:has(#system-log:target) .n-log, -body:has(#inbound:target) .n-inbound, -body:has(#inbound-domain:target) .n-inbound, -body:has(#inbound-backup:target) .n-inbound, -body:has(#inbound-delete:target) .n-inbound, -body:has(#dmarc:target) .n-dmarc, -body:has(#backup:target) .n-backup, -body:has(#users:target) .n-users, -body:has(#user-form:target) .n-users, -body:has(#user-delete:target) .n-users, -body:has(#help:target) .n-help, -body:has(#settings:target) .n-settings { +body.page-status .n-status, +body.page-domains .n-domains, +body.page-domain .n-domains, +body.page-domain-delete .n-domains, +body.page-deliveries .n-deliveries, +body.page-delivery .n-deliveries, +body.page-mail-queue .n-queue, +body.page-system-log .n-log, +body.page-inbound .n-inbound, +body.page-inbound-domain .n-inbound, +body.page-inbound-backup .n-inbound, +body.page-inbound-delete .n-inbound, +body.page-dmarc .n-dmarc, +body.page-backup .n-backup, +body.page-users .n-users, +body.page-user-form .n-users, +body.page-user-delete .n-users, +body.page-help .n-help, +body.page-settings .n-settings { font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 var(--accent-fill); } -.pt { display: none; } -html:not(:has(.screen:target)) .pt-status, -body:has(#status:target) .pt-status, -body:has(#login:target) .pt-login, -body:has(#setup:target) .pt-setup, -body:has(#domains:target) .pt-domains, -body:has(#domain:target) .pt-domain, -body:has(#domain-delete:target) .pt-domain, -body:has(#deliveries:target) .pt-deliveries, -body:has(#delivery:target) .pt-delivery, -body:has(#mail-queue:target) .pt-queue, -body:has(#system-log:target) .pt-log, -body:has(#inbound:target) .pt-inbound, -body:has(#inbound-domain:target) .pt-inbound-domain, -body:has(#inbound-backup:target) .pt-inbound-backup, -body:has(#inbound-delete:target) .pt-inbound-domain, -body:has(#dmarc:target) .pt-dmarc, -body:has(#backup:target) .pt-backup, -body:has(#users:target) .pt-users, -body:has(#user-form:target) .pt-users, -body:has(#user-delete:target) .pt-users, -body:has(#help:target) .pt-help, -body:has(#settings:target) .pt-settings { display: inline; } .nav .icon { width: 1rem; height: 1rem; flex: none; } .nav button, .nav .btn-ghost { display: flex; align-items: center; gap: 0.5rem; @@ -246,28 +222,27 @@ main { flex: 1 1 auto; min-width: 0; width: 100%; padding: 1.5rem 1.5rem 2.5rem; } -main.ops { max-width: var(--ops-max); } -main.form { max-width: calc(var(--form-max) + 3rem); } -main.form .measure, main.form > :not(.phone-bar) { max-width: var(--form-max); } main.auth { max-width: none; display: flex; flex-direction: column; align-items: center; padding-top: 3rem; } main.auth > * { width: 100%; max-width: var(--auth-max); margin-left: auto; margin-right: auto; } +main.stack > .page-head, +main.stack > h1 { margin-bottom: 0; } +main.stack > .back { margin: 0; } +main.stack > .route { margin-top: 0; margin-bottom: 0; } +main.stack > p.muted { margin-top: 0; margin-bottom: 0; } +main.stack > .toolbar { margin-bottom: 0; max-width: var(--ops-max); } -.screen { display: none; } -.screen:target { display: block; } -main.auth.screen:target { display: flex; } -html:not(:has(.screen:target)) #status { display: block; } -body:has(#login:target) .nav, -body:has(#setup:target) .nav, -body:has(#login:target) .phone-bar, -body:has(#setup:target) .phone-bar { display: none !important; } -body:has(#login:target) .app, -body:has(#setup:target) .app { display: block; } +body.page-login .nav, +body.page-setup .nav, +body.page-login .phone-bar, +body.page-setup .phone-bar { display: none !important; } +body.page-login .app, +body.page-setup .app { display: block; } #role-domain:checked ~ .app .g-only { display: none !important; } -#role-domain:checked ~ .app #settings .split { +#role-domain:checked ~ .app .pair:has(> .g-only) { display: block; max-width: var(--form-max); } @@ -545,7 +520,7 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re padding: 0.75rem 0.1rem; text-decoration: none; color: var(--fg); } .phone-list .when { font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); } -.phone-list .pair { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; } +.phone-list .meta { grid-column: 1 / -1; font-family: var(--font-mono); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; } .phone-list .subj { grid-column: 1 / -1; margin: 0; } /* Help drawer — CSS checkbox, no script required */ @@ -626,6 +601,7 @@ html:has(#help-export:checked) .help-pane-export { display: block; } #vp-phone:checked ~ .app .phone-list { display: block; } #vp-phone:checked ~ .app .status-grid { display: flex; flex-direction: column; } #vp-phone:checked ~ .app .status-grid .attn { order: -1; } +#vp-phone:checked ~ .app .pair { grid-template-columns: 1fr; } #vp-phone:checked ~ .app .split, #vp-phone:checked ~ .app .check-cols, #vp-phone:checked ~ .app .field-pair:not(.host-type), @@ -665,7 +641,7 @@ html:has(#help-export:checked) .help-pane-export { display: block; } .phone-only, .phone-list { display: block; } .status-grid { display: flex; flex-direction: column; } .status-grid .attn { order: -1; } - .split, #settings .split, #backup > .split, + .split, .pair, #settings .split, #backup > .split, .check-cols, .field-pair:not(.host-type), .facts, .retry-facts { grid-template-columns: 1fr; } .help-drawer { width: 100vw; } } diff --git a/docs/assets/panel-ui/settings.html b/docs/assets/panel-ui/settings.html new file mode 100644 index 0000000..981c04f --- /dev/null +++ b/docs/assets/panel-ui/settings.html @@ -0,0 +1,73 @@ + + + + + +Settings — SelfPost mockups + + + + + +
+

Settings

+
+
+
+

Panel credentials

+

These are the credentials for this control panel only. Applications keep their own logins and passwords, which are not affected.

+ + + + +
+

Leave both new-password fields empty to change the username or DMARC address only. Changing the password signs out every other session; this one stays signed in.

+
+
+

DMARC aggregate reports

+

Default rua= for every sending domain (overridable per domain). When ingest is on, this can be an address SelfPost accepts.

+ + +

When rua= points at another domain, that hub must publish a report-authorisation record. DMARC reports in the panel.

+
+
+ +
mail.example.org._report._dmarc.example.com
+
+
+ + TXT +
+
+ +
v=DMARC1;
+ +

Published at mail.example.org._report._dmarc.example.com — aggregate reports addressed to dmarc@mail.example.org are authorised.

+
+
+
+
+
+

Sending rate limits

+

Level 1 is set in Compose; restart the container to change it. Domain and application ceilings live on each domain’s page.

+
+
+

Level 1 — per client IP

+ 100 messages / 60 seconds +

RATE_LIMIT_MESSAGES_PER_IP / RATE_LIMIT_WINDOW_SECONDS. Hard ceiling for every connecting IP; the panel cannot raise a domain or application limit above this.

+
+
+

Level 2 — domain

+

Optional ceiling for all senders on a domain. When unset, only level 1 applies. Must be ≤ level 1.

+
+
+

Level 2 — application

+

Optional override for trusted IPs: a ceiling strictly above the domain limit (still ≤ level 1). Those IPs skip the domain check; everyone else stays under the domain (or level 1).

+
+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/setup.html b/docs/assets/panel-ui/setup.html new file mode 100644 index 0000000..9931c5d --- /dev/null +++ b/docs/assets/panel-ui/setup.html @@ -0,0 +1,30 @@ + + + + + +Create administrator — SelfPost mockups + + + + + +
+ SelfPost +

Create administrator

+
+

This one-time link creates the single panel administrator. After you submit, the link stops working for good.

+
+ + + + + + +
+
+
+
+ + + diff --git a/docs/assets/panel-ui/shell.js b/docs/assets/panel-ui/shell.js new file mode 100644 index 0000000..b32ef7c --- /dev/null +++ b/docs/assets/panel-ui/shell.js @@ -0,0 +1,276 @@ +/* Shared chrome for panel UI page mockups. Each screen is its own HTML file; + this script injects radios, gallery, nav, sprite, and the help drawer so + file:// viewing does not need a module fetch. */ +(function () { + if (document.body.dataset.shell === "1") return; + document.body.dataset.shell = "1"; + var main = document.querySelector("main"); + if (!main) return; + + var page = document.body.getAttribute("data-page") || ""; + var navKey = document.body.getAttribute("data-nav") || page; + var title = document.body.getAttribute("data-title") || document.title; + var globalOnly = document.body.getAttribute("data-global-only") === "1"; + + document.body.insertAdjacentHTML("afterbegin", + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + ); + + var q = new URLSearchParams(location.search); + if (q.get("view") === "phone") document.getElementById("vp-phone").checked = true; + if (q.get("role") === "domain") document.getElementById("role-domain").checked = true; + if (q.get("theme") === "dark") document.getElementById("theme-dark").checked = true; + if (q.get("inbound") === "0") document.getElementById("feat-inbound").checked = false; + + var gallery = + '"; + + var sprite = + '"; + + function icon(id) { + return ''; + } + + var navHtml = + '"; + + var phone = + '
' + + '" + + '' + + '' + + '' + + "
"; + + var help = + '' + + '"; + + var app = document.createElement("div"); + app.className = "app"; + app.innerHTML = navHtml + '
' + phone + "
"; + main.parentNode.insertBefore(app, main); + app.insertAdjacentHTML("beforebegin", gallery + sprite); + app.querySelector(".stage").appendChild(main); + app.insertAdjacentHTML("afterend", help); + + var cur = app.querySelector(".n-" + navKey); + if (cur) cur.setAttribute("aria-current", "page"); + var grow = app.querySelector(".phone-title"); + if (grow) grow.textContent = title; + + var skip = { "index.html": 1, "system.html": 1, "app.html": 1 }; + + function qs() { + var p = new URLSearchParams(); + if (document.getElementById("vp-phone").checked) p.set("view", "phone"); + if (document.getElementById("role-domain").checked) p.set("role", "domain"); + if (document.getElementById("theme-dark").checked) p.set("theme", "dark"); + if (!document.getElementById("feat-inbound").checked) p.set("inbound", "0"); + var s = p.toString(); + return s ? "?" + s : ""; + } + + function withQuery(href) { + if (!href) return href; + if (href.charAt(0) === "#" || /^(https?:|mailto:|javascript:)/i.test(href)) return href; + var hash = ""; + var path = href; + var hashAt = href.indexOf("#"); + if (hashAt >= 0) { + hash = href.slice(hashAt); + path = href.slice(0, hashAt); + } + var qAt = path.indexOf("?"); + var file = qAt >= 0 ? path.slice(0, qAt) : path; + var extra = qAt >= 0 ? path.slice(qAt + 1) : ""; + var base = file.split("/").pop(); + if (!base || !/\.html$/i.test(base) || skip[base]) return href; + var p = new URLSearchParams(extra); + var curQs = new URLSearchParams(qs().replace(/^\?/, "")); + ["view", "role", "theme", "inbound"].forEach(function (k) { + if (curQs.has(k)) p.set(k, curQs.get(k)); + else p.delete(k); + }); + var s = p.toString(); + return base + (s ? "?" + s : "") + hash; + } + + function rewriteLinks() { + document.querySelectorAll("a[href]").forEach(function (a) { + var raw = a.getAttribute("href"); + if (!raw) return; + a.setAttribute("href", withQuery(raw)); + }); + } + + function brandHref() { + var brand = document.querySelector(".nav .brand"); + if (!brand) return; + brand.setAttribute("href", withQuery( + document.getElementById("role-domain").checked ? "domains.html" : "status.html" + )); + } + + function gate() { + brandHref(); + if (!document.getElementById("role-domain").checked) return; + if (globalOnly) location.replace(withQuery("domains.html")); + } + + function syncUrl() { + if (history.replaceState) { + history.replaceState(null, "", location.pathname.split("/").pop() + qs() + location.hash); + } + rewriteLinks(); + brandHref(); + gate(); + } + + ["role-global", "role-domain", "vp-desktop", "vp-phone", "theme-light", "theme-dark", "feat-inbound"].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener("change", syncUrl); + }); + + document.querySelectorAll(".nav a").forEach(function (a) { + a.addEventListener("click", function () { + var open = document.getElementById("nav-open"); + if (open) open.checked = false; + }); + }); + + rewriteLinks(); + gate(); +})(); diff --git a/docs/assets/panel-ui/status.html b/docs/assets/panel-ui/status.html new file mode 100644 index 0000000..39c2f30 --- /dev/null +++ b/docs/assets/panel-ui/status.html @@ -0,0 +1,125 @@ + + + + + +Status — SelfPost mockups + + + + + +
+
+

Status

+ +
+ +
+
+

Overall warn

+

Running, with warnings below.

+
+
+ +
+
+

Mail queue warn

+

3 Kbytes in 3 Requests.

+ +
+
+

TLS certificate ok

+ + 2026-11-02 12:00 UTC +

Valid for another 78 day(s).

+
+
+ +
+
+

Milter sockets ok

+ + + + + + +
MilterStateDetail
OpenDKIMokListening
send-logokListening
+
+
+

Hostname and reverse DNS ok

+ + mail.example.org + + 203.0.113.10 → mail.example.org +

mail.example.org resolves to 203.0.113.10 and the reverse lookup points back at it.

+
+
+
+ +
+
+

Inbound warn

+

INBOUND_RELAY_ENABLE is on. Port 25 accepts mail for 2 domains and forwards it upstream — not to local mailboxes.

+

One domain has no MX pointing at this server. Recipients are a list or any address at the domain. Open Inbound for the list, MX checks, upstream, and recipient maps.

+

Inbound domains

+
+
+ +
+
+

Machine ok

+ + + + + + + + + + + + + + + + + + + +
ResourceUsageDetail
CPU12% 12%4 cores · 4 threads
Memory41% 41%1.6 GiB used of 4.0 GiB.
Network↓ 2.0 KiB/s
↑ 1.0 KiB/s
eth0: 1.0 MiB in, 512.0 KiB out
+
+
+

Processes ok

+ + + + + + + + + + +
ProgramStateDetail
opendkimRUNNINGpid 21, uptime 3 days, 4:12:01
panelRUNNINGpid 18, uptime 3 days, 4:12:03
postfixRUNNINGpid 42, uptime 3 days, 4:11:58
postfix-reloadSTOPPEDNot started
cert-reloadSTOPPEDNot started
logrotateSTOPPEDNot started
+
+
+ +
+
+

Configuration

+

Regenerates the OpenDKIM and Postfix configuration from the + database and reloads both daemons. Use it if you edited the files by hand, + restored a backup, or the running configuration looks out of step with the + domain and application lists. It does not touch the mail queue or the TLS + certificate, and it is safe to run at any time.

+
+
+
+

SelfPost 1.2.3 · © Mixeme · License (AGPL-3.0)

+
+ + + diff --git a/docs/assets/panel-ui/system-log.html b/docs/assets/panel-ui/system-log.html new file mode 100644 index 0000000..fbdda11 --- /dev/null +++ b/docs/assets/panel-ui/system-log.html @@ -0,0 +1,27 @@ + + + + + +System log — SelfPost mockups + + + + + +
+

System log

+
+
+

Recent log entries

+ Aug 15 20:14:08 mail postfix/smtp[230]: 4C3A1E2F1A: to=<ada@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=6, delays=0.2/0.1/0.4/5.3, dsn=4.2.1, status=deferred (450 4.2.1 mailbox busy) +Aug 15 20:14:02 mail postfix/qmgr[119]: 4C3A1E2F1A: from=<billing@example.com>, size=12288, nrcpt=1 (queue active) +Aug 15 20:14:02 mail postfix/smtpd[221]: 4C3A1E2F1A: client=203.0.113.40[203.0.113.40], sasl_method=PLAIN, sasl_username=billing +Aug 15 20:11:40 mail postfix/smtp[228]: 4B19D0AA01: to=<list-bounces@example.net>, relay=mx.example.net[198.51.100.20]:25, delay=0.9, status=sent (250 2.0.0 Ok) +Aug 15 20:02:11 mail postfix/smtp[226]: 4B19C0BB12: to=<noreply@blocked.example>, status=bounced (host mx.blocked.example[203.0.113.99] said: 550 5.7.1 rejected) +
+
+
+ + + diff --git a/docs/assets/panel-ui/system.css b/docs/assets/panel-ui/system.css new file mode 100644 index 0000000..5877fd0 --- /dev/null +++ b/docs/assets/panel-ui/system.css @@ -0,0 +1,127 @@ +/* Panel UI system primitives. + Source of truth: system.html. Screens are separate HTML files composed from + these classes. Do not “fix” empty columns, Host/Type height, or split + actions by one-off rules — compose with these instead. */ + +.stack { + display: flex; flex-direction: column; gap: 1rem; + width: 100%; min-width: 0; +} +.stack > .card, +.stack > .pair, +.stack > .measure, +.stack > .fill { margin-top: 0; } +.stack > .card + .card { margin-top: 0; } + +.measure { width: 100%; max-width: var(--form-max); min-width: 0; } + +.fill { width: 100%; max-width: var(--ops-max); min-width: 0; } +.fill > .card { margin-top: 0; } +.fill .card:has(table) { overflow-x: auto; } + +/* Two peer jobs. One child → reading measure (domain-admin Settings). + Never a full-width lonely card on an ops page. */ +.pair { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + width: 100%; max-width: var(--ops-max); min-width: 0; +} +.pair > * { min-width: 0; } +.card > .stack { min-width: 0; } +.pair > .card { margin-top: 0; } +.pair > .card + .card { margin-top: 0; } +.pair:has(> :only-child) { + display: block; + max-width: var(--form-max); +} + +.actions-row { + display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; + margin-top: 1.1rem; +} +.actions-row > button, +.actions-row > a.btn, +.actions-row > a.danger { margin-top: 0; } + +/* Two labelled controls on one row. Labels share row 1, values share row 2 + so they are the same height by construction — not by matching padding. */ +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto minmax(2.5rem, auto); + column-gap: 1rem; + align-items: stretch; + margin-top: 0.45rem; +} +.field-row.equal { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } +.field-row > .field { display: contents; } +.field-row > .field:first-child > * { grid-column: 1; } +.field-row > .field:last-child > * { grid-column: 2; } +.field-row > .field > label { grid-row: 1; margin-top: 0.45rem; } +.field-row > .field > :not(label) { + grid-row: 2; align-self: stretch; min-width: 0; + margin-top: 0.3rem; box-sizing: border-box; +} +.field-row > .field > .code, +.field-row > .field > .code-row { + height: 100%; +} +.field-row > .field > .code { + display: flex; align-items: center; + padding-top: 0.45rem; padding-bottom: 0.45rem; +} +.field-row > .field:last-child > .code { + width: fit-content; min-width: 2.75rem; + justify-content: center; text-align: center; +} +.field-row .code-row { + display: flex; align-items: stretch; gap: 0.5rem; height: 100%; +} +.field-row .code-row .code { + flex: 1; min-width: 0; margin-top: 0; height: auto; + display: flex; align-items: center; + padding-top: 0.45rem; padding-bottom: 0.45rem; +} +.field-row .code-row .copy { margin-top: 0; align-self: stretch; } + +.card-head { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; + margin-bottom: 0.5rem; +} +.card-head h2 { margin: 0; } + +@media (max-width: 52rem) { + .pair { grid-template-columns: 1fr; } + /* field-row stays two columns: Type is a token, not a second form. */ +} + +/* Specimens on the system page */ +.sys-wrap { max-width: 70rem; } +.sys-wrap > header p, +.sys-wrap section > p, +.sys-wrap li { max-width: 68ch; } +.sys-toc { + display: flex; flex-wrap: wrap; gap: 0.35rem 1.1rem; + margin: 1rem 0 0; padding: 0; list-style: none; +} +.sys-toc a { font-size: 0.92rem; } +.specimen { + margin: 1rem 0 0; padding: 0.9rem 1rem 1.1rem; + border: 1px dashed var(--border); border-radius: 6px; background: var(--code-bg); +} +.specimen > figcaption { + font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--muted); margin: 0 0 0.7rem; +} +.specimen.bad { + border-color: var(--danger-border); background: var(--danger-bg); +} +.specimen.bad > figcaption { color: var(--danger-fg); } +.recipes { + width: 100%; margin-top: 0.8rem; font-size: 0.92rem; +} +.recipes th, .recipes td { white-space: normal; vertical-align: top; } +.recipes code { font-size: 0.82rem; } +.forbid { margin: 0.4rem 0 0; padding-left: 1.1rem; } +.forbid li { margin: 0.35rem 0; } diff --git a/docs/assets/panel-ui/system.html b/docs/assets/panel-ui/system.html new file mode 100644 index 0000000..e460c82 --- /dev/null +++ b/docs/assets/panel-ui/system.html @@ -0,0 +1,294 @@ + + + + + +SelfPost — система панели + + + + + +
+ +
+

система · не рескин

+

SelfPost — грамматика интерфейса

+

Это проект системы. Экраны складываются из регионов и контролов. Запрещённые состояния нельзя «починить паддингом» — их не из чего собрать. Знак, кирпич и IBM Plex не меняются. Живая панель (internal/web) пока не трогается.

+ + +
+ +
+

Зачем

+

Первый макет скопировал грамматику живой панели: страница помечается ops или form, карточки стопкой, пара полей — два независимых .code, Save внутри <form>, Delete снаружи. Дальше каждая мелочь чинилась отдельно — как в panel.css годами. Новый интерфейс должен не давать собрать пустую колонку, разные высоты Host/Type и кнопки на двух строках.

+

Три оси, которые больше не выбираются «на глаз» у каждой страницы:

+
    +
  1. Регион — как блок занимает ширину окна.
  2. +
  3. Карточка — заголовок, справка, тело, действия.
  4. +
  5. Контрол — одно поле или пара полей одной высоты.
  6. +
+
+ +
+

Регионы

+

Страница — это stack регионов, не класс на <main>. Оболочка одна: навбар прижат влево, контент забирает остаток. Навбар не прыгает, когда меняется содержимое.

+ + + + + + + + + + + + + + + + + + + + + + + + +
РегионШиринаКогда
measureдо 42rem, влевоОдна читаемая форма: login, setup, подтверждение удаления, форма пользователя. Не «вся страница Settings».
pairдве колонки до 90remДве равноправные задачи на одном экране. Один ребёнок — сам сжимается в measure (domain-admin без DMARC).
fillдо 90remТаблица, лог, DNS-статус, список приложений. Ячейки не переносят однострочные значения; карточка скроллится по X.
stackколонка с зазоромВертикальный порядок регионов. Зазор даёт gap, не .card + .card.
+ +
+
Нельзя — страница-форма, две задачи стопкой
+
+
+

Full backup

+

Карточка узкая. Справа пустое поле на 1600px.

+ +
+
+

Import a domain

+

Вторая задача под первой — та же пустота.

+ +
+
+
+ +
+
Надо — pair
+
+
+

Full backup

+

Секрет. Шифрование — внутри карточки, поля не растягиваются на 1440px.

+
+
+
+

Import a domain

+

Тот же экран, вторая задача. Не «ещё одна форма ниже».

+
+
+
+
+
+ +
+

Карточка

+

Один хром: card-head (заголовок + слот «?»), тело, при необходимости actions-row. Справка — часть хрома, её не расставляют после того, как карточки уже собраны. Нет «?» — слот пустой, заголовки соседних карточек всё равно на одной линии.

+

Показания (очередь, PTR, CPU) остаются на карточке. В drawer уходит только то, чего на карточке быть не должно: что такое kernel counter, зачем PTR у провайдера, почему пароль показывают один раз.

+
+
Хром карточки
+
+
+
+

DNS status ok

+ ? +
+

Чтение и Re-check здесь. Абзац «зачем MX» — в справке.

+
+
+
+

Danger zone

+
+

Без «?»: действие очевидное. Карточка всё равно пара к форме, не на всю ширину.

+ +
+
+
+
+ +
+

Контролы

+

Одно поле — field (подпись + контроль). Два поля в ряд — field-row: подписи в первой сетке-строке, значения во второй. Высота значений общая, потому что это одна строка грида, а не два блока с подобранным padding. Баг «Type ниже Host» (b0ebe06) из этой разметки не собирается.

+

field-row с узкой второй колонкой — Host / name ‖ Type. field-row equal — два равноправных инпута (лимит и окно).

+ +
+
field-row — Host ‖ Type, с Copy и без
+
+
+

With Copy

+
+
+ +
+ mail._domainkey.example.com + +
+
+
+ + TXT +
+
+
+
+

Lookup, no Copy

+
+
+ + lists.example.com +
+
+ + MX +
+
+
+
+
+ +
+
field-row equal — лимит ‖ окно
+
+
+

Level-2 rate limit

+
+
+ + +
+
+ + +
+
+
+
+
+
+ +
+

Действия

+

Primary, secondary и danger — всегда actions-row. Для вёрстки неважно, POST это или переход на confirm: ряд один. Форма либо оборачивает всю карточку, либо кнопки несут form=. Нельзя оставить Submit внутри блочной формы, а Delete — следующим соседом: блок формы занимает строку целиком.

+
+
Save и Delete в одном ряду
+
+
+

Edit user

+ + +
+ + Delete user +
+
+
+
+
+ +
+

Нельзя

+

Если хочется добавить правило «только на этой странице» — сначала проверить, какого региона не хватило.

+
    +
  • Класс на main (ops / form) как способ выбрать ширину. Ширину выбирает регион.
  • +
  • Одиночная карточка на 90rem с двумя полями ввода. Это measure или pair.
  • +
  • Danger zone отдельным fill. Она вторая колонка последней пары (получатели ‖ удалить, экспорт ‖ удалить).
  • +
  • Два .code рядом с разным padding, чтобы «почти совпало». Только field-row.
  • +
  • Submit внутри <form>, danger-ссылка после </form>.
  • +
  • Перенос однострочного поля, адреса, статуса, hostname. nowrap + горизонтальный скролл карточки.
  • +
  • Status из одних бейджей без Detail / без строки очереди / без PTR.
  • +
  • Телефон как уменьшенный десктоп с шестиколоночной таблицей. Таблица → список; pair → одна колонка; field-row остаётся парой.
  • +
  • Копировать разметку internal/web/view/templates «как есть» в макет. Рецепт экрана — ниже, не шаблон Go.
  • +
+
+ +
+

Рецепты экранов

+

Экраны — отдельные HTML-файлы рядом с этой спецификацией. Собираются только так:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ЭкранСтек регионов
Login / setupцентр оболочки, measure 24rem, полный stamp
Statusstack: overall → pair очередь‖TLS → pair milters‖PTR → inbound если есть → pair machine‖processes → configuration. На карточках остаются числа и Detail.
Domains / Deliveries / log / queuefill таблица. Добавление домена — measure или узкая карточка над таблицей, не растянутый инпут.
Domainfill DNS status → pair DKIM/SPF ‖ DMARC → pair connection ‖ add app → fill applications → pair domain settings (две половины) → pair export ‖ danger. «?» на рабочих карточках.
Inbound domainfill MX DNS → pair upstream ‖ MX to publish → pair recipients ‖ danger
Backuppair full backup ‖ import
Settingsglobal: pair credentials ‖ DMARC, затем fill rate limits. Domain-admin: один ребёнок в pair → сам measure
Usersfill таблица. Create/Editmeasure + actions-row
Confirm deletemeasure одна карточка
+
+ +
+

Телефон

+

Аварийный доступ, не продукт. pair складывается в одну колонку. Таблица заменяется списком (как сейчас в прототипе). field-row не складывается: Type — токен. Навбар — выезжающая колонка на checkbox, без обязательного JS. Знак в шапке — SP-иконка, не второй wordmark.

+
+ +
+

Как внедрять

+
    +
  1. Новый экран или правка макета — только классы из system.css (stack, pair, measure, fill, field-row, actions-row).
  2. +
  3. Не добавлять исключения в mock.css «для этой страницы», если это ширина, высота пары полей или ряд кнопок.
  4. +
  5. Click-through — отдельные страницы (status.html, domain.html, …), не простыня с якорями. Оболочка общая: shell.js.
  6. +
  7. Вёрстка internal/web — отдельная задача после утверждения системы, не параллельный рескин шаблонов.
  8. +
+

Классы живут в docs/assets/panel-ui/system.css. Этот файл — спецификация. Экраны — status.html и соседние страницы; оглавление — index.html.

+
+ +
+ + diff --git a/docs/assets/panel-ui/user-delete.html b/docs/assets/panel-ui/user-delete.html new file mode 100644 index 0000000..80daf4f --- /dev/null +++ b/docs/assets/panel-ui/user-delete.html @@ -0,0 +1,27 @@ + + + + + +Delete ops-alerts — SelfPost mockups + + + + + +
+
+

Delete ops-alerts

+ ← Back to ops-alerts +
+
+
+

Confirm deletion

+

You are about to delete the panel user ops-alerts. A signed-in session for this user stops working immediately.

+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/user-form.html b/docs/assets/panel-ui/user-form.html new file mode 100644 index 0000000..5d43347 --- /dev/null +++ b/docs/assets/panel-ui/user-form.html @@ -0,0 +1,42 @@ + + + + + +Edit user — SelfPost mockups + + + + + +
+
+

Edit user

+ ← Back to users +
+
+
+
+ + + + + + +
+ Assigned domains +

Required for domain administrators.

+ + +
+
+ + Delete user +
+
+
+
+
+ + + diff --git a/docs/assets/panel-ui/users.html b/docs/assets/panel-ui/users.html new file mode 100644 index 0000000..37bbe3b --- /dev/null +++ b/docs/assets/panel-ui/users.html @@ -0,0 +1,29 @@ + + + + + +Users — SelfPost mockups + + + + + +
+

Users

+
+
+

Create user

+ + + + + + +
UsernameRoleDomains
adminGlobalAllEdit
ops-alertsDomain adminalerts.example.comEdit
+
+
+
+ + +