Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d45fbf2c44 | ||
|
|
84d2d24c7d |
@@ -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.out{color:rgb(var(--app-faint))}
|
||||
.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}
|
||||
/* month */
|
||||
.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
|
||||
panelView: "agenda", // embed agenda: day | 3days | agenda
|
||||
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: "",
|
||||
quota: "",
|
||||
jitsi: "", // custom Jitsi server (blank => https://meet.jit.si/)
|
||||
@@ -500,8 +513,10 @@ export default function mount(el, ctx, props) {
|
||||
|
||||
function rangeFor() {
|
||||
if (state.embed) {
|
||||
const gs = startOfMonth(state.anchor);
|
||||
return [gs, addDays(gs, 42)]; // covers the panel's ~30-day agenda
|
||||
// The anchor month's six mini-calendar weeks: covers the agenda (that
|
||||
// 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") {
|
||||
const gs = startOfWeek(startOfMonth(state.anchor));
|
||||
@@ -519,6 +534,7 @@ export default function mount(el, ctx, props) {
|
||||
const [s, e] = rangeFor();
|
||||
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.loadedRange = [s, e];
|
||||
}
|
||||
function visibleEvents() {
|
||||
return state.events.filter((ev) => !state.hidden.has(ev.calendar_id));
|
||||
@@ -529,6 +545,50 @@ export default function mount(el, ctx, props) {
|
||||
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"); }
|
||||
// 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,
|
||||
@@ -1098,6 +1158,7 @@ export default function mount(el, ctx, props) {
|
||||
// Compact single-column layout for Mail's right panel. Reuses miniMonth/agenda/
|
||||
// searchPanelHtml + the form/popover/confirm overlays exactly as the full app.
|
||||
function renderEmbed() {
|
||||
if (!miniCovered() && state.miniBusy.key !== iso(miniRange()[0])) void loadMiniBusy();
|
||||
// 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).
|
||||
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 gs = week ? startOfWeek(state.anchor) : startOfWeek(startOfMonth(m));
|
||||
const cellCount = week ? 7 : 42;
|
||||
const busySet = !state.embed
|
||||
? new Set()
|
||||
: busyDays(miniCovered() ? state.events : (state.miniBusy.events || []));
|
||||
let cells = "";
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const d = addDays(gs, i);
|
||||
const today = sameDay(d, new Date());
|
||||
// Embed: the agenda has no other sign of which day was picked.
|
||||
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>`;
|
||||
}
|
||||
return `<div class="cal-mini">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
||||
<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 class="cal-mini-grid">${["Mo","Tu","We","Th","Fr","Sa","Su"].map((h) => `<div class="h">${h}</div>`).join("")}${cells}</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.
|
||||
const onChanged = async () => {
|
||||
await loadEvents();
|
||||
if (state.embed) state.miniBusy.key = ""; // another month's dots: fetched again
|
||||
if (!state.composing && !state.confirm) render();
|
||||
};
|
||||
window.addEventListener("webmail:calendar-changed", onChanged);
|
||||
|
||||
@@ -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). |
|
||||
| **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
|
||||
- `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.
|
||||
|
||||
Reference in New Issue
Block a user