Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64ef3893d3 | ||
|
|
8fce86559e |
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
"""
|
||||
from app.api.accounts import router as accounts_router
|
||||
from app.api.actions import router as actions_router
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.avatar import router as avatar_router
|
||||
from app.api.compose import router as compose_router
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
@@ -40,6 +41,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
saved_searches_router,
|
||||
avatar_router,
|
||||
plugins_router,
|
||||
admin_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Instance administration (SPEC §6.1).
|
||||
|
||||
The backend seam for the admin panel. Today it carries only the registration
|
||||
gate — the first account on a fresh install becomes the administrator and closes
|
||||
signup behind itself, and this is how that gets reopened. Further instance-wide
|
||||
options land here as the panel grows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentAdmin, require_csrf
|
||||
from app.identity.schemas import AdminSettingsIn, AdminSettingsOut
|
||||
from app.identity.service import app_settings
|
||||
from app.models import AppSettings
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _out(row: AppSettings) -> AdminSettingsOut:
|
||||
return AdminSettingsOut(
|
||||
registration_open=row.registration_open,
|
||||
default_quota_bytes=row.default_quota_bytes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=AdminSettingsOut)
|
||||
async def read_settings(admin: CurrentAdmin, db: DbSession) -> AdminSettingsOut:
|
||||
return _out(await app_settings(db))
|
||||
|
||||
|
||||
@router.put("/settings", response_model=AdminSettingsOut, dependencies=[Depends(require_csrf)])
|
||||
async def write_settings(
|
||||
body: AdminSettingsIn, admin: CurrentAdmin, db: DbSession
|
||||
) -> AdminSettingsOut:
|
||||
"""Merge the supplied fields into the settings row.
|
||||
|
||||
Changing ``default_quota_bytes`` affects users created afterwards only —
|
||||
nothing here rewrites an existing ``users.quota_bytes``.
|
||||
"""
|
||||
row = await app_settings(db, for_update=True)
|
||||
if body.registration_open is not None:
|
||||
row.registration_open = body.registration_open
|
||||
if body.default_quota_bytes is not None:
|
||||
row.default_quota_bytes = body.default_quota_bytes
|
||||
row.updated_at = dt.datetime.now(dt.UTC)
|
||||
await db.commit()
|
||||
return _out(row)
|
||||
@@ -46,5 +46,13 @@ async def require_csrf(request: Request) -> None:
|
||||
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
|
||||
|
||||
|
||||
async def current_admin(user: Annotated[User, Depends(current_user)]) -> User:
|
||||
"""Load the authenticated user and require the administrator flag."""
|
||||
if not user.is_admin:
|
||||
raise_api_error(403, "not_admin", "administrator privileges required")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(current_user)]
|
||||
CurrentAdmin = Annotated[User, Depends(current_admin)]
|
||||
CurrentSession = Annotated[Session, Depends(current_session)]
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.identity.schemas import (
|
||||
PasswordResetIn,
|
||||
PasswordResetRequestIn,
|
||||
RegisterRequest,
|
||||
RegistrationStatusOut,
|
||||
SessionOut,
|
||||
TotpDisableRequest,
|
||||
TotpEnableRequest,
|
||||
@@ -41,6 +42,7 @@ from app.identity.service import (
|
||||
TotpRequired,
|
||||
authenticate,
|
||||
register_user,
|
||||
registration_status,
|
||||
resolve_handle,
|
||||
)
|
||||
from app.identity.sessions import (
|
||||
@@ -77,9 +79,20 @@ def _user_out(user: User) -> UserOut:
|
||||
recovery_email=user.recovery_email,
|
||||
settings=user.settings,
|
||||
totp_enabled=user.totp_secret is not None,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/registration", response_model=RegistrationStatusOut)
|
||||
async def registration(db: DbSession) -> RegistrationStatusOut:
|
||||
"""Whether signup is open. Unauthenticated: the signup page needs it to render."""
|
||||
status = await registration_status(db)
|
||||
return RegistrationStatusOut(open=status.open, bootstrap=status.bootstrap)
|
||||
|
||||
|
||||
_REGISTRATION_ERROR_STATUS = {"handle_taken": 409, "registration_closed": 403}
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
async def register(
|
||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||
@@ -92,8 +105,7 @@ async def register(
|
||||
recovery_email=body.recovery_email,
|
||||
)
|
||||
except RegistrationError as exc:
|
||||
status = 409 if exc.code == "handle_taken" else 400
|
||||
raise_api_error(status, exc.code, exc.message)
|
||||
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
||||
await db.commit()
|
||||
await _establish_session(request, response, user)
|
||||
return _user_out(user)
|
||||
|
||||
@@ -55,6 +55,28 @@ class UserOut(BaseModel):
|
||||
recovery_email: str | None
|
||||
settings: dict[str, object]
|
||||
totp_enabled: bool = False
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class RegistrationStatusOut(BaseModel):
|
||||
"""Public, unauthenticated: what the signup page should show."""
|
||||
|
||||
open: bool
|
||||
# True when the instance has no users yet, so signup creates the administrator.
|
||||
bootstrap: bool
|
||||
|
||||
|
||||
class AdminSettingsOut(BaseModel):
|
||||
registration_open: bool
|
||||
default_quota_bytes: int
|
||||
|
||||
|
||||
class AdminSettingsIn(BaseModel):
|
||||
"""Merge semantics: omitted fields are left as they are."""
|
||||
|
||||
registration_open: bool | None = None
|
||||
# Applies to users created after the change; existing mailboxes keep theirs.
|
||||
default_quota_bytes: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
|
||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
|
||||
from app.identity.passwords import hash_password, verify_password
|
||||
from app.models import User
|
||||
from app.models import AppSettings, User
|
||||
from app.redis_client import get_redis
|
||||
|
||||
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
|
||||
@@ -28,6 +29,15 @@ class RegistrationError(Exception):
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegistrationStatus:
|
||||
"""What the signup page may offer right now."""
|
||||
|
||||
open: bool
|
||||
# True when the instance has no users: the next signup creates the administrator.
|
||||
bootstrap: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimited(Exception):
|
||||
"""Raised when the login rate limit is exceeded."""
|
||||
@@ -51,19 +61,64 @@ def resolve_handle(handle_input: str) -> str:
|
||||
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
|
||||
|
||||
|
||||
async def app_settings(db: AsyncSession, *, for_update: bool = False) -> AppSettings:
|
||||
"""Load the singleton settings row, creating it if an old database lacks one.
|
||||
|
||||
``for_update`` takes a row lock, which is what serialises concurrent signups.
|
||||
A row conjured here defaults to *closed*: the safe answer when we cannot tell
|
||||
what the operator intended.
|
||||
"""
|
||||
stmt = select(AppSettings).where(AppSettings.id == 1)
|
||||
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
|
||||
if row is None:
|
||||
await db.execute(
|
||||
pg_insert(AppSettings).values(id=1, registration_open=False).on_conflict_do_nothing()
|
||||
)
|
||||
row = await db.scalar(stmt.with_for_update() if for_update else stmt)
|
||||
assert row is not None # just inserted in this transaction
|
||||
return row
|
||||
|
||||
|
||||
async def _has_users(db: AsyncSession) -> bool:
|
||||
return await db.scalar(select(User.id).limit(1)) is not None
|
||||
|
||||
|
||||
async def registration_status(db: AsyncSession) -> RegistrationStatus:
|
||||
"""Whether signup is currently accepted, and whether it would bootstrap an admin."""
|
||||
bootstrap = not await _has_users(db)
|
||||
settings = await app_settings(db)
|
||||
return RegistrationStatus(open=bootstrap or settings.registration_open, bootstrap=bootstrap)
|
||||
|
||||
|
||||
async def register_user(
|
||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||
) -> User:
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
|
||||
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.
|
||||
|
||||
The first user on an empty instance is always allowed and becomes the
|
||||
administrator, which closes registration behind them; after that it takes an
|
||||
admin reopening it. The settings row is locked across the whole decision so
|
||||
two simultaneous first signups cannot both see an empty table and both claim
|
||||
administrator.
|
||||
"""
|
||||
try:
|
||||
handle = full_handle(localpart)
|
||||
except InvalidHandle as exc:
|
||||
raise RegistrationError(exc.code, exc.message) from exc
|
||||
|
||||
settings = await app_settings(db, for_update=True)
|
||||
bootstrap = not await _has_users(db)
|
||||
if not bootstrap and not settings.registration_open:
|
||||
raise RegistrationError("registration_closed", "registration is closed on this server")
|
||||
|
||||
user = User(
|
||||
handle=handle,
|
||||
password_hash=hash_password(password),
|
||||
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
|
||||
is_admin=bootstrap,
|
||||
# Snapshotted at creation: later changes to the instance default leave
|
||||
# this user's quota alone.
|
||||
quota_bytes=settings.default_quota_bytes,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
@@ -71,6 +126,8 @@ async def register_user(
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise RegistrationError("handle_taken", "that handle is already registered") from exc
|
||||
if bootstrap:
|
||||
settings.registration_open = False
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -45,6 +46,32 @@ def _now() -> Mapped[dt.datetime]:
|
||||
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
|
||||
|
||||
|
||||
class AppSettings(Base):
|
||||
"""Instance-wide settings, exactly one row (id = 1).
|
||||
|
||||
Distinct from :func:`app.config.get_settings`, which is deployment environment
|
||||
read at boot; these are options an administrator changes at runtime from the
|
||||
admin panel. Locking this row serialises the registration decision below.
|
||||
"""
|
||||
|
||||
__tablename__ = "app_settings"
|
||||
__table_args__ = (
|
||||
CheckConstraint("id = 1", name="ck_app_settings_singleton"),
|
||||
CheckConstraint("default_quota_bytes > 0", name="ck_app_settings_quota_positive"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
|
||||
# Whether POST /auth/register accepts new users. Closed automatically once the
|
||||
# first account (the administrator) is created; an admin reopens it later.
|
||||
registration_open: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
# Quota handed to the NEXT user created. Existing users keep whatever
|
||||
# users.quota_bytes already says — changing this never resizes a mailbox.
|
||||
default_quota_bytes: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("5368709120")
|
||||
)
|
||||
updated_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -61,6 +88,9 @@ class User(Base):
|
||||
quota_bytes: Mapped[int] = mapped_column(
|
||||
BigInteger, nullable=False, server_default=text("5368709120")
|
||||
)
|
||||
# The first account created on a fresh install bootstraps as administrator;
|
||||
# the admin panel can grant it to others later.
|
||||
is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false"))
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""bootstrap administrator + registration gate
|
||||
|
||||
The first account created on a fresh install becomes the administrator and
|
||||
registration closes behind it, so an exposed port cannot be claimed by a
|
||||
stranger. Existing installs keep their oldest account as administrator and start
|
||||
closed; an admin reopens registration from the admin panel.
|
||||
|
||||
Revision ID: 0016
|
||||
Revises: 0015
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0016"
|
||||
down_revision: str | None = "0015"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE users ADD COLUMN is_admin boolean NOT NULL DEFAULT false")
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE app_settings (
|
||||
id integer PRIMARY KEY DEFAULT 1,
|
||||
registration_open boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ck_app_settings_singleton CHECK (id = 1)
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Existing deployment: the oldest account is the one that set the instance up,
|
||||
# so it becomes the administrator. Ties on created_at break by id.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users SET is_admin = true
|
||||
WHERE id = (SELECT id FROM users ORDER BY created_at, id LIMIT 1)
|
||||
"""
|
||||
)
|
||||
# Fresh install (no users yet) starts open so the first signup can happen;
|
||||
# anything else starts closed.
|
||||
op.execute(
|
||||
"INSERT INTO app_settings (id, registration_open) "
|
||||
"VALUES (1, NOT EXISTS (SELECT 1 FROM users))"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS app_settings")
|
||||
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS is_admin")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""instance-wide default quota for new users
|
||||
|
||||
The per-user ``users.quota_bytes`` stays authoritative for accounts that already
|
||||
exist — changing the default must never resize anyone's mailbox retroactively.
|
||||
This is only the value handed to the next user created.
|
||||
|
||||
Revision ID: 0017
|
||||
Revises: 0016
|
||||
Create Date: 2026-09-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0017"
|
||||
down_revision: str | None = "0016"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# 5 GiB — the same figure users.quota_bytes has defaulted to since 0001.
|
||||
_DEFAULT = 5 * 1024**3
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"ALTER TABLE app_settings ADD COLUMN default_quota_bytes bigint NOT NULL "
|
||||
f"DEFAULT {_DEFAULT}"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_quota_positive "
|
||||
"CHECK (default_quota_bytes > 0)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_quota_positive")
|
||||
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS default_quota_bytes")
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Registration policy: first user bootstraps as administrator, then signup closes.
|
||||
|
||||
The bootstrap cases run against the rollback-per-test ``db_session`` fixture, so
|
||||
they can empty the users table to simulate a fresh install without touching the
|
||||
dev database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.service import (
|
||||
RegistrationError,
|
||||
app_settings,
|
||||
register_user,
|
||||
registration_status,
|
||||
)
|
||||
from app.identity.sessions import create_session
|
||||
from app.main import create_app
|
||||
from app.models import AppSettings, User
|
||||
|
||||
CSRF = "csrf-token"
|
||||
PASSWORD = "hunter2pass"
|
||||
|
||||
|
||||
def _localpart() -> str:
|
||||
return f"reg{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
# --- service level: the bootstrap decision ----------------------------------
|
||||
|
||||
|
||||
async def test_first_user_becomes_admin_and_closes_registration(db_session: AsyncSession) -> None:
|
||||
"""On an empty instance the first signup is allowed, is admin, and shuts the door."""
|
||||
await db_session.execute(delete(User))
|
||||
settings = await app_settings(db_session)
|
||||
settings.registration_open = False # even closed, an empty instance must bootstrap
|
||||
await db_session.flush()
|
||||
|
||||
status = await registration_status(db_session)
|
||||
assert (status.open, status.bootstrap) == (True, True)
|
||||
|
||||
first = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert first.is_admin is True
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
assert settings.registration_open is False
|
||||
status = await registration_status(db_session)
|
||||
assert (status.open, status.bootstrap) == (False, False)
|
||||
|
||||
with pytest.raises(RegistrationError) as exc:
|
||||
await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert exc.value.code == "registration_closed"
|
||||
|
||||
|
||||
async def test_reopened_registration_admits_plain_users(db_session: AsyncSession) -> None:
|
||||
"""A second account created while open is an ordinary user, not an admin."""
|
||||
await db_session.execute(delete(User))
|
||||
await register_user(db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None)
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
settings.registration_open = True
|
||||
await db_session.flush()
|
||||
|
||||
second = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert second.is_admin is False
|
||||
# Reopening is a deliberate admin act; it stays open until they close it.
|
||||
assert (await app_settings(db_session)).registration_open is True
|
||||
|
||||
|
||||
async def test_missing_settings_row_defaults_to_closed(db_session: AsyncSession) -> None:
|
||||
"""A database predating the settings row must not fall open."""
|
||||
await db_session.execute(delete(AppSettings))
|
||||
assert (await app_settings(db_session)).registration_open is False
|
||||
|
||||
|
||||
# --- API level ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api() -> AsyncIterator[tuple[httpx.AsyncClient, AsyncSession, uuid.UUID]]:
|
||||
"""A logged-in non-admin user plus a restored-on-exit app_settings row."""
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
session = sm()
|
||||
before = await app_settings(session)
|
||||
was_open, was_quota = before.registration_open, before.default_quota_bytes
|
||||
user = User(handle=f"{_localpart()}@test.local", password_hash=hash_password(PASSWORD))
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield client, session, user.id
|
||||
finally:
|
||||
await client.aclose()
|
||||
await session.execute(delete(User).where(User.id == user.id))
|
||||
await session.execute(
|
||||
update(AppSettings).values(registration_open=was_open, default_quota_bytes=was_quota)
|
||||
)
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _set_open(session: AsyncSession, value: bool) -> None:
|
||||
await session.execute(update(AppSettings).values(registration_open=value))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_register_is_refused_while_closed(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, _ = api
|
||||
await _set_open(session, False)
|
||||
|
||||
status = await client.get("/api/auth/registration")
|
||||
assert status.status_code == 200
|
||||
assert status.json() == {"open": False, "bootstrap": False}
|
||||
|
||||
refused = await client.post(
|
||||
"/api/auth/register", json={"localpart": _localpart(), "password": PASSWORD}
|
||||
)
|
||||
assert refused.status_code == 403
|
||||
assert refused.json()["error"]["code"] == "registration_closed"
|
||||
|
||||
|
||||
async def test_admin_settings_endpoint_requires_admin(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
denied = await client.get("/api/admin/settings")
|
||||
assert denied.status_code == 403
|
||||
assert denied.json()["error"]["code"] == "not_admin"
|
||||
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
|
||||
assert (await client.get("/api/admin/settings")).status_code == 200
|
||||
opened = await client.put(
|
||||
"/api/admin/settings", json={"registration_open": True}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert opened.status_code == 200
|
||||
assert opened.json()["registration_open"] is True
|
||||
assert (await client.get("/api/auth/registration")).json()["open"] is True
|
||||
|
||||
|
||||
async def test_me_exposes_admin_flag(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
assert (await client.get("/api/auth/me")).json()["is_admin"] is False
|
||||
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
assert (await client.get("/api/auth/me")).json()["is_admin"] is True
|
||||
|
||||
|
||||
async def test_settings_row_is_a_singleton(db_session: AsyncSession) -> None:
|
||||
"""The CHECK constraint is what stops a second row drifting into existence."""
|
||||
rows = (await db_session.execute(select(AppSettings.id))).scalars().all()
|
||||
assert rows == [1]
|
||||
|
||||
|
||||
# --- default quota -----------------------------------------------------------
|
||||
|
||||
_GIB = 1024**3
|
||||
|
||||
|
||||
async def test_default_quota_applies_only_to_later_users(db_session: AsyncSession) -> None:
|
||||
"""Raising the default must not resize a mailbox that already exists."""
|
||||
await db_session.execute(delete(User))
|
||||
settings = await app_settings(db_session)
|
||||
settings.default_quota_bytes = 5 * _GIB
|
||||
await db_session.flush()
|
||||
|
||||
first = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert first.quota_bytes == 5 * _GIB
|
||||
|
||||
settings = await app_settings(db_session)
|
||||
settings.default_quota_bytes = 20 * _GIB
|
||||
settings.registration_open = True
|
||||
await db_session.flush()
|
||||
|
||||
second = await register_user(
|
||||
db_session, localpart=_localpart(), password=PASSWORD, recovery_email=None
|
||||
)
|
||||
assert second.quota_bytes == 20 * _GIB
|
||||
# The whole point: the earlier account is untouched.
|
||||
await db_session.refresh(first)
|
||||
assert first.quota_bytes == 5 * _GIB
|
||||
|
||||
|
||||
async def test_admin_can_set_default_quota(
|
||||
api: tuple[httpx.AsyncClient, AsyncSession, uuid.UUID],
|
||||
) -> None:
|
||||
client, session, user_id = api
|
||||
await session.execute(update(User).where(User.id == user_id).values(is_admin=True))
|
||||
await session.commit()
|
||||
# Establish the value the merge must preserve rather than assuming the
|
||||
# ambient one — the dev database is shared and its state moves around.
|
||||
await _set_open(session, True)
|
||||
|
||||
saved = await client.put(
|
||||
"/api/admin/settings",
|
||||
json={"default_quota_bytes": 10 * _GIB},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert saved.json()["default_quota_bytes"] == 10 * _GIB
|
||||
# A merge, not a replace: the field we left out kept its value.
|
||||
assert saved.json()["registration_open"] is True
|
||||
|
||||
rejected = await client.put(
|
||||
"/api/admin/settings", json={"default_quota_bytes": 0}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert rejected.status_code == 422
|
||||
+52
-6
@@ -2,6 +2,7 @@
|
||||
# Webmail operator control script. One command per thing you might need to do.
|
||||
#
|
||||
# ./deploy/webmail.sh init create data dirs + a filled-in .env.prod
|
||||
# ./deploy/webmail.sh pull [tag] fetch prebuilt images from your registry
|
||||
# ./deploy/webmail.sh up [--meili] start (builds images if missing)
|
||||
# ./deploy/webmail.sh down stop (data is untouched)
|
||||
# ./deploy/webmail.sh status what is running, and which version
|
||||
@@ -33,14 +34,34 @@ grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||
die() { red "error: $*" >&2; exit 1; }
|
||||
|
||||
# Which compose is on this box. Synology DSM and QNAP QTS ship the standalone
|
||||
# `docker-compose` binary and have no `docker compose` subcommand, so detect
|
||||
# rather than assume. COMPOSE_WAIT is empty on v1, which has no `--wait`; the
|
||||
# script's own health_gate covers that case.
|
||||
# QNAP keeps Container Station's `docker` and `docker-compose` inside the QPKG
|
||||
# directory, which is NOT on the PATH of an ordinary SSH session — the script
|
||||
# would otherwise die with "no compose found" on a box that has both. The volume
|
||||
# name differs between QTS (CACHEDEV1_DATA) and QuTS hero (the ZFS pool name),
|
||||
# so glob rather than hard-code it. No-op everywhere else.
|
||||
ensure_docker_on_path() {
|
||||
command -v docker >/dev/null 2>&1 && return 0
|
||||
local dir
|
||||
for dir in /share/*/.qpkg/container-station/bin /share/.qpkg/container-station/bin; do
|
||||
if [ -x "$dir/docker" ]; then
|
||||
PATH="$dir:$PATH"
|
||||
export PATH
|
||||
ylw "using Container Station's docker from $dir"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 0 # not found: let the caller fail with its own, clearer message
|
||||
}
|
||||
|
||||
# Which compose is on this box. Synology DSM and QNAP QTS/QuTS hero ship the
|
||||
# standalone `docker-compose` binary and may have no `docker compose`
|
||||
# subcommand, so detect rather than assume. COMPOSE_WAIT is empty on v1, which
|
||||
# has no `--wait`; the script's own health_gate covers that case.
|
||||
COMPOSE_BIN=()
|
||||
COMPOSE_WAIT=""
|
||||
detect_compose() {
|
||||
[ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0
|
||||
ensure_docker_on_path
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
COMPOSE_BIN=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
@@ -242,6 +263,7 @@ cmd_doctor() {
|
||||
echo " DATA_ROOT ${DATA_ROOT}"
|
||||
echo " APP_DOMAIN ${APP_DOMAIN:-<unset>}"
|
||||
echo " WEB_PORT ${WEB_PORT:-8088}"
|
||||
echo " ADMIN_PORT ${ADMIN_PORT:-8089} (keep this one LAN-only)"
|
||||
echo " SEARCH_BACKEND ${SEARCH_BACKEND:-pg}"
|
||||
echo " IMAGE_TAG ${IMAGE_TAG:-local}"
|
||||
echo
|
||||
@@ -297,8 +319,13 @@ cmd_doctor() {
|
||||
else grn " ${avail} MB free on ${DATA_ROOT}"; fi
|
||||
}
|
||||
|
||||
if command -v ss >/dev/null 2>&1 && ss -ltn 2>/dev/null | grep -q ":${WEB_PORT:-8088} "; then
|
||||
ylw " port ${WEB_PORT:-8088} already has a listener — change WEB_PORT if that is not us"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
local p
|
||||
for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do
|
||||
if ss -ltn 2>/dev/null | grep -q ":${p#*:} "; then
|
||||
ylw " port ${p#*:} already has a listener — change ${p%%:*} if that is not us"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# chmod 700 and owned by uid 999, so count them as root in a container.
|
||||
@@ -341,6 +368,21 @@ then re-run this command."
|
||||
health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs"
|
||||
record_version "${IMAGE_TAG:-local}"
|
||||
grn "up. open http://<this-host>:${WEB_PORT:-8088}/"
|
||||
echo " admin: http://<this-host>:${ADMIN_PORT:-8089}/ — restrict this port to your LAN"
|
||||
}
|
||||
|
||||
# First install from a registry: `up` only builds what is missing locally, and
|
||||
# `update` refuses to run without an existing database to back up. This is the
|
||||
# gap between them — fetch the images, then `up` starts them instead of building.
|
||||
cmd_pull() {
|
||||
require_env
|
||||
local tag="${1:-${IMAGE_TAG:-local}}"
|
||||
[ "${REGISTRY_PREFIX:-webmail}" = "webmail" ] && die \
|
||||
"REGISTRY_PREFIX is still the local default — nothing to pull from.
|
||||
Set REGISTRY_PREFIX and IMAGE_TAG in ${ENV_FILE}, or use: webmail.sh up --build"
|
||||
detect_compose
|
||||
IMAGE_TAG="$tag" compose pull || die "pull failed — is the registry reachable and logged in?"
|
||||
grn "pulled ${REGISTRY_PREFIX}/{backend,web}:${tag}. Next: ./deploy/webmail.sh up"
|
||||
}
|
||||
|
||||
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
|
||||
@@ -526,9 +568,13 @@ usage() { awk 'NR>1 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "${
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
SUBCOMMAND="$cmd"
|
||||
# Before anything touches Docker: on QNAP the binaries are off-PATH (see above).
|
||||
# doctor/backup/restore call `docker` directly, not only through compose.
|
||||
[ -n "$cmd" ] && ensure_docker_on_path
|
||||
case "$cmd" in
|
||||
init) cmd_init "$@" ;;
|
||||
doctor) cmd_doctor "$@" ;;
|
||||
pull) cmd_pull "$@" ;;
|
||||
up) cmd_up "$@" ;;
|
||||
down) cmd_down "$@" ;;
|
||||
restart) cmd_restart "$@" ;;
|
||||
|
||||
+10
-2
@@ -294,9 +294,17 @@ services:
|
||||
environment:
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
# The ONLY published port. Bind to a specific NAS interface by setting
|
||||
# WEB_BIND (e.g. 192.168.1.185) to keep it off other networks.
|
||||
# Bind to a specific NAS interface by setting WEB_BIND / ADMIN_BIND
|
||||
# (e.g. 192.168.1.185) to keep a port off other networks.
|
||||
#
|
||||
# WEB_PORT -> :80, the mail app; /api/admin/* is refused on this one.
|
||||
# ADMIN_PORT -> :81, the same app with the admin API reachable.
|
||||
#
|
||||
# Restrict ADMIN_PORT to the LAN in the NAS firewall — publishing it is
|
||||
# not what protects it (docs/nas-install.md §5.1). Comment the second
|
||||
# line out entirely if you would rather reach admin over VPN only.
|
||||
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8088}:80"
|
||||
- "${ADMIN_BIND:-0.0.0.0}:${ADMIN_PORT:-8089}:81"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
|
||||
interval: 30s
|
||||
|
||||
@@ -31,10 +31,19 @@ POSTGRES_PASSWORD=
|
||||
APP_DOMAIN=mail.home.lan
|
||||
|
||||
# --- Network ----------------------------------------------------------------
|
||||
# The single published port. Avoid 5000/5001 (Synology DSM) and 8080 (QNAP QTS).
|
||||
# Two published ports, same container. Avoid 5000/5001 (Synology DSM) and
|
||||
# 8080 (QNAP QTS).
|
||||
#
|
||||
# WEB_PORT is the mail app. The admin API is refused on it, so this is the one
|
||||
# you may expose beyond the LAN.
|
||||
WEB_PORT=8088
|
||||
# ADMIN_PORT is the same app with the admin API reachable. Restrict it to your
|
||||
# LAN in the NAS firewall — see docs/nas-install.md §5.1. Publishing it is not
|
||||
# what protects it.
|
||||
ADMIN_PORT=8089
|
||||
# Bind to one NAS interface instead of every network, e.g. WEB_BIND=192.168.1.185
|
||||
WEB_BIND=0.0.0.0
|
||||
ADMIN_BIND=0.0.0.0
|
||||
|
||||
TZ=Europe/Warsaw
|
||||
|
||||
|
||||
@@ -27,9 +27,13 @@ LABEL org.opencontainers.image.title="webmail-web" \
|
||||
org.opencontainers.image.revision="${GIT_SHA}"
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# Shared server body, included by both listeners. Outside conf.d/ so nginx
|
||||
# does not try to load it as a config of its own.
|
||||
COPY docker/nginx-app.conf /etc/nginx/webmail-app.conf
|
||||
COPY --from=builder /src/dist /usr/share/nginx/html
|
||||
|
||||
# Surface the running version to `deploy/webmail.sh status` without needing the API.
|
||||
RUN printf '%s\n' "${APP_VERSION}" > /usr/share/nginx/html/version.txt
|
||||
|
||||
EXPOSE 80
|
||||
# 80 = public mail, 81 = admin (see docker/nginx.conf).
|
||||
EXPOSE 80 81
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# The webmail front door's shared body, included by BOTH listeners in
|
||||
# nginx.conf (port 80 = public, port 81 = admin). Keeping it in one file is the
|
||||
# point: a location that existed on only one listener would be a silent hole or
|
||||
# a silent outage. Included at `server` scope, so everything here is server-level.
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Attachment uploads. Keep this comfortably above MAX_ATTACHMENT_MB (default
|
||||
# 25) to leave room for multipart/base64 overhead.
|
||||
client_max_body_size 64m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# The NAS is doing other jobs too — don't buffer big uploads to disk twice.
|
||||
proxy_request_buffering off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/manifest+json;
|
||||
|
||||
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied
|
||||
# server-side in app/indexer/sanitize.py; this is only the shell document.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
# ---- API ----------------------------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://webmail_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $fwd_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket (/api/ws) and any long-poll: no upgrade means no live mail.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Live updates hold the connection open; don't cut them at 60 s.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ---- static -------------------------------------------------------------
|
||||
# Vite emits content-hashed filenames into /assets, so they are immutable.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# The SPA fallback lives in each server block, not here: the two listeners serve
|
||||
# two different apps out of the same dist (index.html vs admin.html).
|
||||
|
||||
# An entry document must never be cached, or a deploy won't be picked up. Each
|
||||
# listener adds its own entry document's rule; this one is harmless on both
|
||||
# because the public listener 404s /admin.html before it can be served.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
+50
-68
@@ -2,11 +2,26 @@
|
||||
#
|
||||
# This file is dropped into /etc/nginx/conf.d/, i.e. it is already inside the
|
||||
# `http` block — hence the bare `map` below.
|
||||
#
|
||||
# TWO LISTENERS, one app:
|
||||
#
|
||||
# :80 public — the mail client. Neither the admin bundle nor /api/admin/*
|
||||
# is served here; both 404.
|
||||
# :81 admin — the admin panel (its own bundle, its own sign-in), and the
|
||||
# /api/admin/* endpoints it needs.
|
||||
#
|
||||
# The compose file publishes :80 as WEB_PORT and :81 as ADMIN_PORT, and the
|
||||
# firewall is what makes the split mean anything: allow ADMIN_PORT from the LAN
|
||||
# only, leave WEB_PORT as exposed as you like (docs/nas-install.md §5.1).
|
||||
#
|
||||
# Note what this boundary is and is not. It is nginx refusing paths on one
|
||||
# listener — good enough to keep the admin panel and its API off an
|
||||
# internet-facing port, and it fails closed if the app forgets a permission
|
||||
# check. It is NOT process isolation: both listeners proxy to the same API
|
||||
# container, so anything that can reach :81 can reach every admin route. The
|
||||
# panel still demands an administrator sign-in, and the API's own `CurrentAdmin`
|
||||
# dependency still guards each route; this is the outer layer, not the only one.
|
||||
|
||||
# Preserve the original scheme when something else terminates TLS in front of us
|
||||
# (Synology's Login Portal reverse proxy, QNAP's, Caddy, Cloudflare Tunnel...).
|
||||
# The API only emits Secure/__Host- cookies when it believes the request was
|
||||
# https, so losing this header silently downgrades session cookies.
|
||||
map $http_x_forwarded_proto $fwd_proto {
|
||||
default $scheme;
|
||||
https https;
|
||||
@@ -24,81 +39,48 @@ upstream webmail_api {
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# ---- :80 — public -----------------------------------------------------------
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Attachment uploads. Keep this comfortably above MAX_ATTACHMENT_MB (default
|
||||
# 25) to leave room for multipart/base64 overhead.
|
||||
client_max_body_size 64m;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# The NAS is doing other jobs too — don't buffer big uploads to disk twice.
|
||||
proxy_request_buffering off;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json
|
||||
image/svg+xml application/manifest+json;
|
||||
|
||||
# Clickjacking / sniffing basics. The app's own CSP for mail HTML is applied
|
||||
# server-side in app/indexer/sanitize.py; this is only the shell document.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
# ---- API ----------------------------------------------------------------
|
||||
location /api/ {
|
||||
proxy_pass http://webmail_api;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $fwd_proto;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
|
||||
# WebSocket (/api/ws) and any long-poll: no upgrade means no live mail.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Live updates hold the connection open; don't cut them at 60 s.
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ---- static -------------------------------------------------------------
|
||||
# Vite emits content-hashed filenames into /assets, so they are immutable.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /healthz {
|
||||
# Regex locations beat prefix ones, so this wins over `location /api/`
|
||||
# regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/...,
|
||||
# never a future /api/administrators. 404 rather than 403: a port open to
|
||||
# the internet shouldn't confirm which endpoints exist.
|
||||
location ~ ^/api/admin(/|$) {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# SPA fallback: every non-file route renders index.html (react-router).
|
||||
# The admin bundle ships in the same dist; don't hand it out here.
|
||||
location = /admin.html {
|
||||
access_log off;
|
||||
return 404;
|
||||
}
|
||||
|
||||
include /etc/nginx/webmail-app.conf;
|
||||
|
||||
# SPA fallback -> the mail client.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# index.html itself must never be cached, or a deploy won't be picked up.
|
||||
location = /index.html {
|
||||
# ---- :81 — admin ------------------------------------------------------------
|
||||
server {
|
||||
listen 81;
|
||||
server_name _;
|
||||
|
||||
include /etc/nginx/webmail-app.conf;
|
||||
|
||||
location = /admin.html {
|
||||
add_header Cache-Control "no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# SPA fallback -> the admin panel. `/` on this port IS the panel; the mail
|
||||
# client is not reachable here at all.
|
||||
location / {
|
||||
try_files $uri /admin.html;
|
||||
}
|
||||
}
|
||||
|
||||
+299
-24
@@ -3,10 +3,12 @@
|
||||
This is the operator runbook: how to install it, how to keep your mail safe from
|
||||
anything Docker does to itself, and how to update without holding your breath.
|
||||
It assumes the NAS is doing other work too, so the stack is deliberately modest:
|
||||
**one published port, no host networking, memory caps on every container.**
|
||||
**two published ports, no host networking, memory caps on every container** —
|
||||
mail on one, the admin panel on the other (§5.1).
|
||||
|
||||
Everything here works on any Intel/AMD Docker host — the NAS parts are just the
|
||||
specifics for DSM and QTS.
|
||||
specifics for DSM, QTS and QuTS hero. Synology users want §2.5; QNAP users
|
||||
§2.6.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +23,7 @@ specifics for DSM and QTS.
|
||||
| `postgres` | the database | no |
|
||||
| `redis` | job queues + sync leases | no |
|
||||
| `backup` | nightly verified database dump | no |
|
||||
| `meilisearch` | optional better search (see §8) | no |
|
||||
| `meilisearch` | optional better search (see §7) | no |
|
||||
| `migrate`, `init` | run once at start, then exit | — |
|
||||
|
||||
Plugins (calendar, contacts, labels, filters) are baked into the backend image
|
||||
@@ -43,7 +45,13 @@ Docker named volume:
|
||||
| NAS | Use |
|
||||
|---|---|
|
||||
| Synology | `/volume1/docker/webmail/data` |
|
||||
| QNAP | `/share/Container/webmail/data` |
|
||||
| QNAP (QTS / QuTS hero) | `/share/Container/webmail/data` |
|
||||
|
||||
On **QuTS hero** `/share/<shared-folder>` is the stable path — use it rather
|
||||
than the underlying `/share/<POOL>_DATA/...` form, which carries the pool name
|
||||
and changes if you ever move the folder. ZFS is a genuine advantage here:
|
||||
snapshot the `Container` shared folder and you get cheap, instant, restorable
|
||||
points-in-time for the whole install, on top of the nightly dump in §3.
|
||||
|
||||
Why: named volumes live inside Docker's internal storage, where Hyper Backup,
|
||||
Snapshot Replication and HBS3 cannot see them, and where "delete the project" in
|
||||
@@ -82,8 +90,10 @@ database password. Then open it and set:
|
||||
DATA_ROOT=/volume1/docker/webmail/data # or /share/Container/webmail/data
|
||||
APP_DOMAIN=mail.home.lan # your handles become you@this
|
||||
TZ=Europe/Warsaw
|
||||
WEB_PORT=8088 # avoid 5000/5001 (DSM), 8080 (QTS)
|
||||
WEB_PORT=8088 # mail; avoid 5000/5001 (DSM), 8080 (QTS)
|
||||
ADMIN_PORT=8089 # admin; keep this one LAN-only (§5.1)
|
||||
WEB_BIND=0.0.0.0 # or one NAS IP, e.g. 192.168.1.185
|
||||
ADMIN_BIND=0.0.0.0
|
||||
```
|
||||
|
||||
> ### Copy `MASTER_KEY` into your password manager. Now.
|
||||
@@ -101,12 +111,13 @@ WEB_BIND=0.0.0.0 # or one NAS IP, e.g. 192.168.1.185
|
||||
```
|
||||
|
||||
First run builds both images. The frontend build wants ~2 GB of RAM: if your NAS
|
||||
is small or you'd rather not tie it up, build on your PC instead — see §6.3.
|
||||
is small or you'd rather not tie it up, build on your PC instead — see §4
|
||||
"Building images elsewhere".
|
||||
|
||||
Open `http://your-nas:8088/`, create your account, and add a mail account
|
||||
through the wizard.
|
||||
|
||||
### 2.5 Installing entirely from the DSM GUI (no SSH)
|
||||
### 2.5 Synology: installing entirely from the DSM GUI (no SSH)
|
||||
|
||||
The whole install can be done without ever enabling SSH. The trick is **Task
|
||||
Scheduler**: it runs any shell command as root from the DSM web UI, which covers
|
||||
@@ -170,9 +181,11 @@ means the GUI and `deploy/webmail.sh` drive the same stack.
|
||||
Click through, and it builds. **First build takes 5-15 minutes and wants ~2 GB
|
||||
of RAM** — it compiles the frontend on the NAS. This is the one place the GUI
|
||||
route is meaningfully worse than SSH, where you can build on your PC and copy
|
||||
the images over (§6.3).
|
||||
the images over (§4 "Building images elsewhere").
|
||||
|
||||
**5. Open `http://your-nas:8088/`** and register your account.
|
||||
**5. Open `http://your-nas:8088/`** and register your account. The first account
|
||||
created on a fresh install becomes the **administrator**, and registration closes
|
||||
behind it — see §5.1.
|
||||
|
||||
**What you will see in Container Manager:** `init` and `migrate` sitting at
|
||||
**Exited (0)**. That is correct — they are one-shot jobs that did their work and
|
||||
@@ -198,11 +211,108 @@ and does apply migrations — but it takes no backup first, has no health gate,
|
||||
and will not roll back if the new version fails to start. The `update` task
|
||||
above does all three. Use it.
|
||||
|
||||
### 2.6 Starting automatically after a reboot
|
||||
### 2.6 QNAP: Container Station and QuTS hero
|
||||
|
||||
The stack itself is identical — same compose file, same `deploy/webmail.sh`.
|
||||
Four things differ from the Synology route.
|
||||
|
||||
**1. Docker is off the PATH.** Container Station keeps `docker` and
|
||||
`docker-compose` inside its QPKG directory, so a fresh SSH session finds
|
||||
neither. `deploy/webmail.sh` locates them itself (it globs
|
||||
`/share/*/.qpkg/container-station/bin`, which covers both QTS's
|
||||
`CACHEDEV1_DATA` and QuTS hero's pool name). For your own commands, put it on
|
||||
the PATH for the session:
|
||||
|
||||
```bash
|
||||
export PATH="$(ls -d /share/*/.qpkg/container-station/bin | head -1):$PATH"
|
||||
docker ps
|
||||
```
|
||||
|
||||
**2. Drive it from the shell, not the Container Station wizard.** Two reasons,
|
||||
one of which holds whatever your Container Station version offers. The weaker
|
||||
one: depending on version, its *Applications* create-flow may want YAML pasted
|
||||
into a text box rather than a folder path, and pasted YAML has no `.env` beside
|
||||
it — which is where `MASTER_KEY` and the database password live. The one that
|
||||
always holds: its update path takes no backup, has no health gate and cannot
|
||||
roll back, exactly like Synology's project *Build* button. Run the install the
|
||||
normal way:
|
||||
|
||||
```bash
|
||||
ssh admin@your-qnap
|
||||
sudo -i
|
||||
mkdir -p /share/Container/webmail && cd /share/Container/webmail
|
||||
git clone <your-gitea-url>/webmail.git src && cd src
|
||||
bash deploy/webmail.sh init --data-root /share/Container/webmail/data \
|
||||
--domain mail.home.lan --tz Europe/Warsaw --port 8088
|
||||
bash deploy/webmail.sh up
|
||||
```
|
||||
|
||||
You still get the GUI. Containers started this way use the same Docker daemon,
|
||||
so Container Station lists them, shows logs and stats, and can stop or restart
|
||||
them — you simply use `deploy/webmail.sh update` instead of its update button.
|
||||
|
||||
**3. Pick ports around QTS.** The web UI owns **8080** (and 443/80 if you have
|
||||
HTTPS on), so the defaults `WEB_PORT=8088` / `ADMIN_PORT=8089` are already
|
||||
clear of it. `deploy/webmail.sh doctor` warns if either is taken.
|
||||
|
||||
**4. There is no DSM firewall problem.** §8 — the chain that silently drops
|
||||
container traffic — is Synology-specific. QuTS hero has no such rule by default;
|
||||
container-to-container and container-to-internet both just work. If you install
|
||||
**QuFirewall**, re-read §8 first: it describes the same class of failure and,
|
||||
more usefully, how to prove where a packet dies.
|
||||
|
||||
#### Pulling images instead of building on the NAS
|
||||
|
||||
Building on the NAS costs 5-15 minutes and ~2 GB of RAM per update. Pulling
|
||||
pre-built images makes an update a download and a restart. The catch is that
|
||||
your Gitea speaks plain **HTTP**, and Docker refuses a non-HTTPS registry
|
||||
unless told otherwise.
|
||||
|
||||
On the QNAP, once:
|
||||
|
||||
```bash
|
||||
# find the daemon's config file rather than guessing its path
|
||||
ps -ef | grep -o '\--config-file[= ][^ ]*' | head -1
|
||||
```
|
||||
|
||||
Add the registry to that `daemon.json`:
|
||||
|
||||
```json
|
||||
{ "insecure-registries": ["192.168.1.185:8418"] }
|
||||
```
|
||||
|
||||
Restart Container Station afterwards so the daemon reloads, then:
|
||||
|
||||
```bash
|
||||
docker login 192.168.1.185:8418
|
||||
```
|
||||
|
||||
From then on, on your PC:
|
||||
|
||||
```bash
|
||||
REGISTRY_PREFIX=192.168.1.185:8418/<you>/webmail ./deploy/build-push.sh v1.0.0
|
||||
```
|
||||
|
||||
and on the NAS, with `REGISTRY_PREFIX` and `IMAGE_TAG` set in `.env`:
|
||||
|
||||
```bash
|
||||
bash deploy/webmail.sh pull v1.0.0 # first install
|
||||
bash deploy/webmail.sh update v1.0.0 # every time after
|
||||
```
|
||||
|
||||
`pull` exists because the other two don't cover a first install from a
|
||||
registry: `up` only builds what is missing locally, and `update` refuses to run
|
||||
without an existing database to back up.
|
||||
|
||||
> If you would rather not loosen the daemon, `./deploy/build-push.sh v1.0.0
|
||||
> --save` writes a tar you copy over and `docker load` — no registry, no
|
||||
> insecure flag, but a manual copy each time.
|
||||
|
||||
### 2.7 Starting automatically after a reboot
|
||||
|
||||
Every service is `restart: unless-stopped`, so Docker brings the stack back on
|
||||
boot by itself. On Synology, make sure Container Manager itself is set to start
|
||||
on boot (it is by default).
|
||||
on boot (it is by default); on QNAP, the same goes for Container Station.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,7 +327,7 @@ Containers are disposable. These are not:
|
||||
| 3 | **`MASTER_KEY`** | `.env` + your password manager | Every saved mail-account password, permanently |
|
||||
|
||||
Everything else is derived and needs no backup: Redis holds queues and leases,
|
||||
Meilisearch's index rebuilds from Postgres (§8), and images rebuild from source.
|
||||
Meilisearch's index rebuilds from Postgres (§7), and images rebuild from source.
|
||||
|
||||
### Backups
|
||||
|
||||
@@ -299,8 +409,8 @@ the dump that `update` took in step 1.
|
||||
Building on the NAS is simple but slow and RAM-hungry. Two alternatives:
|
||||
|
||||
```bash
|
||||
# On your PC — push to your Gitea registry:
|
||||
REGISTRY_PREFIX=192.168.1.185:8418/claude/webmail ./deploy/build-push.sh v1.0.0
|
||||
# On your PC — push to your Gitea registry (the namespace is your Gitea user):
|
||||
REGISTRY_PREFIX=192.168.1.185:8418/<you>/webmail ./deploy/build-push.sh v1.0.0
|
||||
# then on the NAS, set REGISTRY_PREFIX + IMAGE_TAG in .env and:
|
||||
./deploy/webmail.sh update v1.0.0
|
||||
|
||||
@@ -316,14 +426,100 @@ docker load -i webmail-v1.0.0.tar && ./deploy/webmail.sh update v1.0.0
|
||||
|
||||
### 5.1 Read this first
|
||||
|
||||
**Anyone who can open the page can create an account on it.** Registration is
|
||||
open by design (it's a personal server), so do not put this straight on the
|
||||
public internet. Pick one:
|
||||
**Registration is open only until the first account exists.** On a fresh install
|
||||
the signup page is available to anyone who can reach the port; whoever uses it
|
||||
first becomes the administrator, and signup closes automatically behind them. So
|
||||
the window is narrow, but it is real: create your account before exposing the
|
||||
port, or someone else owns the instance.
|
||||
|
||||
After that, new accounts need an administrator to reopen registration — which
|
||||
brings us to the two ports.
|
||||
|
||||
#### The two ports
|
||||
|
||||
One container, two apps, two nginx listeners:
|
||||
|
||||
| Port | Serves | Expose to |
|
||||
|---|---|---|
|
||||
| `WEB_PORT` (8088) | The mail client. The admin panel and `/api/admin/*` both return **404** here. | Wherever you like |
|
||||
| `ADMIN_PORT` (8089) | The admin panel — its own app, its own sign-in. | **LAN only** |
|
||||
|
||||
They are two separate applications built from one source tree: `index.html` is
|
||||
the mail client, `admin.html` is the panel, and nginx serves one per listener.
|
||||
The panel is a few KB of its own code and never loads the mail client. Opening
|
||||
`http://your-nas:8089/` lands directly on the administrator sign-in.
|
||||
|
||||
Signing in there is the ordinary account login — same session cookie as mail.
|
||||
An account without administrator rights gets in and is told it is not an
|
||||
administrator; it sees no settings.
|
||||
|
||||
Understand what this boundary is: nginx refusing a path on one listener. That is
|
||||
enough to keep the admin API off an internet-facing port, and it fails closed if
|
||||
the app ever forgets a permission check. It is **not** process isolation — both
|
||||
listeners proxy to the same API container, so anything that reaches `ADMIN_PORT`
|
||||
reaches every admin route, and the API's own administrator check is still what
|
||||
guards each one. Restricting `ADMIN_PORT` is therefore not optional.
|
||||
|
||||
#### Restricting ADMIN_PORT to the LAN
|
||||
|
||||
**On QNAP** there is usually no firewall in the way at all, which cuts both
|
||||
ways: nothing filters `ADMIN_PORT` for you either. Two things keep it off the
|
||||
internet, and you want both:
|
||||
|
||||
1. **Do not forward 8089 on your router.** This is what actually matters. A
|
||||
published port is only reachable from outside if something routes to it, and
|
||||
a home router forwards nothing by default.
|
||||
2. **Bind it to the LAN interface.** Set `ADMIN_BIND` in `.env` to the NAS's LAN
|
||||
address, e.g. `ADMIN_BIND=192.168.1.50`. The port then isn't listening on any
|
||||
other interface — useful if the box ever gains a second one (a VPN tunnel, a
|
||||
second NIC, a Docker macvlan).
|
||||
|
||||
For an actual source-IP rule on QNAP you need the optional **QuFirewall** app;
|
||||
its allow/deny-by-subnet model works like the DSM recipe below.
|
||||
|
||||
**On Synology:** *Control Panel → Security → Firewall → Edit Rules → Create*
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Ports | Custom → TCP → `8089` |
|
||||
| Source IP | Specific IP → Subnet → `192.168.1.0` / `255.255.255.0` |
|
||||
| Action | Allow |
|
||||
|
||||
Then a second rule for the same port with Source IP **All** and Action **Deny**,
|
||||
placed directly below it. Order is everything: DSM evaluates top-down, so the
|
||||
allow must come first. Verify from off-LAN — or just trust the deny and check
|
||||
that `curl http://nas:8089/healthz` still answers from a LAN machine.
|
||||
|
||||
If you would rather not publish it at all, comment the `ADMIN_PORT` line out of
|
||||
the `web` service's `ports:` in `docker-compose.yml` and reach admin over VPN,
|
||||
or temporarily `docker compose port` it when you need it.
|
||||
|
||||
Reopening registration, once you are on `ADMIN_PORT`:
|
||||
|
||||
```bash
|
||||
# the panel does this for you; this is the same call by hand, from a
|
||||
# signed-in browser session:
|
||||
curl -X PUT http://your-nas:8089/api/admin/settings \
|
||||
-H 'Content-Type: application/json' -H "X-CSRF: $CSRF" \
|
||||
--cookie "session=$SESSION; csrf=$CSRF" \
|
||||
-d '{"registration_open": true}'
|
||||
```
|
||||
|
||||
`GET /api/admin/settings` reads the current state, and `{"registration_open":
|
||||
false}` closes it again. The same endpoint carries `default_quota_bytes`, the
|
||||
quota handed to accounts created from then on — changing it never resizes a
|
||||
mailbox that already exists.
|
||||
|
||||
#### And the mail port
|
||||
|
||||
Even with admin split off, do not put `WEB_PORT` straight on the public
|
||||
internet unguarded. Pick one:
|
||||
|
||||
- **VPN** (Tailscale, WireGuard, or the NAS's own VPN Server) — simplest and
|
||||
safest. The port stays LAN-only.
|
||||
safest. Both ports stay LAN-only.
|
||||
- **NAS reverse proxy with HTTPS**, restricted by firewall rules to the networks
|
||||
you trust.
|
||||
you trust. Proxy `WEB_PORT` only — never create a proxy entry for
|
||||
`ADMIN_PORT`, or you have undone the split.
|
||||
|
||||
### 5.2 HTTPS through the NAS reverse proxy
|
||||
|
||||
@@ -401,7 +597,15 @@ backends, or search ever looks stale, just rebuild it:
|
||||
|
||||
---
|
||||
|
||||
## 8. DSM firewall breaks container-to-container traffic
|
||||
## 8. Synology only: the DSM firewall drops container traffic
|
||||
|
||||
**On QNAP, skip this section** unless you have installed *QuFirewall* — QTS and
|
||||
QuTS hero add no such rule of their own. It is still worth skimming §8.2, which
|
||||
is mostly a method for proving *where* a packet dies; that part is portable.
|
||||
|
||||
There are two separate failures here with the same silent-timeout signature.
|
||||
|
||||
### 8.1 Containers cannot reach each other
|
||||
|
||||
**Symptom:** everything reports healthy, but `migrate` fails with an asyncpg
|
||||
`TimeoutError`, or services intermittently cannot reach `postgres:5432`. Testing
|
||||
@@ -459,7 +663,7 @@ sysctl -w net.bridge.bridge-nf-call-iptables=0
|
||||
|
||||
Re-check it after a DSM upgrade with `sysctl net.bridge.bridge-nf-call-iptables`.
|
||||
|
||||
### Is that safe?
|
||||
#### Is that safe?
|
||||
|
||||
Yes, for this deployment, and the scope is narrower than it first sounds.
|
||||
|
||||
@@ -474,7 +678,9 @@ What does **not** change:
|
||||
bridged, so it still traverses `FORWARD` and Docker's
|
||||
`DOCKER-ISOLATION-STAGE-1/2` rules still apply.
|
||||
- **Traffic from your LAN or the internet into a published port.** Still routed,
|
||||
still filtered by DSM's firewall. The one published port here is `WEB_PORT`.
|
||||
still filtered by DSM's firewall. The published ports here are `WEB_PORT` and
|
||||
`ADMIN_PORT` — and that filtering is exactly what keeps `ADMIN_PORT` on the
|
||||
LAN, so this is a place you *want* DSM's firewall doing its job (§5.1).
|
||||
- **Traffic from containers out to the network.** Still routed and filtered.
|
||||
- **DSM's own protection.** The `INPUT` chain, which guards the NAS itself, is
|
||||
untouched.
|
||||
@@ -484,6 +690,71 @@ bridge on the box. If you run virtual machines on a bridged network *and* rely
|
||||
on DSM's firewall to filter traffic between VMs on that same bridge, that
|
||||
filtering stops too. Filtering of VM traffic to anywhere else is unaffected.
|
||||
|
||||
### 8.2 Containers cannot reach the internet
|
||||
|
||||
The `bridge-nf` fix above does **not** cover this one, and the symptom is easy
|
||||
to misread.
|
||||
|
||||
**Symptom:** adding a mail account fails with `connection timed out`, while the
|
||||
setup wizard happily auto-discovers the provider's server names. That looks like
|
||||
"the app found the server, so the network is fine" — it is not. Autodiscovery
|
||||
answers from a local presets file for the common providers, and never touches
|
||||
the network at all.
|
||||
|
||||
**Cause:** `FORWARD_FIREWALL` is an allow-list of destination ports ending in
|
||||
`DROP`. Inspect it and the shape is obvious:
|
||||
|
||||
```
|
||||
23 RETURN udp -- udp dpt:53 <- DNS allowed
|
||||
24 RETURN tcp -- multiport dports 80,443 <- HTTPS allowed
|
||||
... no 993, no 465, no 587 ...
|
||||
DROP all <- IMAP/SMTP dies here
|
||||
```
|
||||
|
||||
So DNS resolves and HTTPS works, while IMAP's SYN goes unanswered. A *timeout*
|
||||
rather than a refusal is the tell: a closed port answers with RST, a firewall
|
||||
says nothing.
|
||||
|
||||
Note that `bridge-nf-call-iptables=0` cannot help, because this traffic is
|
||||
**routed** (bridge → eth0), not bridged, so it crosses `FORWARD` regardless.
|
||||
|
||||
**Prove it** — the host reaches the server, a container does not:
|
||||
|
||||
```bash
|
||||
# A: from the NAS itself
|
||||
timeout 5 bash -c 'exec 3<>/dev/tcp/imap.gmail.com/993' && echo "HOST OK" || echo "HOST FAIL"
|
||||
|
||||
# B: from inside the stack's network
|
||||
docker run --rm --network webmail-prod postgres:16 \
|
||||
bash -c 'timeout 5 bash -c "exec 3<>/dev/tcp/imap.gmail.com/993" && echo "CTR OK" || echo "CTR FAIL"'
|
||||
```
|
||||
|
||||
A ok + B fail confirms it. To see precisely which rule kills it, zero the
|
||||
counters, re-run B, and look for the one that moved:
|
||||
|
||||
```bash
|
||||
iptables -Z FORWARD_FIREWALL
|
||||
iptables -vnL FORWARD_FIREWALL --line-numbers
|
||||
```
|
||||
|
||||
**Fix — allow the Docker subnets outbound, in DSM's own GUI.** Not with
|
||||
`iptables`: a hand-inserted rule is flushed the next time DSM rebuilds its
|
||||
chains (§8.1), whereas a GUI rule is part of what gets rebuilt.
|
||||
|
||||
*Control Panel → Security → Firewall → Edit Rules → Create*
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Ports | All |
|
||||
| Protocol | All |
|
||||
| Source IP | Specific IP → Subnet → `172.16.0.0` / `255.240.0.0` |
|
||||
| Action | Allow |
|
||||
|
||||
Place it above the deny-all. Use the whole `/12` rather than the stack's current
|
||||
subnet: Docker assigns those dynamically, so a rule naming `172.21.0.0/16` stops
|
||||
matching the day the network is recreated on a different subnet — a trap that is
|
||||
easy to fall into and hard to spot afterwards.
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
@@ -491,8 +762,12 @@ filtering stops too. Filtering of VM traffic to anywhere else is unaffected.
|
||||
| `up` hangs on `postgres` | `DATA_ROOT` on NFS/SMB, or wrong permissions | `./deploy/webmail.sh doctor` |
|
||||
| UI loads, no live updates | WebSocket not enabled in the NAS reverse proxy | §5.2 Custom Header → WebSocket |
|
||||
| Login works, logs out on reload | HTTPS terminated upstream but `X-Forwarded-Proto` stripped | Use the NAS reverse proxy as documented; don't strip headers |
|
||||
| "port is already allocated" | 8088 taken by another app | change `WEB_PORT` |
|
||||
| `migrate` fails with asyncpg TimeoutError | DSM firewall dropping container traffic (§8) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
|
||||
| "port is already allocated" | 8088/8089 taken by another app | change `WEB_PORT` / `ADMIN_PORT` |
|
||||
| Admin panel 404s | you are on `WEB_PORT`; the panel is only on `ADMIN_PORT` | open `http://nas:8089/` (§5.1) |
|
||||
| Panel says "Not an administrator" | signed in with a non-admin account | sign out, sign in as the first-created account (§5.1) |
|
||||
| `migrate` fails with asyncpg TimeoutError | Synology: DSM firewall dropping container traffic (§8.1) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
|
||||
| Adding a mail account times out, but autodiscovery worked | Synology: DSM firewall dropping container *egress* (§8.2) | allow `172.16.0.0/12` outbound in the DSM firewall GUI |
|
||||
| `no compose found` on QNAP over SSH | Container Station's binaries are off-PATH | nothing to do — the script globs for them (§2.6) |
|
||||
| Mail not syncing | worker stopped, or credentials rejected | `./deploy/webmail.sh logs worker-sync` |
|
||||
| Search finds nothing after a restore | index not rebuilt | `./deploy/webmail.sh reindex --purge` |
|
||||
| App won't start after editing `.env` | malformed `MASTER_KEY` | `./deploy/webmail.sh doctor` |
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<title>Webmail admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { useMe } from "../lib/auth";
|
||||
|
||||
const GIB = 1024 ** 3;
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
|
||||
|
||||
/**
|
||||
* The admin application. Three states: signed out (login), signed in without the
|
||||
* administrator flag (refused), signed in as an administrator (the panel).
|
||||
*
|
||||
* The login here is the ordinary session login — same cookie, same endpoint as
|
||||
* the mail client. What makes this a separate thing is that the bundle is served
|
||||
* only on the admin listener, and every action it performs hits /api/admin/*,
|
||||
* which the public listener refuses outright.
|
||||
*/
|
||||
export function AdminApp() {
|
||||
const t = useT();
|
||||
const me = useMe();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app-bg text-app-fg">
|
||||
<header className="border-b border-app-border px-6 py-3">
|
||||
<h1 className="font-semibold">{t("admin.title")}</h1>
|
||||
</header>
|
||||
<main className="mx-auto max-w-2xl px-6 py-10">
|
||||
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
|
||||
{!me.isPending && !me.data && <SignIn />}
|
||||
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
|
||||
{me.data?.is_admin && <Panel handle={me.data.handle} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignIn() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
const [needTotp, setNeedTotp] = useState(false);
|
||||
|
||||
const login = useMutation({
|
||||
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
|
||||
onSuccess: (user) => queryClient.setQueryData(["me"], user),
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) {
|
||||
setNeedTotp(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const error = login.error instanceof ApiError ? login.error : null;
|
||||
const showError = error && !(needTotp && error.code === "totp_required");
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-sm">
|
||||
<h2 className="mb-1 text-2xl font-semibold">{t("admin.signInTitle")}</h2>
|
||||
<p className="mb-6 text-sm text-app-muted">{t("admin.signInHint")}</p>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
login.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={t("auth.handlePlaceholder")}
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
disabled={needTotp}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="password"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={needTotp}
|
||||
/>
|
||||
{needTotp && (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
className={`${inputClass} tracking-widest`}
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-app-muted">{t("auth.totpPrompt")}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={login.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{login.isPending ? t("auth.signingIn") : t("auth.signIn")}
|
||||
</button>
|
||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SignOutButton() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const logout = useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: () => queryClient.setQueryData(["me"], null),
|
||||
});
|
||||
return (
|
||||
<button
|
||||
className="rounded border border-app-border px-3 py-1 text-sm text-app-muted hover:bg-app-hover"
|
||||
onClick={() => logout.mutate()}
|
||||
>
|
||||
{t("admin.signOut")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NotAdmin({ handle }: { handle: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-2xl font-semibold">{t("admin.notAdminTitle")}</h2>
|
||||
<p className="text-sm text-app-muted">{t("admin.notAdmin", { handle })}</p>
|
||||
<SignOutButton />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ handle }: { handle: string }) {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["adminSettings"], queryFn: api.adminSettings });
|
||||
|
||||
// The quota field is a draft until Save — unlike the toggle, you do not want a
|
||||
// write per keystroke. `null` means "not edited yet, show the server's value".
|
||||
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: api.saveAdminSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["adminSettings"], data);
|
||||
setQuotaDraft(null);
|
||||
},
|
||||
});
|
||||
|
||||
const open = settings.data?.registration_open ?? false;
|
||||
const serverQuotaGib = settings.data ? settings.data.default_quota_bytes / GIB : 0;
|
||||
const quotaValue = quotaDraft ?? String(Number(serverQuotaGib.toFixed(3)));
|
||||
const quotaGib = Number(quotaValue);
|
||||
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
|
||||
const quotaDirty = quotaDraft !== null && quotaGib !== serverQuotaGib;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-app-muted">{t("admin.signedInAs", { handle })}</p>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
|
||||
{settings.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
|
||||
{settings.error instanceof ApiError && (
|
||||
<p className="text-sm text-red-500">{settings.error.message}</p>
|
||||
)}
|
||||
|
||||
{settings.data && (
|
||||
<>
|
||||
<div className="border-b border-app-border py-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-app-muted">{t("admin.registration")}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate({ registration_open: value })}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
open === value
|
||||
? "bg-app-active font-medium text-app-fg"
|
||||
: "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "admin.registrationOn" : "admin.registrationOff")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-app-faint">{t("admin.registrationHelp")}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-app-border py-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-app-muted">{t("admin.defaultQuota")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-28 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.5"
|
||||
value={quotaValue}
|
||||
onChange={(e) => setQuotaDraft(e.target.value)}
|
||||
/>
|
||||
<span className="text-app-muted">GiB</span>
|
||||
<button
|
||||
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
|
||||
disabled={!quotaDirty || !quotaValid || save.isPending}
|
||||
onClick={() => save.mutate({ default_quota_bytes: Math.round(quotaGib * GIB) })}
|
||||
>
|
||||
{save.isPending ? t("admin.saving") : t("admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-app-faint">{t("admin.quotaHelp")}</p>
|
||||
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
|
||||
{save.error instanceof ApiError && (
|
||||
<p className="mt-1 text-xs text-red-500">{save.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Entry point for the ADMIN app — a separate bundle from the mail client, served
|
||||
// only on nginx's admin listener (docker/nginx.conf :81 -> ADMIN_PORT). It shares
|
||||
// the API client, i18n and theme, but nothing of the mail UI: no router, no
|
||||
// plugins, no mailbox state. See docs/nas-install.md §5.1.
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { I18nProvider } from "../i18n";
|
||||
import { ThemeProvider } from "../lib/theme";
|
||||
import { AdminApp } from "./AdminApp";
|
||||
import "../index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminApp />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -36,6 +36,13 @@ export const en = {
|
||||
"auth.noAccount": "No account?",
|
||||
"auth.haveAccount": "Already have an account?",
|
||||
"auth.register": "Register",
|
||||
"auth.checking": "Checking…",
|
||||
"auth.firstUserTitle": "Create the administrator account",
|
||||
"auth.firstUserHint":
|
||||
"This is the first account on this server, so it becomes the administrator. Registration closes automatically once it exists.",
|
||||
"auth.registrationClosedTitle": "Registration is closed",
|
||||
"auth.registrationClosed":
|
||||
"This server is not accepting new accounts. Ask the administrator to open registration.",
|
||||
|
||||
"nav.mail": "Mail",
|
||||
"nav.back": "Back",
|
||||
@@ -399,6 +406,27 @@ export const en = {
|
||||
"settings.addToCalendar": "Add to calendar",
|
||||
"settings.alsoFile": "Also mark the message as read and file it",
|
||||
"settings.integrations": "Integrations",
|
||||
|
||||
"admin.title": "Webmail administration",
|
||||
"admin.signInTitle": "Administrator sign-in",
|
||||
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
||||
"admin.signOut": "Sign out",
|
||||
"admin.signedInAs": "Signed in as {handle}",
|
||||
"admin.notAdminTitle": "Not an administrator",
|
||||
"admin.notAdmin":
|
||||
"{handle} is signed in, but does not have administrator rights on this server.",
|
||||
"admin.registration": "New user registration",
|
||||
"admin.registrationOn": "Open",
|
||||
"admin.registrationOff": "Closed",
|
||||
"admin.registrationHelp":
|
||||
"While closed, the signup page is hidden and the server refuses new accounts.",
|
||||
"admin.defaultQuota": "Default quota for new users",
|
||||
"admin.quotaHelp":
|
||||
"Applies to accounts created from now on. Existing mailboxes keep the quota they were given.",
|
||||
"admin.save": "Save",
|
||||
"admin.saving": "Saving…",
|
||||
"admin.saved": "Saved",
|
||||
"admin.quotaInvalid": "Enter a size greater than 0.",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import type {
|
||||
AccountDetail,
|
||||
AdminSettings,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
CalendarOut,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
MailboxOut,
|
||||
QuotaOut,
|
||||
RecipientSuggestion,
|
||||
RegistrationStatus,
|
||||
RemoteDraftOut,
|
||||
SavedSearch,
|
||||
SearchOut,
|
||||
@@ -77,6 +79,11 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminSettings: () => apiFetch<AdminSettings>("/admin/settings"),
|
||||
// PUT merges: send only the fields you are changing.
|
||||
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
||||
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
|
||||
register: (localpart: string, password: string) =>
|
||||
apiFetch<UserOut>("/auth/register", {
|
||||
method: "POST",
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface UserOut {
|
||||
recovery_email: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
totp_enabled: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registration_open: boolean;
|
||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||
default_quota_bytes: number;
|
||||
}
|
||||
|
||||
export interface RegistrationStatus {
|
||||
open: boolean;
|
||||
/** No users yet: the next signup creates the administrator. */
|
||||
bootstrap: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadSummary {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -13,6 +13,8 @@ export function Login() {
|
||||
const [needTotp, setNeedTotp] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
// Only offer the signup link when the server would actually accept one.
|
||||
const registration = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
|
||||
@@ -80,12 +82,14 @@ export function Login() {
|
||||
</button>
|
||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link className="text-app-accent" to="/register">
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
{registration.data?.open && (
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
{t("auth.noAccount")}{" "}
|
||||
<Link className="text-app-accent" to="/register">
|
||||
{t("auth.register")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none";
|
||||
|
||||
export function Register() {
|
||||
const t = useT();
|
||||
const [localpart, setLocalpart] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Gates the whole page: an instance with users and registration closed offers
|
||||
// no form at all. The server enforces this too — this is only the UI.
|
||||
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => api.register(localpart, password),
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["me"], user);
|
||||
// The first signup closes registration behind itself; drop the cached answer.
|
||||
void queryClient.invalidateQueries({ queryKey: ["registration"] });
|
||||
navigate("/mail");
|
||||
},
|
||||
});
|
||||
|
||||
const signInLink = (
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
{t("auth.haveAccount")}{" "}
|
||||
<Link className="text-app-accent" to="/login">
|
||||
{t("auth.signIn")}
|
||||
</Link>
|
||||
</p>
|
||||
);
|
||||
|
||||
if (status.isPending) {
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<p className="text-sm text-app-muted">{t("auth.checking")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status.data && !status.data.open) {
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<h1 className="mb-3 text-2xl font-semibold">{t("auth.registrationClosedTitle")}</h1>
|
||||
<p className="text-sm text-app-muted">{t("auth.registrationClosed")}</p>
|
||||
{signInLink}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bootstrap = status.data?.bootstrap ?? false;
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
||||
<h1 className="mb-6 text-2xl font-semibold">Create account</h1>
|
||||
<h1 className="mb-3 text-2xl font-semibold">
|
||||
{bootstrap ? t("auth.firstUserTitle") : t("auth.registerTitle")}
|
||||
</h1>
|
||||
{bootstrap && <p className="mb-5 text-sm text-app-muted">{t("auth.firstUserHint")}</p>}
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
@@ -29,16 +72,16 @@ export function Register() {
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
placeholder="handle (3–30 chars, a-z 0-9 . _ -)"
|
||||
className={inputClass}
|
||||
placeholder={t("auth.handlePlaceholder")}
|
||||
value={localpart}
|
||||
onChange={(e) => setLocalpart(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
className="w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
|
||||
className={inputClass}
|
||||
type="password"
|
||||
placeholder="password (min 8 chars)"
|
||||
placeholder={t("auth.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
@@ -47,18 +90,13 @@ export function Register() {
|
||||
disabled={mutation.isPending}
|
||||
type="submit"
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Create account"}
|
||||
{mutation.isPending ? t("auth.creating") : t("auth.create")}
|
||||
</button>
|
||||
{mutation.error instanceof ApiError && (
|
||||
<p className="text-sm text-red-500">{mutation.error.message}</p>
|
||||
)}
|
||||
</form>
|
||||
<p className="mt-4 text-sm text-app-muted">
|
||||
Already have an account?{" "}
|
||||
<Link className="text-app-accent" to="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
{signInLink}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
/// <reference types="vitest" />
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// SPEC dev-environment §5: Vite dev proxy /api -> :8000 with ws:true for /api/ws.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Two apps, one dist: the mail client (index.html) and the admin panel
|
||||
// (admin.html). nginx serves each from its own listener — see
|
||||
// docker/nginx.conf. In dev, the panel is at /admin.html.
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
admin: resolve(__dirname, "admin.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user