Prechádzať zdrojové kódy

feat(landing): add bilingual developer marketing homepage

Serve a public Vite landing page at anonymous /, keep admin console for
authenticated sessions, and add i18n, samples, and route tests.

AI-Co-Authored-By: Grok
chendeben 1 mesiac pred
rodič
commit
6f2ae70274

+ 18 - 0
docs/superpowers/plans/2026-07-09-mailhub-landing-page.md

@@ -0,0 +1,18 @@
+# MailHub Public Landing Page Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Ship a bilingual developer marketing landing at anonymous `/`, keeping authenticated `/` as the admin console.
+
+**Architecture:** Vite third entry `landing` builds static HTML/CSS/JS; `server.js` splits root by session and marks HTML `Cache-Control: private, no-store`.
+
+**Tech Stack:** Vite, vanilla TS/JS + CSS, Node server static serving
+
+**Spec:** `docs/superpowers/specs/2026-07-09-mailhub-landing-page-design.md`
+
+---
+
+### Task 1: Landing source (HTML/CSS/i18n/JS)
+### Task 2: Vite entry
+### Task 3: Server routing + cache headers + tests
+### Task 4: Build, commit, deploy per Agents.md

+ 183 - 0
landing.html

@@ -0,0 +1,183 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="MailHub — self-hosted transactional email with API, SMTP, DNS and webhooks." />
+    <title>MailHub</title>
+  </head>
+  <body>
+    <div class="page">
+      <header class="site-header">
+        <a class="brand" href="#top">
+          <span class="brand-mark">MH</span>
+          <span class="brand-name">MailHub</span>
+        </a>
+        <nav class="nav-links" aria-label="Primary">
+          <a href="#features" data-i18n="nav.features">Features</a>
+          <a href="#integrate" data-i18n="nav.integrate">Integrate</a>
+          <a href="#api" data-i18n="nav.api">API</a>
+          <a href="#smtp" data-i18n="nav.smtp">SMTP</a>
+          <a href="#webhooks" data-i18n="nav.webhooks">Webhooks</a>
+        </nav>
+        <div class="header-actions">
+          <div class="lang-switch" role="group" aria-label="Language">
+            <button type="button" data-locale="zh-CN" class="lang-btn">中文</button>
+            <button type="button" data-locale="en-US" class="lang-btn">EN</button>
+          </div>
+          <a class="btn btn-ghost" href="/login" data-i18n="cta.login">Log in</a>
+          <a class="btn btn-primary" href="/register" data-i18n="cta.signup">Get started</a>
+        </div>
+      </header>
+
+      <main id="top">
+        <section class="hero">
+          <div class="hero-copy">
+            <p class="eyebrow" data-i18n="hero.eyebrow">Transactional email platform</p>
+            <h1 data-i18n="hero.title">Send reliable email via API &amp; SMTP</h1>
+            <p class="lede" data-i18n="hero.lede">
+              Self-hosted MailHub gives you domain DNS guidance, DKIM signing, delivery logs, and signed webhooks — without locking you into a black-box SaaS.
+            </p>
+            <div class="hero-cta">
+              <a class="btn btn-primary btn-lg" href="/register" data-i18n="cta.signup">Get started</a>
+              <a class="btn btn-ghost btn-lg" href="#api" data-i18n="cta.viewApi">View API sample</a>
+            </div>
+            <ul class="trust-strip">
+              <li data-i18n="trust.selfHosted">Self-hosted</li>
+              <li>DKIM</li>
+              <li>SPF</li>
+              <li>DMARC</li>
+              <li data-i18n="trust.webhooks">Delivery webhooks</li>
+            </ul>
+          </div>
+          <div class="hero-code" aria-hidden="false">
+            <div class="code-card">
+              <div class="code-card-bar">
+                <span>curl</span>
+                <button type="button" class="copy-btn" data-copy-target="hero-curl" data-i18n="common.copy">Copy</button>
+              </div>
+              <pre id="hero-curl"><code data-i18n-html="hero.curl"></code></pre>
+            </div>
+          </div>
+        </section>
+
+        <section id="features" class="section">
+          <div class="section-head">
+            <h2 data-i18n="features.title">Built for production senders</h2>
+            <p data-i18n="features.sub">Everything you need to authenticate domains and observe delivery.</p>
+          </div>
+          <div class="feature-grid">
+            <article class="card"><h3 data-i18n="features.dns.title">Domains &amp; DNS</h3><p data-i18n="features.dns.body">Guided SPF, DKIM, DMARC, and one-click DNS provider writes.</p></article>
+            <article class="card"><h3 data-i18n="features.smtp.title">SMTP Submission</h3><p data-i18n="features.smtp.body">App-friendly SMTP listeners with credentials you control.</p></article>
+            <article class="card"><h3 data-i18n="features.api.title">HTTP Send API</h3><p data-i18n="features.api.body">Bearer tokens and a simple JSON send endpoint for apps and services.</p></article>
+            <article class="card"><h3 data-i18n="features.logs.title">Delivery logs</h3><p data-i18n="features.logs.body">Track queue and MTA outcomes with searchable send history.</p></article>
+            <article class="card"><h3 data-i18n="features.hooks.title">Webhooks</h3><p data-i18n="features.hooks.body">Signed callbacks for sent, bounced, and failed terminal events.</p></article>
+            <article class="card"><h3 data-i18n="features.multi.title">Multi-user isolation</h3><p data-i18n="features.multi.body">Domains, tokens, and logs stay scoped to each account.</p></article>
+          </div>
+        </section>
+
+        <section id="integrate" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="integrate.title">Three ways to integrate</h2>
+            <p data-i18n="integrate.sub">Pick the path that matches your stack — configure after sign-in.</p>
+          </div>
+          <div class="integrate-grid">
+            <article class="card">
+              <h3 data-i18n="integrate.api.title">API</h3>
+              <p data-i18n="integrate.api.body">Create an API token, then POST JSON to /api/send with Bearer auth.</p>
+              <a class="text-link" href="#api" data-i18n="integrate.api.link">See API sample →</a>
+            </article>
+            <article class="card">
+              <h3 data-i18n="integrate.smtp.title">SMTP</h3>
+              <p data-i18n="integrate.smtp.body">Point your app at MailHub submission ports with per-user credentials.</p>
+              <a class="text-link" href="#smtp" data-i18n="integrate.smtp.link">See SMTP details →</a>
+            </article>
+            <article class="card">
+              <h3 data-i18n="integrate.hooks.title">Webhooks</h3>
+              <p data-i18n="integrate.hooks.body">Subscribe endpoints to terminal delivery events with HMAC signatures.</p>
+              <a class="text-link" href="#webhooks" data-i18n="integrate.hooks.link">See webhook events →</a>
+            </article>
+          </div>
+        </section>
+
+        <section id="api" class="section">
+          <div class="section-head">
+            <h2 data-i18n="api.title">Send API</h2>
+            <p data-i18n="api.sub">Minimal JSON contract. Hostnames below are placeholders — replace with your MailHub host.</p>
+          </div>
+          <div class="code-card">
+            <div class="code-card-bar">
+              <span>POST /api/send</span>
+              <button type="button" class="copy-btn" data-copy-target="api-curl" data-i18n="common.copy">Copy</button>
+            </div>
+            <pre id="api-curl"><code data-i18n-html="api.curl"></code></pre>
+          </div>
+          <ul class="field-list">
+            <li><code>from</code> — <span data-i18n="api.field.from">sender address on a verified domain</span></li>
+            <li><code>to</code> — <span data-i18n="api.field.to">recipient string or array</span></li>
+            <li><code>subject</code> — <span data-i18n="api.field.subject">message subject</span></li>
+            <li><code>text</code> / <code>html</code> — <span data-i18n="api.field.body">message body</span></li>
+          </ul>
+        </section>
+
+        <section id="smtp" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="smtp.title">SMTP submission</h2>
+            <p data-i18n="smtp.sub">Default listener ports (override in your deployment):</p>
+          </div>
+          <div class="port-grid">
+            <div class="card mono"><strong>25</strong><span>smtp</span></div>
+            <div class="card mono"><strong>587</strong><span>smtp / STARTTLS</span></div>
+            <div class="card mono"><strong>465</strong><span>smtps</span></div>
+            <div class="card mono"><strong>2525</strong><span>smtp</span></div>
+          </div>
+          <p class="note" data-i18n="smtp.note">Create SMTP login credentials in the console after registration. Host example: mail.example.com</p>
+        </section>
+
+        <section id="webhooks" class="section">
+          <div class="section-head">
+            <h2 data-i18n="webhooks.title">Delivery webhooks</h2>
+            <p data-i18n="webhooks.sub">Terminal events only — signed HTTPS callbacks to your systems.</p>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th data-i18n="webhooks.col.status">Status</th>
+                  <th data-i18n="webhooks.col.type">type</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr><td><code>sent</code></td><td><code>email.sent</code></td></tr>
+                <tr><td><code>bounced</code></td><td><code>email.bounced</code></td></tr>
+                <tr><td><code>failed</code></td><td><code>email.failed</code></td></tr>
+              </tbody>
+            </table>
+          </div>
+          <p class="note"><span data-i18n="webhooks.sig">Signature header:</span> <code>X-MailHub-Signature: t=&lt;unix&gt;,v1=&lt;hmac&gt;</code></p>
+        </section>
+
+        <section class="section cta-band">
+          <h2 data-i18n="ctaBand.title">Ready to send?</h2>
+          <p data-i18n="ctaBand.sub">Create an account, add a domain, and ship your first message in minutes.</p>
+          <div class="hero-cta">
+            <a class="btn btn-primary btn-lg" href="/register" data-i18n="cta.signup">Get started</a>
+            <a class="btn btn-ghost btn-lg" href="/login" data-i18n="cta.login">Log in</a>
+          </div>
+        </section>
+      </main>
+
+      <footer class="site-footer">
+        <div>
+          <strong>MailHub</strong>
+          <span data-i18n="footer.tag">Self-hosted email delivery control plane</span>
+        </div>
+        <div class="footer-links">
+          <a href="/login" data-i18n="cta.login">Log in</a>
+          <a href="/register" data-i18n="cta.signup">Get started</a>
+        </div>
+      </footer>
+    </div>
+    <script type="module" src="/src/frontend/landing/main.ts"></script>
+  </body>
+</html>

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 1
public/assets/index-BF4JJP0a.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 32 - 0
public/assets/landing-Btfutfyv.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/landing-CC2SsPE-.css


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/login-Clt8-De1.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/login-Kt8VraJ2.js


