Compare commits

..
2 Commits
Author SHA1 Message Date
lukasandClaude Opus 5.5 d45fbf2c44 M6: network and DNS plan for hosted mail on the QNAP
Hosting decided (spec App. D §D.1 option A): Stalwart on the QNAP, receiving and
sending directly; addresses @ultimo.email; mail apps allowed with app
passwords; mail host mail.ultimo.email. docs/mail-server-network.md records the
connection checks (clean IP, outbound 25 open, generic reverse DNS to change),
the router / Synology / QNAP port layout, container ports, TLS certificate,
the DNS records to set at home.pl, moving mail from home.pl, and checks after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
lukasandClaude Opus 5.5 84d2d24c7d Calendar panel: event dots in the mini calendar, bigger arrows on phones
The side panel's mini calendar marks each day with events with a small dot under
the number (white in dark mode, the text colour in light; white on today's blue
circle), following the calendars filter. The dots come from the agenda's own
events — the panel now loads its month's six mini-calendar weeks once for both;
a mini calendar paged to another month fetches that month once.

The month arrows are drawn icons, centred with the today circle (the text
glyphs sat lower); on phones they are 50% larger with 5px more between the
three buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:40:26 +02:00
3 changed files with 189 additions and 4 deletions
+117
View File
@@ -0,0 +1,117 @@
# Hosted mail (M6): network and DNS plan
Notes for switching `ultimo.email` mail from home.pl to Ultimo's own mail server
(Stalwart on the QNAP). Companion to the M6 plan in
`webmail-client-spec_v1.5.md` Appendix D. Gathered 2026-10-09/10.
## Decisions
| | Decision |
|---|---|
| Hosting | **Everything on the QNAP** — receive and send directly (spec §D.1 option A). A relay (home.pl SMTP, SMTP2GO, Brevo) is optional, as a fallback only. |
| Domain | Addresses `name@ultimo.email`; webmail stays at `https://ultimo.email`. |
| Mail apps | **Allowed**: Thunderbird and phone mail apps use the mailboxes over IMAP/SMTP, signing in with **app passwords** (required when 2FA is on). |
| Mail host name | **`mail.ultimo.email`** (recommended; `ultimo.email` would also work, see below). |
## Current setup
- Static public IP **`178.219.143.96`** (ISP: Multiplay).
- Traffic → **router** → **Synology DSM** → DSM **reverse proxy** → **QNAP** (the webmail).
- All DNS records are at **home.pl**:
- A `ultimo.email` → `178.219.143.96` (the QNAP, through the Synology);
- MX `ultimo.email` → `serwer2295308.home.pl` — **mail is still at home.pl**;
- other records also point at home.pl.
## Checks of the connection (2026-10-10)
| Check | Result |
|---|---|
| Blocklists — Spamhaus ZEN (incl. PBL), Barracuda, SpamCop, SORBS, UCEPROTECT | **Not listed** |
| Outbound port 25 (tested on the QNAP) | **Open** |
| Reverse DNS of `178.219.143.96` | `pub-143.96.multiplay.pl` — generic: **must change** (below) |
| Inbound 25 / 465 / 587 / 993 from outside | Time out — expected: not forwarded yet, nothing listening. Re-test after setup. |
| Inbound 443 | Open (webmail) |
## To do before switching
### 1. Reverse DNS (ask Multiplay)
Set the reverse DNS (PTR) of `178.219.143.96` to **`mail.ultimo.email`**.
Why: receiving servers (Gmail, Outlook…) check that three things agree — the
IP's reverse DNS name, that name's A record pointing back to the IP, and the
name the mail server introduces itself with (its SMTP greeting). A generic ISP
name or a mismatch reads as a hijacked home computer and hurts delivery.
`ultimo.email` itself would work too (its A record already points here); a
separate `mail.` name keeps mail working if the website ever moves elsewhere.
### 2. Router: forward the mail ports straight to the QNAP
DSM's reverse proxy carries only web traffic (HTTP/HTTPS); SMTP and IMAP are
other protocols, so these ports go from the router directly to the QNAP:
| Outside port | To | Purpose |
|---|---|---|
| 443, 80 | Synology (as today) | webmail, CalDAV/CardDAV (`/dav/`, `/.well-known/…`), certificates |
| **25** | QNAP | receiving mail from other servers (must be 25: MX delivery always uses it) |
| **465** | QNAP | sending from mail apps (SMTP over TLS) |
| **587** | QNAP | sending from mail apps (SMTP + STARTTLS) |
| **993** | QNAP | mail apps reading mail (IMAP over TLS) |
Mail sent by the QNAP leaves through the same router, i.e. from
`178.219.143.96` — the address the reverse DNS names.
### 3. The container's ports
Stalwart listens inside its container on the standard ports; Docker publishes
them on the QNAP — the same numbers if free (`"25:25"`, `"465:465"`, `"587:587"`,
`"993:993"`), or any free QNAP port (e.g. `"2525:25"`) with the router mapping
the standard outside port to it. Outside ports stay standard: 25 is mandatory,
and mail apps assume 465/587/993 when setting up an account.
Verify after setup: Stalwart must see the **real IP** of connecting servers and
apps (not a Docker address) — its spam checks and sign-in throttling use it.
### 4. TLS certificate for the mail server
The Synology holds the HTTPS certificate, but IMAP/SMTP connections end at the
QNAP, so Stalwart needs its own for `mail.ultimo.email`. Options:
- **(recommended)** Stalwart's own Let's Encrypt certificate: Let's Encrypt
checks over port 80, which goes to the Synology — add a **DSM reverse-proxy
rule: host `mail.ultimo.email`, port 80 → QNAP, Stalwart's HTTP port**.
Renews itself.
- DNS-based check, if home.pl's DNS can be updated automatically.
- Copy the Synology's certificate to the QNAP after every renewal (clumsiest).
### 5. DNS records at home.pl (at switch-over)
Lower the TTLs a day before.
| Record | Value |
|---|---|
| A `mail.ultimo.email` | `178.219.143.96` |
| MX `ultimo.email` | `10 mail.ultimo.email.` (replaces `serwer2295308.home.pl`) |
| TXT `ultimo.email` (SPF) | `v=spf1 mx -all` (add the relay's include if one is used) |
| TXT `<selector>._domainkey.ultimo.email` (DKIM) | the public key Stalwart generates |
| TXT `_dmarc.ultimo.email` | `v=DMARC1; p=none; rua=mailto:postmaster@ultimo.email` — watch the reports, then move to `quarantine` / `reject` |
| (optional) `autoconfig` / SRV records | so Thunderbird and phones find the server settings by themselves |
| (optional) MTA-STS, TLS-RPT | enforce encrypted delivery to us |
### 6. Moving the existing mail from home.pl
1. Create the mailboxes in Ultimo.
2. Copy each home.pl mailbox into its new one over IMAP.
3. Switch the MX.
4. Keep the home.pl account in the webmail for a while to catch mail that
still arrives there (DNS caches), then remove it.
### 7. Checks after switching
- From outside: ports 25 / 465 / 587 / 993 answer (with a TLS certificate for
`mail.ultimo.email`).
- Reverse DNS → `mail.ultimo.email` → `178.219.143.96`, and Stalwart greets as
`mail.ultimo.email`.
- Mail to and from Gmail and Outlook: headers show SPF, DKIM and DMARC **pass**,
delivered to the inbox (not spam).
- Thunderbird sets up an account with just the address and an app password.
@@ -289,6 +289,17 @@ const CSS = `
.cal-mini-grid button:hover{background:rgb(var(--app-hover))} .cal-mini-grid button:hover{background:rgb(var(--app-hover))}
.cal-mini-grid button.out{color:rgb(var(--app-faint))} .cal-mini-grid button.out{color:rgb(var(--app-faint))}
.cal-mini-grid button.today{background:#3b82f6;color:#fff} .cal-mini-grid button.today{background:#3b82f6;color:#fff}
.cal-mini-grid button{position:relative}
.cal-mininav{display:inline-flex;align-items:center}
.cal-mininav .cal-iconbtn{align-items:center;justify-content:center;line-height:1}
.cal-mininav svg{display:block}
/* Phones (the app's mobile layout): bigger arrows, more room to tap between. */
@media (max-width:767px),(pointer:coarse) and (max-height:500px){
.cal-mininav{gap:5px}
.cal-mininav [data-act=miniprev],.cal-mininav [data-act=mininext]{font-size:1.5em}
}
.cal-mini-grid button.busy::after{content:"";position:absolute;left:50%;bottom:1px;width:4px;height:4px;margin-left:-2px;border-radius:50%;background:rgb(var(--app-fg))}
.cal-mini-grid button.today.busy::after{background:#fff}
.cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600} .cal-mini-grid button.sel{background:rgb(var(--app-active));font-weight:600}
/* month */ /* month */
.cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%} .cal-month{display:grid;grid-template-columns:repeat(7,1fr);grid-template-rows:auto;grid-auto-rows:minmax(0,1fr);height:100%}
@@ -487,6 +498,8 @@ export default function mount(el, ctx, props) {
panelMini: "month", // embed mini calendar: month | week panelMini: "month", // embed mini calendar: month | week
panelView: "agenda", // embed agenda: day | 3days | agenda panelView: "agenda", // embed agenda: day | 3days | agenda
scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once scrollToAnchor: false, // embed: bring the anchor day to the top of the agenda once
loadedRange: null, // [start, end) of state.events
miniBusy: { key: "", events: [] }, // embed: another month's events, for the mini calendar's dots
me: "", me: "",
quota: "", quota: "",
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/) jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
@@ -500,8 +513,10 @@ export default function mount(el, ctx, props) {
function rangeFor() { function rangeFor() {
if (state.embed) { if (state.embed) {
const gs = startOfMonth(state.anchor); // The anchor month's six mini-calendar weeks: covers the agenda (that
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda // month, from its 1st) and the mini calendar's dots in one request.
const gs = startOfWeek(startOfMonth(state.anchor));
return [gs, addDays(gs, 42)];
} }
if (state.view === "month") { if (state.view === "month") {
const gs = startOfWeek(startOfMonth(state.anchor)); const gs = startOfWeek(startOfMonth(state.anchor));
@@ -519,6 +534,7 @@ export default function mount(el, ctx, props) {
const [s, e] = rangeFor(); const [s, e] = rangeFor();
const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`); const res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`);
state.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) })); state.events = (res.events || []).map((ev) => ({ ...ev, _s: new Date(ev.start), _e: new Date(ev.end) }));
state.loadedRange = [s, e];
} }
function visibleEvents() { function visibleEvents() {
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id)); return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
@@ -529,6 +545,50 @@ export default function mount(el, ctx, props) {
render(); render();
} }
// Panel: which days of the mini calendar have events (a dot under the number).
function miniRange() {
if (state.panelMini === "week") { const s = startOfWeek(state.anchor); return [s, addDays(s, 7)]; }
const s = startOfWeek(startOfMonth(state.mini));
return [s, addDays(s, 42)];
}
const dayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
function busyDays(events) {
const days = new Set();
for (const ev of events) {
if (state.hidden.has(ev.calendar_id)) continue;
if (ev.all_day) { // date-anchored: its own dates, end exclusive
let d = new Date(`${ev.start.slice(0, 10)}T00:00:00`);
const last = new Date(`${ev.end.slice(0, 10)}T00:00:00`);
do { days.add(dayKey(d)); d = addDays(d, 1); } while (d < last);
} else {
let d = startOfDay(new Date(ev.start));
const end = new Date(ev.end);
do { days.add(dayKey(d)); d = addDays(d, 1); } while (d < end);
}
}
return days;
}
// The dots come from the agenda's events while the mini calendar shows their
// month; paged to another month, that month's are fetched once.
function miniCovered() {
const [s, e] = miniRange();
const r = state.loadedRange;
return Boolean(r && s >= r[0] && e <= r[1]);
}
async function loadMiniBusy() {
const [s, e] = miniRange();
const key = iso(s);
if (state.miniBusy.key === key) return;
state.miniBusy.key = key;
let res;
try {
res = await api.pluginFetch(`/events?start=${encodeURIComponent(iso(s))}&end=${encodeURIComponent(iso(e))}`);
} catch { return; }
if (state.miniBusy.key !== key) return; // paged on meanwhile
state.miniBusy.events = res.events || [];
render();
}
function pad(n) { return String(n).padStart(2, "0"); } function pad(n) { return String(n).padStart(2, "0"); }
// Wire every custom date field in the form: an editable text box showing the date in // Wire every custom date field in the form: an editable text box showing the date in
// the preferred order (typing is parsed in that order), backed by a hidden ISO input, // the preferred order (typing is parsed in that order), backed by a hidden ISO input,
@@ -1098,6 +1158,7 @@ export default function mount(el, ctx, props) {
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/ // Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app. // searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
function renderEmbed() { function renderEmbed() {
if (!miniCovered() && state.miniBusy.key !== iso(miniRange()[0])) void loadMiniBusy();
// Re-rendering replaces the agenda: keep where it was scrolled to (else // Re-rendering replaces the agenda: keep where it was scrolled to (else
// opening an event's details lower down jumped the list back to the top). // opening an event's details lower down jumped the list back to the top).
const prevBody = el.querySelector(".cal-embed-body"); const prevBody = el.querySelector(".cal-embed-body");
@@ -1260,19 +1321,23 @@ export default function mount(el, ctx, props) {
const week = state.embed && state.panelMini === "week"; const week = state.embed && state.panelMini === "week";
const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m)); const gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
const cellCount = week ? 7 : 42; const cellCount = week ? 7 : 42;
const busySet = !state.embed
? new Set()
: busyDays(miniCovered() ? state.events : (state.miniBusy.events || []));
let cells = ""; let cells = "";
for (let i = 0; i < cellCount; i++) { for (let i = 0; i < cellCount; i++) {
const d = addDays(gs, i); const d = addDays(gs, i);
const today = sameDay(d, new Date()); const today = sameDay(d, new Date());
// Embed: the agenda has no other sign of which day was picked. // Embed: the agenda has no other sign of which day was picked.
const sel = state.embed && !today && sameDay(d, state.anchor); const sel = state.embed && !today && sameDay(d, state.anchor);
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : ""].join(" "); const busy = state.embed && busySet.has(dayKey(d));
const cls = [!week && d.getMonth() !== m.getMonth() ? "out" : "", today ? "today" : "", sel ? "sel" : "", busy ? "busy" : ""].join(" ");
cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`; cells += `<button class="${cls}" data-mini="${iso(d)}">${d.getDate()}</button>`;
} }
return `<div class="cal-mini"> return `<div class="cal-mini">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b> <b>${MONTHS[m.getMonth()].slice(0, 3)} ${m.getFullYear()}</b>
<span><button class="cal-iconbtn" data-act="miniprev">‹</button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext">›</button></span> <span class="cal-mininav"><button class="cal-iconbtn" data-act="miniprev" title="Previous month"><svg width=".4em" height=".7em" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 1L1 7l5 6"/></svg></button><button class="cal-iconbtn" data-act="mini-today" title="Go to today"><svg width="11" height="11" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" stroke-width="1.4"/></svg></button><button class="cal-iconbtn" data-act="mininext" title="Next month"><svg width=".4em" height=".7em" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 1l5 6-5 6"/></svg></button></span>
</div> </div>
<div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div> <div class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</div>
</div>`; </div>`;
@@ -1924,6 +1989,7 @@ export default function mount(el, ctx, props) {
// Not while a form is open: re-rendering would throw away what is being typed. // Not while a form is open: re-rendering would throw away what is being typed.
const onChanged = async () => { const onChanged = async () => {
await loadEvents(); await loadEvents();
if (state.embed) state.miniBusy.key = ""; // another month's dots: fetched again
if (!state.composing && !state.confirm) render(); if (!state.composing && !state.confirm) render();
}; };
window.addEventListener("webmail:calendar-changed", onChanged); window.addEventListener("webmail:calendar-changed", onChanged);
+2
View File
@@ -843,6 +843,8 @@ Receiving needs inbound port 25, an MX record and a fixed address; many home ISP
| **B. NAS + relay (recommended)** | Small VPS holds the MX, forwards to the NAS over WireGuard | Relay service (SMTP2GO / Postmark / SES) or the VPS | Mail stays on the NAS; reliable delivery; the VPS is the SMTP-time spam edge (§C.7). | | **B. NAS + relay (recommended)** | Small VPS holds the MX, forwards to the NAS over WireGuard | Relay service (SMTP2GO / Postmark / SES) or the VPS | Mail stays on the NAS; reliable delivery; the VPS is the SMTP-time spam edge (§C.7). |
| **C. Stalwart on a VPS** | VPS | VPS | Mail stored off the NAS; webmail reaches it over the internet. | | **C. Stalwart on a VPS** | VPS | VPS | Mail stored off the NAS; webmail reaches it over the internet. |
**Chosen (2026-10-10): option A** — everything on the QNAP; see `docs/mail-server-network.md` for the network, DNS and port plan and the connection checks.
### D.2 Infrastructure ### D.2 Infrastructure
- `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version. - `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version.
- **Stores:** data store = a separate `stalwart` database on the existing Postgres; blob store = filesystem under `DATA_ROOT/stalwart` (bind mount, not a named volume). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately. - **Stores:** data store = a separate `stalwart` database on the existing Postgres; blob store = filesystem under `DATA_ROOT/stalwart` (bind mount, not a named volume). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately.