Admin panel on its own port, and a registration gate
Three strands that only make sense together, so they land together. Registration gate (migration 0016). POST /auth/register was open to anyone who could reach the port. Now the first account on an empty instance is always allowed, becomes the administrator, and closes registration behind itself; after that it takes an admin reopening it. The decision is made under a row lock on app_settings so two simultaneous first signups cannot both see an empty table and both claim administrator. Existing installs keep their oldest account as admin and start closed. Instance default quota (migration 0017). users.quota_bytes is snapshotted at creation from app_settings.default_quota_bytes, so changing the default affects later accounts only and never resizes a mailbox that exists. The panel is a separate app, not a page in the mail client. admin.html is its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the vendor chunk), with its own administrator sign-in. One nginx now serves two listeners: :80 is the mail client and 404s both /admin.html and /api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not process isolation — CurrentAdmin still guards every admin route — so ADMIN_PORT must be restricted to the LAN, which the runbook now covers for both DSM and QNAP. Also QNAP: deploy/webmail.sh finds Container Station's docker under /share/*/.qpkg/container-station/bin, which is off an SSH session's PATH, before anything shells out to it. docs/nas-install.md gains a QuTS hero runbook (§2.6), splits the DSM firewall section into container-to-container (§8.1) and the egress case that makes account-add time out while autodiscovery appears to work (§8.2), and scopes both to Synology. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
|
|||||||
"""
|
"""
|
||||||
from app.api.accounts import router as accounts_router
|
from app.api.accounts import router as accounts_router
|
||||||
from app.api.actions import router as actions_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.avatar import router as avatar_router
|
||||||
from app.api.compose import router as compose_router
|
from app.api.compose import router as compose_router
|
||||||
from app.api.mailboxes import router as mailboxes_router
|
from app.api.mailboxes import router as mailboxes_router
|
||||||
@@ -40,6 +41,7 @@ def register_routers(app: FastAPI) -> None:
|
|||||||
saved_searches_router,
|
saved_searches_router,
|
||||||
avatar_router,
|
avatar_router,
|
||||||
plugins_router,
|
plugins_router,
|
||||||
|
admin_router,
|
||||||
):
|
):
|
||||||
app.include_router(router, prefix="/api")
|
app.include_router(router, prefix="/api")
|
||||||
app.include_router(ws_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")
|
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)]
|
CurrentUser = Annotated[User, Depends(current_user)]
|
||||||
|
CurrentAdmin = Annotated[User, Depends(current_admin)]
|
||||||
CurrentSession = Annotated[Session, Depends(current_session)]
|
CurrentSession = Annotated[Session, Depends(current_session)]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from app.identity.schemas import (
|
|||||||
PasswordResetIn,
|
PasswordResetIn,
|
||||||
PasswordResetRequestIn,
|
PasswordResetRequestIn,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
RegistrationStatusOut,
|
||||||
SessionOut,
|
SessionOut,
|
||||||
TotpDisableRequest,
|
TotpDisableRequest,
|
||||||
TotpEnableRequest,
|
TotpEnableRequest,
|
||||||
@@ -41,6 +42,7 @@ from app.identity.service import (
|
|||||||
TotpRequired,
|
TotpRequired,
|
||||||
authenticate,
|
authenticate,
|
||||||
register_user,
|
register_user,
|
||||||
|
registration_status,
|
||||||
resolve_handle,
|
resolve_handle,
|
||||||
)
|
)
|
||||||
from app.identity.sessions import (
|
from app.identity.sessions import (
|
||||||
@@ -77,9 +79,20 @@ def _user_out(user: User) -> UserOut:
|
|||||||
recovery_email=user.recovery_email,
|
recovery_email=user.recovery_email,
|
||||||
settings=user.settings,
|
settings=user.settings,
|
||||||
totp_enabled=user.totp_secret is not None,
|
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)
|
@router.post("/register", response_model=UserOut)
|
||||||
async def register(
|
async def register(
|
||||||
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
body: RegisterRequest, request: Request, response: Response, db: DbSession
|
||||||
@@ -92,8 +105,7 @@ async def register(
|
|||||||
recovery_email=body.recovery_email,
|
recovery_email=body.recovery_email,
|
||||||
)
|
)
|
||||||
except RegistrationError as exc:
|
except RegistrationError as exc:
|
||||||
status = 409 if exc.code == "handle_taken" else 400
|
raise_api_error(_REGISTRATION_ERROR_STATUS.get(exc.code, 400), exc.code, exc.message)
|
||||||
raise_api_error(status, exc.code, exc.message)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _establish_session(request, response, user)
|
await _establish_session(request, response, user)
|
||||||
return _user_out(user)
|
return _user_out(user)
|
||||||
|
|||||||
@@ -55,6 +55,28 @@ class UserOut(BaseModel):
|
|||||||
recovery_email: str | None
|
recovery_email: str | None
|
||||||
settings: dict[str, object]
|
settings: dict[str, object]
|
||||||
totp_enabled: bool = False
|
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):
|
class MessageOut(BaseModel):
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
|
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
|
||||||
from app.identity.passwords import hash_password, verify_password
|
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
|
from app.redis_client import get_redis
|
||||||
|
|
||||||
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
|
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
|
||||||
@@ -28,6 +29,15 @@ class RegistrationError(Exception):
|
|||||||
self.message = message
|
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)
|
@dataclass(frozen=True)
|
||||||
class RateLimited(Exception):
|
class RateLimited(Exception):
|
||||||
"""Raised when the login rate limit is exceeded."""
|
"""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}"
|
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(
|
async def register_user(
|
||||||
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
|
||||||
) -> User:
|
) -> 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:
|
try:
|
||||||
handle = full_handle(localpart)
|
handle = full_handle(localpart)
|
||||||
except InvalidHandle as exc:
|
except InvalidHandle as exc:
|
||||||
raise RegistrationError(exc.code, exc.message) from 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(
|
user = User(
|
||||||
handle=handle,
|
handle=handle,
|
||||||
password_hash=hash_password(password),
|
password_hash=hash_password(password),
|
||||||
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
|
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)
|
db.add(user)
|
||||||
try:
|
try:
|
||||||
@@ -71,6 +126,8 @@ async def register_user(
|
|||||||
except IntegrityError as exc:
|
except IntegrityError as exc:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
raise RegistrationError("handle_taken", "that handle is already registered") from exc
|
raise RegistrationError("handle_taken", "that handle is already registered") from exc
|
||||||
|
if bootstrap:
|
||||||
|
settings.registration_open = False
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
BigInteger,
|
BigInteger,
|
||||||
|
CheckConstraint,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
Integer,
|
Integer,
|
||||||
@@ -45,6 +46,32 @@ def _now() -> Mapped[dt.datetime]:
|
|||||||
return mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=text("now()"))
|
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):
|
class User(Base):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
@@ -61,6 +88,9 @@ class User(Base):
|
|||||||
quota_bytes: Mapped[int] = mapped_column(
|
quota_bytes: Mapped[int] = mapped_column(
|
||||||
BigInteger, nullable=False, server_default=text("5368709120")
|
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()
|
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
|
||||||
+36
-6
@@ -33,14 +33,34 @@ grn() { printf '\033[32m%s\033[0m\n' "$*"; }
|
|||||||
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
ylw() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||||
die() { red "error: $*" >&2; exit 1; }
|
die() { red "error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
# Which compose is on this box. Synology DSM and QNAP QTS ship the standalone
|
# QNAP keeps Container Station's `docker` and `docker-compose` inside the QPKG
|
||||||
# `docker-compose` binary and have no `docker compose` subcommand, so detect
|
# directory, which is NOT on the PATH of an ordinary SSH session — the script
|
||||||
# rather than assume. COMPOSE_WAIT is empty on v1, which has no `--wait`; the
|
# would otherwise die with "no compose found" on a box that has both. The volume
|
||||||
# script's own health_gate covers that case.
|
# 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_BIN=()
|
||||||
COMPOSE_WAIT=""
|
COMPOSE_WAIT=""
|
||||||
detect_compose() {
|
detect_compose() {
|
||||||
[ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0
|
[ "${#COMPOSE_BIN[@]}" -gt 0 ] && return 0
|
||||||
|
ensure_docker_on_path
|
||||||
if docker compose version >/dev/null 2>&1; then
|
if docker compose version >/dev/null 2>&1; then
|
||||||
COMPOSE_BIN=(docker compose)
|
COMPOSE_BIN=(docker compose)
|
||||||
elif command -v docker-compose >/dev/null 2>&1; then
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
@@ -242,6 +262,7 @@ cmd_doctor() {
|
|||||||
echo " DATA_ROOT ${DATA_ROOT}"
|
echo " DATA_ROOT ${DATA_ROOT}"
|
||||||
echo " APP_DOMAIN ${APP_DOMAIN:-<unset>}"
|
echo " APP_DOMAIN ${APP_DOMAIN:-<unset>}"
|
||||||
echo " WEB_PORT ${WEB_PORT:-8088}"
|
echo " WEB_PORT ${WEB_PORT:-8088}"
|
||||||
|
echo " ADMIN_PORT ${ADMIN_PORT:-8089} (keep this one LAN-only)"
|
||||||
echo " SEARCH_BACKEND ${SEARCH_BACKEND:-pg}"
|
echo " SEARCH_BACKEND ${SEARCH_BACKEND:-pg}"
|
||||||
echo " IMAGE_TAG ${IMAGE_TAG:-local}"
|
echo " IMAGE_TAG ${IMAGE_TAG:-local}"
|
||||||
echo
|
echo
|
||||||
@@ -297,8 +318,13 @@ cmd_doctor() {
|
|||||||
else grn " ${avail} MB free on ${DATA_ROOT}"; fi
|
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
|
if command -v ss >/dev/null 2>&1; then
|
||||||
ylw " port ${WEB_PORT:-8088} already has a listener — change WEB_PORT if that is not us"
|
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
|
fi
|
||||||
|
|
||||||
# chmod 700 and owned by uid 999, so count them as root in a container.
|
# chmod 700 and owned by uid 999, so count them as root in a container.
|
||||||
@@ -341,6 +367,7 @@ then re-run this command."
|
|||||||
health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs"
|
health_gate || die "services did not come up healthy — check: ./deploy/webmail.sh logs"
|
||||||
record_version "${IMAGE_TAG:-local}"
|
record_version "${IMAGE_TAG:-local}"
|
||||||
grn "up. open http://<this-host>:${WEB_PORT:-8088}/"
|
grn "up. open http://<this-host>:${WEB_PORT:-8088}/"
|
||||||
|
echo " admin: http://<this-host>:${ADMIN_PORT:-8089}/ — restrict this port to your LAN"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
|
cmd_down() { require_env; compose --profile meili down; grn "stopped (data kept in ${DATA_ROOT})"; }
|
||||||
@@ -526,6 +553,9 @@ usage() { awk 'NR>1 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "${
|
|||||||
|
|
||||||
cmd="${1:-}"; shift || true
|
cmd="${1:-}"; shift || true
|
||||||
SUBCOMMAND="$cmd"
|
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
|
case "$cmd" in
|
||||||
init) cmd_init "$@" ;;
|
init) cmd_init "$@" ;;
|
||||||
doctor) cmd_doctor "$@" ;;
|
doctor) cmd_doctor "$@" ;;
|
||||||
|
|||||||
+10
-2
@@ -294,9 +294,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
# The ONLY published port. Bind to a specific NAS interface by setting
|
# Bind to a specific NAS interface by setting WEB_BIND / ADMIN_BIND
|
||||||
# WEB_BIND (e.g. 192.168.1.185) to keep it off other networks.
|
# (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"
|
- "${WEB_BIND:-0.0.0.0}:${WEB_PORT:-8088}:80"
|
||||||
|
- "${ADMIN_BIND:-0.0.0.0}:${ADMIN_PORT:-8089}:81"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz | grep -q ok"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -31,10 +31,19 @@ POSTGRES_PASSWORD=
|
|||||||
APP_DOMAIN=mail.home.lan
|
APP_DOMAIN=mail.home.lan
|
||||||
|
|
||||||
# --- Network ----------------------------------------------------------------
|
# --- 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
|
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
|
# Bind to one NAS interface instead of every network, e.g. WEB_BIND=192.168.1.185
|
||||||
WEB_BIND=0.0.0.0
|
WEB_BIND=0.0.0.0
|
||||||
|
ADMIN_BIND=0.0.0.0
|
||||||
|
|
||||||
TZ=Europe/Warsaw
|
TZ=Europe/Warsaw
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ LABEL org.opencontainers.image.title="webmail-web" \
|
|||||||
org.opencontainers.image.revision="${GIT_SHA}"
|
org.opencontainers.image.revision="${GIT_SHA}"
|
||||||
|
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
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
|
COPY --from=builder /src/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Surface the running version to `deploy/webmail.sh status` without needing the API.
|
# 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
|
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
|
# This file is dropped into /etc/nginx/conf.d/, i.e. it is already inside the
|
||||||
# `http` block — hence the bare `map` below.
|
# `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 {
|
map $http_x_forwarded_proto $fwd_proto {
|
||||||
default $scheme;
|
default $scheme;
|
||||||
https https;
|
https https;
|
||||||
@@ -24,81 +39,48 @@ upstream webmail_api {
|
|||||||
keepalive 16;
|
keepalive 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---- :80 — public -----------------------------------------------------------
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
# Regex locations beat prefix ones, so this wins over `location /api/`
|
||||||
index index.html;
|
# regardless of order. `(/|$)` keeps it to /api/admin and /api/admin/...,
|
||||||
|
# never a future /api/administrators. 404 rather than 403: a port open to
|
||||||
# Attachment uploads. Keep this comfortably above MAX_ATTACHMENT_MB (default
|
# the internet shouldn't confirm which endpoints exist.
|
||||||
# 25) to leave room for multipart/base64 overhead.
|
location ~ ^/api/admin(/|$) {
|
||||||
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;
|
access_log off;
|
||||||
return 200 "ok\n";
|
return 404;
|
||||||
add_header Content-Type text/plain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# index.html itself must never be cached, or a deploy won't be picked up.
|
# ---- :81 — admin ------------------------------------------------------------
|
||||||
location = /index.html {
|
server {
|
||||||
|
listen 81;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
include /etc/nginx/webmail-app.conf;
|
||||||
|
|
||||||
|
location = /admin.html {
|
||||||
add_header Cache-Control "no-store, must-revalidate";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+292
-22
@@ -3,10 +3,12 @@
|
|||||||
This is the operator runbook: how to install it, how to keep your mail safe from
|
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.
|
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:
|
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
|
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 |
|
| `postgres` | the database | no |
|
||||||
| `redis` | job queues + sync leases | no |
|
| `redis` | job queues + sync leases | no |
|
||||||
| `backup` | nightly verified database dump | 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 | — |
|
| `migrate`, `init` | run once at start, then exit | — |
|
||||||
|
|
||||||
Plugins (calendar, contacts, labels, filters) are baked into the backend image
|
Plugins (calendar, contacts, labels, filters) are baked into the backend image
|
||||||
@@ -43,7 +45,13 @@ Docker named volume:
|
|||||||
| NAS | Use |
|
| NAS | Use |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Synology | `/volume1/docker/webmail/data` |
|
| 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,
|
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
|
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
|
DATA_ROOT=/volume1/docker/webmail/data # or /share/Container/webmail/data
|
||||||
APP_DOMAIN=mail.home.lan # your handles become you@this
|
APP_DOMAIN=mail.home.lan # your handles become you@this
|
||||||
TZ=Europe/Warsaw
|
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
|
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.
|
> ### 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
|
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
|
Open `http://your-nas:8088/`, create your account, and add a mail account
|
||||||
through the wizard.
|
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
|
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
|
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
|
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
|
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
|
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
|
**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
|
**Exited (0)**. That is correct — they are one-shot jobs that did their work and
|
||||||
@@ -198,11 +211,103 @@ 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
|
and will not roll back if the new version fails to start. The `update` task
|
||||||
above does all three. Use it.
|
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/claude/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 update v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
> 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
|
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
|
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 +322,7 @@ Containers are disposable. These are not:
|
|||||||
| 3 | **`MASTER_KEY`** | `.env` + your password manager | Every saved mail-account password, permanently |
|
| 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,
|
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
|
### Backups
|
||||||
|
|
||||||
@@ -316,14 +421,100 @@ docker load -i webmail-v1.0.0.tar && ./deploy/webmail.sh update v1.0.0
|
|||||||
|
|
||||||
### 5.1 Read this first
|
### 5.1 Read this first
|
||||||
|
|
||||||
**Anyone who can open the page can create an account on it.** Registration is
|
**Registration is open only until the first account exists.** On a fresh install
|
||||||
open by design (it's a personal server), so do not put this straight on the
|
the signup page is available to anyone who can reach the port; whoever uses it
|
||||||
public internet. Pick one:
|
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
|
- **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
|
- **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
|
### 5.2 HTTPS through the NAS reverse proxy
|
||||||
|
|
||||||
@@ -401,7 +592,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
|
**Symptom:** everything reports healthy, but `migrate` fails with an asyncpg
|
||||||
`TimeoutError`, or services intermittently cannot reach `postgres:5432`. Testing
|
`TimeoutError`, or services intermittently cannot reach `postgres:5432`. Testing
|
||||||
@@ -459,7 +658,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`.
|
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.
|
Yes, for this deployment, and the scope is narrower than it first sounds.
|
||||||
|
|
||||||
@@ -474,7 +673,9 @@ What does **not** change:
|
|||||||
bridged, so it still traverses `FORWARD` and Docker's
|
bridged, so it still traverses `FORWARD` and Docker's
|
||||||
`DOCKER-ISOLATION-STAGE-1/2` rules still apply.
|
`DOCKER-ISOLATION-STAGE-1/2` rules still apply.
|
||||||
- **Traffic from your LAN or the internet into a published port.** Still routed,
|
- **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.
|
- **Traffic from containers out to the network.** Still routed and filtered.
|
||||||
- **DSM's own protection.** The `INPUT` chain, which guards the NAS itself, is
|
- **DSM's own protection.** The `INPUT` chain, which guards the NAS itself, is
|
||||||
untouched.
|
untouched.
|
||||||
@@ -484,6 +685,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
|
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.
|
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
|
## 9. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
| Symptom | Cause | Fix |
|
||||||
@@ -491,8 +757,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` |
|
| `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 |
|
| 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 |
|
| 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` |
|
| "port is already allocated" | 8088/8089 taken by another app | change `WEB_PORT` / `ADMIN_PORT` |
|
||||||
| `migrate` fails with asyncpg TimeoutError | DSM firewall dropping container traffic (§8) | `sysctl -w net.bridge.bridge-nf-call-iptables=0` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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.noAccount": "No account?",
|
||||||
"auth.haveAccount": "Already have an account?",
|
"auth.haveAccount": "Already have an account?",
|
||||||
"auth.register": "Register",
|
"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.mail": "Mail",
|
||||||
"nav.back": "Back",
|
"nav.back": "Back",
|
||||||
@@ -399,6 +406,27 @@ export const en = {
|
|||||||
"settings.addToCalendar": "Add to calendar",
|
"settings.addToCalendar": "Add to calendar",
|
||||||
"settings.alsoFile": "Also mark the message as read and file it",
|
"settings.alsoFile": "Also mark the message as read and file it",
|
||||||
"settings.integrations": "Integrations",
|
"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.jitsi": "Jitsi Meet",
|
||||||
"settings.jitsiHint":
|
"settings.jitsiHint":
|
||||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
"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 {
|
import type {
|
||||||
AccountDetail,
|
AccountDetail,
|
||||||
|
AdminSettings,
|
||||||
AccountOut,
|
AccountOut,
|
||||||
AttachmentOut,
|
AttachmentOut,
|
||||||
CalendarOut,
|
CalendarOut,
|
||||||
@@ -13,6 +14,7 @@ import type {
|
|||||||
MailboxOut,
|
MailboxOut,
|
||||||
QuotaOut,
|
QuotaOut,
|
||||||
RecipientSuggestion,
|
RecipientSuggestion,
|
||||||
|
RegistrationStatus,
|
||||||
RemoteDraftOut,
|
RemoteDraftOut,
|
||||||
SavedSearch,
|
SavedSearch,
|
||||||
SearchOut,
|
SearchOut,
|
||||||
@@ -77,6 +79,11 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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) =>
|
register: (localpart: string, password: string) =>
|
||||||
apiFetch<UserOut>("/auth/register", {
|
apiFetch<UserOut>("/auth/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ export interface UserOut {
|
|||||||
recovery_email: string | null;
|
recovery_email: string | null;
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
totp_enabled: boolean;
|
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 {
|
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 { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ export function Login() {
|
|||||||
const [needTotp, setNeedTotp] = useState(false);
|
const [needTotp, setNeedTotp] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
|
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
|
||||||
@@ -80,12 +82,14 @@ export function Login() {
|
|||||||
</button>
|
</button>
|
||||||
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
{showError && <p className="text-sm text-red-500">{error.message}</p>}
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-app-muted">
|
{registration.data?.open && (
|
||||||
{t("auth.noAccount")}{" "}
|
<p className="mt-4 text-sm text-app-muted">
|
||||||
<Link className="text-app-accent" to="/register">
|
{t("auth.noAccount")}{" "}
|
||||||
{t("auth.register")}
|
<Link className="text-app-accent" to="/register">
|
||||||
</Link>
|
{t("auth.register")}
|
||||||
</p>
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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 { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useT } from "../i18n";
|
||||||
import { api, ApiError } from "../lib/api";
|
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() {
|
export function Register() {
|
||||||
|
const t = useT();
|
||||||
const [localpart, setLocalpart] = useState("");
|
const [localpart, setLocalpart] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: () => api.register(localpart, password),
|
mutationFn: () => api.register(localpart, password),
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
queryClient.setQueryData(["me"], user);
|
queryClient.setQueryData(["me"], user);
|
||||||
|
// The first signup closes registration behind itself; drop the cached answer.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["registration"] });
|
||||||
navigate("/mail");
|
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 (
|
return (
|
||||||
<div className="mx-auto mt-24 max-w-sm px-4">
|
<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
|
<form
|
||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -29,16 +72,16 @@ export function Register() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<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}
|
||||||
placeholder="handle (3–30 chars, a-z 0-9 . _ -)"
|
placeholder={t("auth.handlePlaceholder")}
|
||||||
value={localpart}
|
value={localpart}
|
||||||
onChange={(e) => setLocalpart(e.target.value)}
|
onChange={(e) => setLocalpart(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<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"
|
type="password"
|
||||||
placeholder="password (min 8 chars)"
|
placeholder={t("auth.password")}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -47,18 +90,13 @@ export function Register() {
|
|||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{mutation.isPending ? "Creating…" : "Create account"}
|
{mutation.isPending ? t("auth.creating") : t("auth.create")}
|
||||||
</button>
|
</button>
|
||||||
{mutation.error instanceof ApiError && (
|
{mutation.error instanceof ApiError && (
|
||||||
<p className="text-sm text-red-500">{mutation.error.message}</p>
|
<p className="text-sm text-red-500">{mutation.error.message}</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-app-muted">
|
{signInLink}
|
||||||
Already have an account?{" "}
|
|
||||||
<Link className="text-app-accent" to="/login">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// SPEC dev-environment §5: Vite dev proxy /api -> :8000 with ws:true for /api/ws.
|
// SPEC dev-environment §5: Vite dev proxy /api -> :8000 with ws:true for /api/ws.
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user