+ 1 - 0
public/assets/modulepreload-polyfill-Dezn_h7o.js

@@ -0,0 +1 @@
+(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
public/assets/styles-MGiiWi3S.js


+ 3 - 2
public/index.html

@@ -4,8 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub</title>
-    <script type="module" crossorigin src="/assets/index-C8QpOpFt.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DVUJSal8.js">
+    <script type="module" crossorigin src="/assets/index-BF4JJP0a.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-MGiiWi3S.js">
+    <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-_IX60o7a.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Tu04tXLf.css">
   </head>

+ 185 - 0
public/landing.html

@@ -0,0 +1,185 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="description" content="MailHub — self-hosted transactional email with API, SMTP, DNS and webhooks." />
+    <title>MailHub</title>
+    <script type="module" crossorigin src="/assets/landing-Btfutfyv.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
+    <link rel="stylesheet" crossorigin href="/assets/landing-CC2SsPE-.css">
+  </head>
+  <body>
+    <div class="page">
+      <header class="site-header">
+        <a class="brand" href="#top">
+          <span class="brand-mark">MH</span>
+          <span class="brand-name">MailHub</span>
+        </a>
+        <nav class="nav-links" aria-label="Primary">
+          <a href="#features" data-i18n="nav.features">Features</a>
+          <a href="#integrate" data-i18n="nav.integrate">Integrate</a>
+          <a href="#api" data-i18n="nav.api">API</a>
+          <a href="#smtp" data-i18n="nav.smtp">SMTP</a>
+          <a href="#webhooks" data-i18n="nav.webhooks">Webhooks</a>
+        </nav>
+        <div class="header-actions">
+          <div class="lang-switch" role="group" aria-label="Language">
+            <button type="button" data-locale="zh-CN" class="lang-btn">中文</button>
+            <button type="button" data-locale="en-US" class="lang-btn">EN</button>
+          </div>
+          <a class="btn btn-ghost" href="/login" data-i18n="cta.login">Log in</a>
+          <a class="btn btn-primary" href="/register" data-i18n="cta.signup">Get started</a>
+        </div>
+      </header>
+
+      <main id="top">
+        <section class="hero">
+          <div class="hero-copy">
+            <p class="eyebrow" data-i18n="hero.eyebrow">Transactional email platform</p>
+            <h1 data-i18n="hero.title">Send reliable email via API &amp; SMTP</h1>
+            <p class="lede" data-i18n="hero.lede">
+              Self-hosted MailHub gives you domain DNS guidance, DKIM signing, delivery logs, and signed webhooks — without locking you into a black-box SaaS.
+            </p>
+            <div class="hero-cta">
+              <a class="btn btn-primary btn-lg" href="/register" data-i18n="cta.signup">Get started</a>
+              <a class="btn btn-ghost btn-lg" href="#api" data-i18n="cta.viewApi">View API sample</a>
+            </div>
+            <ul class="trust-strip">
+              <li data-i18n="trust.selfHosted">Self-hosted</li>
+              <li>DKIM</li>
+              <li>SPF</li>
+              <li>DMARC</li>
+              <li data-i18n="trust.webhooks">Delivery webhooks</li>
+            </ul>
+          </div>
+          <div class="hero-code" aria-hidden="false">
+            <div class="code-card">
+              <div class="code-card-bar">
+                <span>curl</span>
+                <button type="button" class="copy-btn" data-copy-target="hero-curl" data-i18n="common.copy">Copy</button>
+              </div>
+              <pre id="hero-curl"><code data-i18n-html="hero.curl"></code></pre>
+            </div>
+          </div>
+        </section>
+
+        <section id="features" class="section">
+          <div class="section-head">
+            <h2 data-i18n="features.title">Built for production senders</h2>
+            <p data-i18n="features.sub">Everything you need to authenticate domains and observe delivery.</p>
+          </div>
+          <div class="feature-grid">
+            <article class="card"><h3 data-i18n="features.dns.title">Domains &amp; DNS</h3><p data-i18n="features.dns.body">Guided SPF, DKIM, DMARC, and one-click DNS provider writes.</p></article>
+            <article class="card"><h3 data-i18n="features.smtp.title">SMTP Submission</h3><p data-i18n="features.smtp.body">App-friendly SMTP listeners with credentials you control.</p></article>
+            <article class="card"><h3 data-i18n="features.api.title">HTTP Send API</h3><p data-i18n="features.api.body">Bearer tokens and a simple JSON send endpoint for apps and services.</p></article>
+            <article class="card"><h3 data-i18n="features.logs.title">Delivery logs</h3><p data-i18n="features.logs.body">Track queue and MTA outcomes with searchable send history.</p></article>
+            <article class="card"><h3 data-i18n="features.hooks.title">Webhooks</h3><p data-i18n="features.hooks.body">Signed callbacks for sent, bounced, and failed terminal events.</p></article>
+            <article class="card"><h3 data-i18n="features.multi.title">Multi-user isolation</h3><p data-i18n="features.multi.body">Domains, tokens, and logs stay scoped to each account.</p></article>
+          </div>
+        </section>
+
+        <section id="integrate" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="integrate.title">Three ways to integrate</h2>
+            <p data-i18n="integrate.sub">Pick the path that matches your stack — configure after sign-in.</p>
+          </div>
+          <div class="integrate-grid">
+            <article class="card">
+              <h3 data-i18n="integrate.api.title">API</h3>
+              <p data-i18n="integrate.api.body">Create an API token, then POST JSON to /api/send with Bearer auth.</p>
+              <a class="text-link" href="#api" data-i18n="integrate.api.link">See API sample →</a>
+            </article>
+            <article class="card">
+              <h3 data-i18n="integrate.smtp.title">SMTP</h3>
+              <p data-i18n="integrate.smtp.body">Point your app at MailHub submission ports with per-user credentials.</p>
+              <a class="text-link" href="#smtp" data-i18n="integrate.smtp.link">See SMTP details →</a>
+            </article>
+            <article class="card">
+              <h3 data-i18n="integrate.hooks.title">Webhooks</h3>
+              <p data-i18n="integrate.hooks.body">Subscribe endpoints to terminal delivery events with HMAC signatures.</p>
+              <a class="text-link" href="#webhooks" data-i18n="integrate.hooks.link">See webhook events →</a>
+            </article>
+          </div>
+        </section>
+
+        <section id="api" class="section">
+          <div class="section-head">
+            <h2 data-i18n="api.title">Send API</h2>
+            <p data-i18n="api.sub">Minimal JSON contract. Hostnames below are placeholders — replace with your MailHub host.</p>
+          </div>
+          <div class="code-card">
+            <div class="code-card-bar">
+              <span>POST /api/send</span>
+              <button type="button" class="copy-btn" data-copy-target="api-curl" data-i18n="common.copy">Copy</button>
+            </div>
+            <pre id="api-curl"><code data-i18n-html="api.curl"></code></pre>
+          </div>
+          <ul class="field-list">
+            <li><code>from</code> — <span data-i18n="api.field.from">sender address on a verified domain</span></li>
+            <li><code>to</code> — <span data-i18n="api.field.to">recipient string or array</span></li>
+            <li><code>subject</code> — <span data-i18n="api.field.subject">message subject</span></li>
+            <li><code>text</code> / <code>html</code> — <span data-i18n="api.field.body">message body</span></li>
+          </ul>
+        </section>
+
+        <section id="smtp" class="section section-alt">
+          <div class="section-head">
+            <h2 data-i18n="smtp.title">SMTP submission</h2>
+            <p data-i18n="smtp.sub">Default listener ports (override in your deployment):</p>
+          </div>
+          <div class="port-grid">
+            <div class="card mono"><strong>25</strong><span>smtp</span></div>
+            <div class="card mono"><strong>587</strong><span>smtp / STARTTLS</span></div>
+            <div class="card mono"><strong>465</strong><span>smtps</span></div>
+            <div class="card mono"><strong>2525</strong><span>smtp</span></div>
+          </div>
+          <p class="note" data-i18n="smtp.note">Create SMTP login credentials in the console after registration. Host example: mail.example.com</p>
+        </section>
+
+        <section id="webhooks" class="section">
+          <div class="section-head">
+            <h2 data-i18n="webhooks.title">Delivery webhooks</h2>
+            <p data-i18n="webhooks.sub">Terminal events only — signed HTTPS callbacks to your systems.</p>
+          </div>
+          <div class="table-wrap">
+            <table>
+              <thead>
+                <tr>
+                  <th data-i18n="webhooks.col.status">Status</th>
+                  <th data-i18n="webhooks.col.type">type</th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr><td><code>sent</code></td><td><code>email.sent</code></td></tr>
+                <tr><td><code>bounced</code></td><td><code>email.bounced</code></td></tr>
+                <tr><td><code>failed</code></td><td><code>email.failed</code></td></tr>
+              </tbody>
+            </table>
+          </div>
+          <p class="note"><span data-i18n="webhooks.sig">Signature header:</span> <code>X-MailHub-Signature: t=&lt;unix&gt;,v1=&lt;hmac&gt;</code></p>
+        </section>
+
+        <section class="section cta-band">
+          <h2 data-i18n="ctaBand.title">Ready to send?</h2>
+          <p data-i18n="ctaBand.sub">Create an account, add a domain, and ship your first message in minutes.</p>
+          <div class="hero-cta">
+            <a class="btn btn-primary btn-lg" href="/register" data-i18n="cta.signup">Get started</a>
+            <a class="btn btn-ghost btn-lg" href="/login" data-i18n="cta.login">Log in</a>
+          </div>
+        </section>
+      </main>
+
+      <footer class="site-footer">
+        <div>
+          <strong>MailHub</strong>
+          <span data-i18n="footer.tag">Self-hosted email delivery control plane</span>
+        </div>
+        <div class="footer-links">
+          <a href="/login" data-i18n="cta.login">Log in</a>
+          <a href="/register" data-i18n="cta.signup">Get started</a>
+        </div>
+      </footer>
+    </div>
+  </body>
+</html>

+ 3 - 2
public/login.html

@@ -4,8 +4,9 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>MailHub Auth</title>
-    <script type="module" crossorigin src="/assets/login-Kt8VraJ2.js"></script>
-    <link rel="modulepreload" crossorigin href="/assets/styles-DVUJSal8.js">
+    <script type="module" crossorigin src="/assets/login-Clt8-De1.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/styles-MGiiWi3S.js">
+    <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-Dezn_h7o.js">
     <link rel="stylesheet" crossorigin href="/assets/styles-_IX60o7a.css">
   </head>
   <body>

+ 182 - 0
src/frontend/landing/i18n.js

@@ -0,0 +1,182 @@
+export const LANDING_LOCALE_KEY = 'mailhub-landing-locale';
+export const supportedLocales = ['zh-CN', 'en-US'];
+
+const messages = {
+  'zh-CN': {
+    'nav.features': '功能',
+    'nav.integrate': '接入',
+    'nav.api': 'API',
+    'nav.smtp': 'SMTP',
+    'nav.webhooks': 'Webhooks',
+    'cta.login': '登录',
+    'cta.signup': '开始使用',
+    'cta.viewApi': '查看 API 示例',
+    'common.copy': '复制',
+    'common.copied': '已复制',
+    'hero.eyebrow': '事务邮件平台',
+    'hero.title': '用 API 与 SMTP 发送可靠的事务邮件',
+    'hero.lede': '自托管 MailHub 提供域名 DNS 引导、DKIM 签名、投递日志与签名 Webhook —— 无需被黑盒 SaaS 锁定。',
+    'hero.curl': `curl -X POST https://mail.example.com/api/send \\
+  -H 'Authorization: Bearer &lt;USER_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@example.com",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'`,
+    'trust.selfHosted': '自托管',
+    'trust.webhooks': '投递 Webhooks',
+    'features.title': '为生产发信而设计',
+    'features.sub': '完成域名认证、发送与可观测所需的关键能力。',
+    'features.dns.title': '域名与 DNS',
+    'features.dns.body': 'SPF / DKIM / DMARC 引导,支持 DNS 服务商一键写入。',
+    'features.smtp.title': 'SMTP Submission',
+    'features.smtp.body': '面向应用的 SMTP 接入,凭据由你掌控。',
+    'features.api.title': 'HTTP 发送 API',
+    'features.api.body': 'Bearer Token + 简洁 JSON 发送接口,适合服务端集成。',
+    'features.logs.title': '投递日志',
+    'features.logs.body': '跟踪入队与 MTA 结果,支持检索发送历史。',
+    'features.hooks.title': 'Webhooks',
+    'features.hooks.body': '对已送达 / 退信 / 失败终态事件进行签名回调。',
+    'features.multi.title': '多用户隔离',
+    'features.multi.body': '域名、Token 与日志按账号隔离。',
+    'integrate.title': '三种接入方式',
+    'integrate.sub': '选择适合你技术栈的路径 —— 注册登录后完成配置。',
+    'integrate.api.title': 'API',
+    'integrate.api.body': '创建 API Token,使用 Bearer 认证 POST JSON 到 /api/send。',
+    'integrate.api.link': '查看 API 示例 →',
+    'integrate.smtp.title': 'SMTP',
+    'integrate.smtp.body': '将应用指向 MailHub Submission 端口,使用独立登录凭据。',
+    'integrate.smtp.link': '查看 SMTP 说明 →',
+    'integrate.hooks.title': 'Webhooks',
+    'integrate.hooks.body': '为终态投递事件订阅端点,校验 HMAC 签名。',
+    'integrate.hooks.link': '查看 Webhook 事件 →',
+    'api.title': '发送 API',
+    'api.sub': '最小 JSON 契约。下列主机名为占位符,请替换为你的 MailHub 地址。',
+    'api.curl': `curl -X POST https://mail.example.com/api/send \\
+  -H 'Authorization: Bearer &lt;USER_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@example.com",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'`,
+    'api.field.from': '已验证域名上的发件地址',
+    'api.field.to': '收件人字符串或数组',
+    'api.field.subject': '邮件主题',
+    'api.field.body': '纯文本或 HTML 正文',
+    'smtp.title': 'SMTP Submission',
+    'smtp.sub': '默认监听端口(部署时可覆盖):',
+    'smtp.note': '注册后在控制台创建 SMTP 登录凭据。主机示例:mail.example.com',
+    'webhooks.title': '投递 Webhooks',
+    'webhooks.sub': '仅终态事件 —— 向你的系统发送签名 HTTPS 回调。',
+    'webhooks.col.status': '状态',
+    'webhooks.col.type': 'type',
+    'webhooks.sig': '签名请求头:',
+    'ctaBand.title': '准备好发送了吗?',
+    'ctaBand.sub': '创建账号、添加域名,几分钟内发出第一封邮件。',
+    'footer.tag': '自托管邮件投递控制平面'
+  },
+  'en-US': {
+    'nav.features': 'Features',
+    'nav.integrate': 'Integrate',
+    'nav.api': 'API',
+    'nav.smtp': 'SMTP',
+    'nav.webhooks': 'Webhooks',
+    'cta.login': 'Log in',
+    'cta.signup': 'Get started',
+    'cta.viewApi': 'View API sample',
+    'common.copy': 'Copy',
+    'common.copied': 'Copied',
+    'hero.eyebrow': 'Transactional email platform',
+    'hero.title': 'Send reliable email via API & SMTP',
+    'hero.lede': 'Self-hosted MailHub gives you domain DNS guidance, DKIM signing, delivery logs, and signed webhooks — without locking you into a black-box SaaS.',
+    'hero.curl': `curl -X POST https://mail.example.com/api/send \\
+  -H 'Authorization: Bearer &lt;USER_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@example.com",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'`,
+    'trust.selfHosted': 'Self-hosted',
+    'trust.webhooks': 'Delivery webhooks',
+    'features.title': 'Built for production senders',
+    'features.sub': 'Everything you need to authenticate domains and observe delivery.',
+    'features.dns.title': 'Domains & DNS',
+    'features.dns.body': 'Guided SPF, DKIM, DMARC, and one-click DNS provider writes.',
+    'features.smtp.title': 'SMTP Submission',
+    'features.smtp.body': 'App-friendly SMTP listeners with credentials you control.',
+    'features.api.title': 'HTTP Send API',
+    'features.api.body': 'Bearer tokens and a simple JSON send endpoint for apps and services.',
+    'features.logs.title': 'Delivery logs',
+    'features.logs.body': 'Track queue and MTA outcomes with searchable send history.',
+    'features.hooks.title': 'Webhooks',
+    'features.hooks.body': 'Signed callbacks for sent, bounced, and failed terminal events.',
+    'features.multi.title': 'Multi-user isolation',
+    'features.multi.body': 'Domains, tokens, and logs stay scoped to each account.',
+    'integrate.title': 'Three ways to integrate',
+    'integrate.sub': 'Pick the path that matches your stack — configure after sign-in.',
+    'integrate.api.title': 'API',
+    'integrate.api.body': 'Create an API token, then POST JSON to /api/send with Bearer auth.',
+    'integrate.api.link': 'See API sample →',
+    'integrate.smtp.title': 'SMTP',
+    'integrate.smtp.body': 'Point your app at MailHub submission ports with per-user credentials.',
+    'integrate.smtp.link': 'See SMTP details →',
+    'integrate.hooks.title': 'Webhooks',
+    'integrate.hooks.body': 'Subscribe endpoints to terminal delivery events with HMAC signatures.',
+    'integrate.hooks.link': 'See webhook events →',
+    'api.title': 'Send API',
+    'api.sub': 'Minimal JSON contract. Hostnames below are placeholders — replace with your MailHub host.',
+    'api.curl': `curl -X POST https://mail.example.com/api/send \\
+  -H 'Authorization: Bearer &lt;USER_API_TOKEN&gt;' \\
+  -H 'Content-Type: application/json' \\
+  -d '{
+    "from": "noreply@example.com",
+    "to": "user@example.com",
+    "subject": "Hello from MailHub",
+    "text": "Signed with DKIM and queued by MailHub."
+  }'`,
+    'api.field.from': 'sender address on a verified domain',
+    'api.field.to': 'recipient string or array',
+    'api.field.subject': 'message subject',
+    'api.field.body': 'plain text or HTML body',
+    'smtp.title': 'SMTP submission',
+    'smtp.sub': 'Default listener ports (override in your deployment):',
+    'smtp.note': 'Create SMTP login credentials in the console after registration. Host example: mail.example.com',
+    'webhooks.title': 'Delivery webhooks',
+    'webhooks.sub': 'Terminal events only — signed HTTPS callbacks to your systems.',
+    'webhooks.col.status': 'Status',
+    'webhooks.col.type': 'type',
+    'webhooks.sig': 'Signature header:',
+    'ctaBand.title': 'Ready to send?',
+    'ctaBand.sub': 'Create an account, add a domain, and ship your first message in minutes.',
+    'footer.tag': 'Self-hosted email delivery control plane'
+  }
+};
+
+export function detectLocale() {
+  try {
+    const stored = localStorage.getItem(LANDING_LOCALE_KEY);
+    if (stored && supportedLocales.includes(stored)) return stored;
+  } catch {
+    /* ignore */
+  }
+  const nav = typeof navigator !== 'undefined' ? navigator.language || '' : '';
+  return nav.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US';
+}
+
+export function t(locale, key) {
+  return messages[locale]?.[key] ?? messages['en-US'][key] ?? key;
+}
+
+export function setStoredLocale(locale) {
+  try {
+    localStorage.setItem(LANDING_LOCALE_KEY, locale);
+  } catch {
+    /* ignore */
+  }
+}

+ 476 - 0
src/frontend/landing/landing.css

@@ -0,0 +1,476 @@
+:root {
+  --mh-primary: #4f46e5;
+  --mh-primary-hover: #4338ca;
+  --mh-primary-soft: #eef2ff;
+  --mh-ink: #0f172a;
+  --mh-text-secondary: #64748b;
+  --mh-text-muted: #94a3b8;
+  --mh-canvas: #f4f6fb;
+  --mh-surface: #ffffff;
+  --mh-border: #e2e8f0;
+  --mh-radius: 14px;
+  --mh-radius-control: 10px;
+  --mh-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), 0 12px 32px rgba(15, 23, 42, 0.06);
+  --mh-code-bg: #0f172a;
+  --mh-max: 1120px;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  scroll-behavior: smooth;
+}
+
+body {
+  margin: 0;
+  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  color: var(--mh-ink);
+  background: var(--mh-canvas);
+  line-height: 1.55;
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.page {
+  min-height: 100vh;
+}
+
+.site-header {
+  position: sticky;
+  top: 0;
+  z-index: 40;
+  display: flex;
+  align-items: center;
+  gap: 20px;
+  padding: 14px 24px;
+  background: rgba(255, 255, 255, 0.92);
+  border-bottom: 1px solid var(--mh-border);
+  backdrop-filter: blur(10px);
+}
+
+.brand {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  font-weight: 700;
+}
+
+.brand-mark {
+  display: inline-grid;
+  place-items: center;
+  width: 36px;
+  height: 36px;
+  border-radius: 10px;
+  color: #fff;
+  font-size: 12px;
+  font-weight: 800;
+  background: linear-gradient(135deg, #6366f1, #8b5cf6);
+}
+
+.nav-links {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 16px;
+  margin-left: 12px;
+  color: var(--mh-text-secondary);
+  font-size: 14px;
+  font-weight: 500;
+}
+
+.nav-links a:hover {
+  color: var(--mh-primary);
+}
+
+.header-actions {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.lang-switch {
+  display: inline-flex;
+  padding: 3px;
+  border: 1px solid var(--mh-border);
+  border-radius: 999px;
+  background: #f8fafc;
+}
+
+.lang-btn {
+  border: 0;
+  background: transparent;
+  color: var(--mh-text-secondary);
+  font: inherit;
+  font-size: 12px;
+  font-weight: 600;
+  padding: 6px 10px;
+  border-radius: 999px;
+  cursor: pointer;
+}
+
+.lang-btn.is-active {
+  background: var(--mh-surface);
+  color: var(--mh-ink);
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
+}
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  min-height: 36px;
+  padding: 0 14px;
+  border-radius: var(--mh-radius-control);
+  font-size: 14px;
+  font-weight: 600;
+  border: 1px solid transparent;
+  cursor: pointer;
+}
+
+.btn-primary {
+  background: var(--mh-primary);
+  color: #fff;
+}
+
+.btn-primary:hover {
+  background: var(--mh-primary-hover);
+}
+
+.btn-ghost {
+  background: var(--mh-surface);
+  border-color: var(--mh-border);
+  color: var(--mh-ink);
+}
+
+.btn-ghost:hover {
+  border-color: #c7d2fe;
+  background: var(--mh-primary-soft);
+}
+
+.btn-lg {
+  min-height: 44px;
+  padding: 0 18px;
+}
+
+main {
+  max-width: var(--mh-max);
+  margin: 0 auto;
+  padding: 28px 24px 64px;
+}
+
+.hero {
+  display: grid;
+  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
+  gap: 32px;
+  align-items: center;
+  padding: 28px 0 48px;
+}
+
+.eyebrow {
+  margin: 0 0 10px;
+  color: var(--mh-primary);
+  font-size: 12px;
+  font-weight: 700;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+}
+
+.hero h1 {
+  margin: 0;
+  font-size: clamp(32px, 5vw, 48px);
+  line-height: 1.1;
+  letter-spacing: -0.03em;
+}
+
+.lede {
+  margin: 16px 0 0;
+  color: var(--mh-text-secondary);
+  font-size: 17px;
+  max-width: 36em;
+}
+
+.hero-cta {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 24px;
+}
+
+.trust-strip {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  list-style: none;
+  padding: 0;
+  margin: 28px 0 0;
+}
+
+.trust-strip li {
+  padding: 6px 10px;
+  border-radius: 999px;
+  background: var(--mh-surface);
+  border: 1px solid var(--mh-border);
+  color: var(--mh-text-secondary);
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.code-card {
+  background: var(--mh-code-bg);
+  color: #e2e8f0;
+  border-radius: 16px;
+  overflow: hidden;
+  box-shadow: var(--mh-shadow);
+  border: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.code-card-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px 14px;
+  background: rgba(255, 255, 255, 0.04);
+  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.code-card pre {
+  margin: 0;
+  padding: 16px 18px 20px;
+  overflow-x: auto;
+  font-size: 12.5px;
+  line-height: 1.55;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.copy-btn {
+  border: 1px solid rgba(255, 255, 255, 0.12);
+  background: rgba(255, 255, 255, 0.06);
+  color: #e2e8f0;
+  border-radius: 8px;
+  padding: 4px 10px;
+  font: inherit;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.copy-btn:hover {
+  background: rgba(255, 255, 255, 0.12);
+}
+
+.section {
+  padding: 48px 0;
+}
+
+.section-alt {
+  margin: 0 -24px;
+  padding-left: 24px;
+  padding-right: 24px;
+  background: linear-gradient(180deg, rgba(238, 242, 255, 0.55), transparent);
+}
+
+.section-head {
+  margin-bottom: 24px;
+  max-width: 40em;
+}
+
+.section-head h2 {
+  margin: 0;
+  font-size: 28px;
+  letter-spacing: -0.02em;
+}
+
+.section-head p {
+  margin: 8px 0 0;
+  color: var(--mh-text-secondary);
+}
+
+.feature-grid,
+.integrate-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 16px;
+}
+
+.card {
+  background: var(--mh-surface);
+  border: 1px solid var(--mh-border);
+  border-radius: var(--mh-radius);
+  padding: 18px;
+  box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
+}
+
+.card h3 {
+  margin: 0 0 8px;
+  font-size: 16px;
+}
+
+.card p {
+  margin: 0;
+  color: var(--mh-text-secondary);
+  font-size: 14px;
+}
+
+.text-link {
+  display: inline-block;
+  margin-top: 14px;
+  color: var(--mh-primary);
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.field-list {
+  margin: 18px 0 0;
+  padding-left: 18px;
+  color: var(--mh-text-secondary);
+}
+
+.field-list code {
+  color: var(--mh-ink);
+  font-weight: 600;
+}
+
+.port-grid {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 12px;
+}
+
+.port-grid .card {
+  text-align: center;
+}
+
+.port-grid strong {
+  display: block;
+  font-size: 22px;
+  letter-spacing: -0.02em;
+}
+
+.port-grid span {
+  color: var(--mh-text-secondary);
+  font-size: 13px;
+}
+
+.mono {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+}
+
+.note {
+  margin: 16px 0 0;
+  color: var(--mh-text-secondary);
+  font-size: 14px;
+}
+
+.table-wrap {
+  overflow-x: auto;
+  background: var(--mh-surface);
+  border: 1px solid var(--mh-border);
+  border-radius: var(--mh-radius);
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 14px;
+}
+
+th,
+td {
+  text-align: left;
+  padding: 12px 16px;
+  border-bottom: 1px solid var(--mh-border);
+}
+
+th {
+  color: var(--mh-text-secondary);
+  font-weight: 600;
+}
+
+tr:last-child td {
+  border-bottom: 0;
+}
+
+.cta-band {
+  text-align: center;
+  background: linear-gradient(135deg, #eef2ff, #f8fafc 55%, #fff);
+  border: 1px solid var(--mh-border);
+  border-radius: 20px;
+  padding: 40px 24px;
+  margin-top: 24px;
+}
+
+.cta-band h2 {
+  margin: 0;
+  font-size: 28px;
+}
+
+.cta-band p {
+  margin: 10px auto 0;
+  max-width: 32em;
+  color: var(--mh-text-secondary);
+}
+
+.cta-band .hero-cta {
+  justify-content: center;
+}
+
+.site-footer {
+  max-width: var(--mh-max);
+  margin: 0 auto;
+  padding: 28px 24px 40px;
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  flex-wrap: wrap;
+  color: var(--mh-text-secondary);
+  font-size: 14px;
+  border-top: 1px solid var(--mh-border);
+}
+
+.site-footer strong {
+  display: block;
+  color: var(--mh-ink);
+  margin-bottom: 4px;
+}
+
+.footer-links {
+  display: flex;
+  gap: 16px;
+  font-weight: 600;
+}
+
+.footer-links a:hover {
+  color: var(--mh-primary);
+}
+
+@media (max-width: 900px) {
+  .hero,
+  .feature-grid,
+  .integrate-grid,
+  .port-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .site-header {
+    flex-wrap: wrap;
+  }
+
+  .nav-links {
+    order: 3;
+    width: 100%;
+    margin-left: 0;
+  }
+
+  .header-actions {
+    margin-left: auto;
+  }
+}

+ 63 - 0
src/frontend/landing/main.ts

@@ -0,0 +1,63 @@
+import { detectLocale, setStoredLocale, supportedLocales, t } from './i18n.js';
+import './landing.css';
+
+let locale = detectLocale();
+
+function applyLocale(next: string | null | undefined) {
+  const resolved = next && supportedLocales.includes(next) ? next : 'en-US';
+  locale = resolved;
+  setStoredLocale(resolved);
+  document.documentElement.lang = resolved === 'zh-CN' ? 'zh-CN' : 'en';
+
+  document.querySelectorAll<HTMLElement>('[data-i18n]').forEach((el) => {
+    const key = el.dataset.i18n;
+    if (!key) return;
+    el.textContent = t(resolved, key);
+  });
+
+  document.querySelectorAll<HTMLElement>('[data-i18n-html]').forEach((el) => {
+    const key = el.dataset.i18nHtml;
+    if (!key) return;
+    el.innerHTML = t(resolved, key);
+  });
+
+  document.querySelectorAll<HTMLButtonElement>('.lang-btn').forEach((btn) => {
+    btn.classList.toggle('is-active', btn.dataset.locale === resolved);
+  });
+
+  document.title = resolved === 'zh-CN' ? 'MailHub · 事务邮件平台' : 'MailHub · Transactional Email';
+}
+
+function setupLocaleSwitch() {
+  document.querySelectorAll<HTMLButtonElement>('.lang-btn').forEach((btn) => {
+    btn.addEventListener('click', () => {
+      const next = btn.dataset.locale || 'en-US';
+      applyLocale(next);
+    });
+  });
+}
+
+function setupCopyButtons() {
+  document.querySelectorAll<HTMLButtonElement>('.copy-btn').forEach((btn) => {
+    btn.addEventListener('click', async () => {
+      const targetId = btn.dataset.copyTarget;
+      const node = targetId ? document.getElementById(targetId) : null;
+      const text = node?.textContent || '';
+      if (!text) return;
+      try {
+        await navigator.clipboard.writeText(text);
+        const original = btn.textContent;
+        btn.textContent = t(locale, 'common.copied');
+        window.setTimeout(() => {
+          btn.textContent = original || t(locale, 'common.copy');
+        }, 1400);
+      } catch {
+        /* ignore */
+      }
+    });
+  });
+}
+
+applyLocale(locale);
+setupLocaleSwitch();
+setupCopyButtons();

+ 25 - 6
src/server.js

@@ -192,6 +192,19 @@ const server = http.createServer(async (req, res) => {
     if (req.method === 'POST' && url.pathname === '/api/auth/reset-password') return await handleResetPassword(req, res);
 
     const user = getRequestUser(req, url.pathname);
+
+    if (url.pathname === '/' || url.pathname === '/index.html' || url.pathname === '/landing.html') {
+      if (user && (url.pathname === '/' || url.pathname === '/index.html')) {
+        return sendStaticFile(res, path.join(__dirname, '..', 'public', 'index.html'), { noStore: true });
+      }
+      if (!user && (url.pathname === '/' || url.pathname === '/landing.html')) {
+        return sendStaticFile(res, path.join(__dirname, '..', 'public', 'landing.html'), { noStore: true });
+      }
+      if (user && url.pathname === '/landing.html') {
+        return redirect(res, '/');
+      }
+    }
+
     if (isLoginAsset(url.pathname)) {
       if ((url.pathname === '/login' || url.pathname === '/register') && user) return redirect(res, '/');
       return await serveStatic(req, res, url);
@@ -1350,12 +1363,13 @@ async function serveStatic(req, res, url) {
   const pathname = decodeURIComponent(resolveStaticPathname(url.pathname));
   const filePath = path.normalize(path.join(publicDir, pathname));
   if (!filePath.startsWith(publicDir) || !existsSync(filePath) || statSync(filePath).isDirectory()) {
-    return sendStaticFile(res, path.join(publicDir, 'index.html'));
+    return sendStaticFile(res, path.join(publicDir, 'index.html'), { noStore: true });
   }
-  return sendStaticFile(res, filePath);
+  const noStore = path.extname(filePath) === '.html';
+  return sendStaticFile(res, filePath, { noStore });
 }
 
-async function sendStaticFile(res, filePath) {
+async function sendStaticFile(res, filePath, { noStore = false } = {}) {
   const ext = path.extname(filePath);
   const contentType = {
     '.html': 'text/html; charset=utf-8',
@@ -1364,7 +1378,11 @@ async function sendStaticFile(res, filePath) {
     '.json': 'application/json; charset=utf-8',
     '.svg': 'image/svg+xml'
   }[ext] || 'application/octet-stream';
-  res.writeHead(200, { 'Content-Type': contentType });
+  const headers = { 'Content-Type': contentType };
+  if (noStore || ext === '.html') {
+    headers['Cache-Control'] = 'private, no-store';
+  }
+  res.writeHead(200, headers);
   res.end(await readFile(filePath));
 }
 
@@ -1524,12 +1542,13 @@ function isLoginAsset(pathname) {
       '/reset-password',
       '/login.html',
       '/login.css',
-      '/login.js'
+      '/login.js',
+      '/landing.html'
     ].includes(pathname);
 }
 
 function resolveStaticPathname(pathname) {
-  if (pathname === '/') return '/index.html';
+  if (pathname === '/') return '/landing.html';
   if (['/login', '/register', '/forgot-password', '/resend-verification', '/reset-password'].includes(pathname)) return '/login.html';
   return pathname;
 }

+ 129 - 0
test/server-landing.test.js

@@ -0,0 +1,129 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+import net from 'node:net';
+
+test('anonymous root serves landing page with no-store cache header', async () => {
+  ensureLandingArtifact();
+  const port = await freePort();
+  const child = spawnServer(port);
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const response = await fetch(`http://127.0.0.1:${port}/`);
+    assert.equal(response.status, 200);
+    assert.match(response.headers.get('cache-control') || '', /no-store/i);
+    const html = await response.text();
+    assert.match(html, /MailHub/i);
+    assert.match(html, /data-i18n|hero|Get started|开始使用|landing/i);
+    assert.doesNotMatch(html, /id="root"/);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('authenticated root serves admin app shell', async () => {
+  ensureLandingArtifact();
+  const port = await freePort();
+  const child = spawnServer(port);
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const baseUrl = `http://127.0.0.1:${port}`;
+    const login = await fetch(`${baseUrl}/api/login`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ username: 'admin', password: 'password123' })
+    });
+    assert.equal(login.status, 200);
+    const cookie = login.headers.get('set-cookie')?.split(';')[0] || '';
+    assert.ok(cookie);
+
+    const response = await fetch(`${baseUrl}/`, { headers: { Cookie: cookie } });
+    assert.equal(response.status, 200);
+    assert.match(response.headers.get('cache-control') || '', /no-store/i);
+    const html = await response.text();
+    assert.match(html, /id="root"/);
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+test('landing.html is publicly reachable without auth', async () => {
+  ensureLandingArtifact();
+  const port = await freePort();
+  const child = spawnServer(port);
+  try {
+    await waitForOutput(child, 'MailHub listening');
+    const response = await fetch(`http://127.0.0.1:${port}/landing.html`);
+    assert.equal(response.status, 200);
+    assert.notEqual(response.headers.get('location'), '/login');
+  } finally {
+    child.kill('SIGTERM');
+    await waitForExit(child, 1000);
+  }
+});
+
+function ensureLandingArtifact() {
+  const landingPath = path.join(process.cwd(), 'public', 'landing.html');
+  if (existsSync(landingPath)) return;
+  writeFileSync(landingPath, '<!doctype html><html><body><h1>MailHub Landing</h1><div data-i18n="hero.title">Get started</div></body></html>');
+}
+
+function spawnServer(port) {
+  return spawn(process.execPath, ['src/server.js'], {
+    cwd: process.cwd(),
+    env: {
+      ...process.env,
+      PORT: String(port),
+      DATA_DIR: mkdtempSync(path.join(tmpdir(), 'mailhub-landing-test-')),
+      ADMIN_PASSWORD: 'password123',
+      SUBMISSION_ENABLED: 'false',
+      WEBHOOK_WORKER_ENABLED: '0'
+    },
+    stdio: ['ignore', 'pipe', 'pipe']
+  });
+}
+
+function freePort() {
+  return new Promise((resolve, reject) => {
+    const server = net.createServer();
+    server.listen(0, '127.0.0.1', () => {
+      const { port } = server.address();
+      server.close((error) => (error ? reject(error) : resolve(port)));
+    });
+    server.on('error', reject);
+  });
+}
+
+function waitForOutput(child, text, timeoutMs = 8000) {
+  return new Promise((resolve, reject) => {
+    let buffer = '';
+    const timer = setTimeout(() => reject(new Error(`Timed out waiting for: ${text}\n${buffer}`)), timeoutMs);
+    const onData = (chunk) => {
+      buffer += String(chunk);
+      if (buffer.includes(text)) {
+        clearTimeout(timer);
+        child.stdout?.off('data', onData);
+        child.stderr?.off('data', onData);
+        resolve();
+      }
+    };
+    child.stdout?.on('data', onData);
+    child.stderr?.on('data', onData);
+  });
+}
+
+function waitForExit(child, timeoutMs) {
+  return new Promise((resolve) => {
+    if (child.exitCode != null) return resolve(true);
+    const timer = setTimeout(() => resolve(false), timeoutMs);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve(true);
+    });
+  });
+}

+ 2 - 1
vite.config.ts

@@ -22,7 +22,8 @@ export default defineConfig({
     rollupOptions: {
       input: {
         index: resolve(__dirname, 'index.html'),
-        login: resolve(__dirname, 'login.html')
+        login: resolve(__dirname, 'login.html'),
+        landing: resolve(__dirname, 'landing.html')
       }
     }
   },

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov