Files
webmail-client/backend/migrations/versions/0025_hosted_mailboxes.py
T

76 lines
2.8 KiB
Python
Raw Normal View History

"""hosted mailboxes: native accounts and the mail server's directory (SPEC App. D)
* ``accounts.kind`` — ``external`` (any IMAP/SMTP server, the user's own
password in the vault) or ``native`` (the user's mailbox on the hosted mail
server: permanent, signed in to without any stored password, §D.4).
* ``mail_directory`` — the view the mail server's SQL directory reads (§D.3):
one row per user, its address — and no password: the webmail password never
opens the mailbox in a mail app, only app passwords do (the mail server's
own, §D.5) — and ``active`` false for a blocked user. Plus the service accounts of
``mail_service_accounts``: the webmail's own worker, which may act for any
mailbox and nothing else.
* ``user_app_passwords.server_id`` — the mail server's id of the same app
password (§D.5), to revoke it there too.
The mail server reads the view as its own role, given SELECT on it alone by
``app.hosted.provision`` (the role is created there, with its password).
Revision ID: 0025
Revises: 0024
Create Date: 2026-10-11
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0025"
down_revision: str | None = "0024"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE accounts ADD COLUMN kind text NOT NULL DEFAULT 'external'")
op.execute(
"ALTER TABLE accounts ADD CONSTRAINT ck_accounts_kind "
"CHECK (kind IN ('external', 'native'))"
)
# One mailbox per user.
op.execute("CREATE UNIQUE INDEX uq_accounts_native ON accounts (user_id) WHERE kind = 'native'")
op.execute("ALTER TABLE user_app_passwords ADD COLUMN server_id text")
op.execute(
"""
CREATE TABLE mail_service_accounts (
name citext PRIMARY KEY,
secret text NOT NULL,
description text NOT NULL DEFAULT ''
)
"""
)
op.execute(
"""
CREATE VIEW mail_directory AS
SELECT u.handle AS name,
NULL::text AS secret,
'individual'::text AS type,
u.handle::text AS description,
u.blocked_at IS NULL AS active
FROM users u
UNION ALL
SELECT s.name, s.secret, 'individual'::text, s.description, true
FROM mail_service_accounts s
"""
)
def downgrade() -> None:
op.execute("DROP VIEW IF EXISTS mail_directory")
op.execute("DROP TABLE IF EXISTS mail_service_accounts")
op.execute("ALTER TABLE user_app_passwords DROP COLUMN IF EXISTS server_id")
op.execute("DROP INDEX IF EXISTS uq_accounts_native")
op.execute("ALTER TABLE accounts DROP CONSTRAINT IF EXISTS ck_accounts_kind")
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS kind")