mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-14 15:19:26 +00:00
* fix(email): serialize default account mutations * fix(email): enforce default account invariant --------- Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
182 lines
6 KiB
Python
Executable file
182 lines
6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Create/remove the switchable 'Demo' EmailAccount in Odysseus.
|
|
|
|
Mirrors the existing local-Dovecot account (localhost:31143, STARTTLS) but points
|
|
at the throwaway demo@odysseus.local mailbox. Password is stored Fernet-encrypted
|
|
via the app's own secret_storage, exactly like real accounts.
|
|
|
|
python demo_account.py setup # add (or update) the 'Demo' account
|
|
python demo_account.py teardown # remove it
|
|
|
|
Run from the repo root so the app's modules import cleanly.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
# Make repo root importable regardless of CWD.
|
|
ROOT = Path(__file__).resolve().parent.parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from core.database import ( # noqa: E402
|
|
Base,
|
|
EmailAccount,
|
|
SessionLocal,
|
|
engine,
|
|
lock_email_account_owner_mutations,
|
|
)
|
|
from sqlalchemy import or_ # noqa: E402
|
|
from src.secret_storage import encrypt # noqa: E402
|
|
|
|
NAME = "Demo"
|
|
IMAP_USER = "demo@odysseus.local"
|
|
IMAP_PASSWORD = "demodemo"
|
|
# Owner empty-string => same list as the real Default account (switchable in the
|
|
# account dropdown).
|
|
OWNER = ""
|
|
|
|
|
|
def _owner_scope(query, owner: str):
|
|
if owner:
|
|
return query.filter(EmailAccount.owner == owner)
|
|
return query.filter(or_(EmailAccount.owner == None, EmailAccount.owner == "")) # noqa: E711
|
|
|
|
|
|
def _discover_demo_scopes() -> set[str]:
|
|
db = SessionLocal()
|
|
try:
|
|
return {
|
|
row.owner or ""
|
|
for row in db.query(EmailAccount).filter(
|
|
EmailAccount.name == NAME,
|
|
EmailAccount.imap_user == IMAP_USER,
|
|
).all()
|
|
}
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _lock_and_load_demo_rows(db, scopes: set[str]):
|
|
"""Reload Demo rows under every observed owner lock."""
|
|
scopes = set(scopes) or {OWNER}
|
|
while True:
|
|
lock_email_account_owner_mutations(db, *scopes)
|
|
rows = (
|
|
db.query(EmailAccount)
|
|
.filter(
|
|
EmailAccount.name == NAME,
|
|
EmailAccount.imap_user == IMAP_USER,
|
|
)
|
|
.order_by(EmailAccount.created_at.asc(), EmailAccount.id.asc())
|
|
.all()
|
|
)
|
|
current_scopes = {row.owner or "" for row in rows}
|
|
if current_scopes.issubset(scopes) or db.get_bind().dialect.name == "sqlite":
|
|
return rows
|
|
db.rollback()
|
|
scopes.update(current_scopes)
|
|
|
|
|
|
def _promote_oldest_enabled(db, owner: str, excluded_ids: list[str]) -> None:
|
|
remaining = _owner_scope(
|
|
db.query(EmailAccount).filter(
|
|
EmailAccount.enabled == True, # noqa: E712
|
|
~EmailAccount.id.in_(excluded_ids),
|
|
),
|
|
owner,
|
|
)
|
|
if remaining.filter(EmailAccount.is_default == True).first() is not None: # noqa: E712
|
|
return
|
|
promote = remaining.order_by(
|
|
EmailAccount.created_at.asc(), EmailAccount.id.asc()
|
|
).first()
|
|
if promote is not None:
|
|
promote.is_default = True
|
|
|
|
|
|
def setup() -> int:
|
|
Base.metadata.create_all(bind=engine)
|
|
scopes = _discover_demo_scopes() | {OWNER}
|
|
db = SessionLocal()
|
|
try:
|
|
rows = _lock_and_load_demo_rows(db, scopes)
|
|
acct = rows[0] if rows else None
|
|
if acct is None:
|
|
acct = EmailAccount(id=uuid.uuid4().hex, name=NAME)
|
|
db.add(acct)
|
|
old_scope = acct.owner or ""
|
|
was_default = bool(acct.is_default)
|
|
if old_scope != OWNER:
|
|
# Move a non-default row first so the unique index cannot see two
|
|
# defaults transiently while SQLAlchemy flushes the owner move and
|
|
# old-scope promotion in separate UPDATE statements.
|
|
acct.is_default = False
|
|
acct.owner = OWNER
|
|
db.flush()
|
|
if was_default:
|
|
_promote_oldest_enabled(db, old_scope, [acct.id])
|
|
|
|
target_default = _owner_scope(
|
|
db.query(EmailAccount).filter(
|
|
EmailAccount.id != acct.id,
|
|
EmailAccount.is_default == True, # noqa: E712
|
|
),
|
|
OWNER,
|
|
).first()
|
|
acct.owner = OWNER
|
|
# Keep Demo non-default when a real default exists. If it is the only
|
|
# enabled account, it must be default to preserve normal create
|
|
# semantics and avoid leaving the owner partition without one.
|
|
acct.is_default = target_default is None
|
|
acct.enabled = True
|
|
acct.imap_host = "localhost"
|
|
acct.imap_port = 31143
|
|
acct.imap_user = IMAP_USER
|
|
acct.imap_password = encrypt(IMAP_PASSWORD)
|
|
acct.imap_starttls = True
|
|
# Local-only: no real SMTP. Point at a dead local port so an accidental
|
|
# "Send" during the demo fails locally instead of mailing anyone.
|
|
acct.smtp_host = "localhost"
|
|
acct.smtp_port = 2525
|
|
acct.smtp_user = IMAP_USER
|
|
acct.smtp_password = encrypt(IMAP_PASSWORD)
|
|
acct.from_address = IMAP_USER
|
|
db.commit()
|
|
state = "default" if acct.is_default else "non-default"
|
|
print(f"'{NAME}' account ready (id={acct.id}, {state}, switchable).")
|
|
return 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def teardown() -> int:
|
|
scopes = _discover_demo_scopes()
|
|
db = SessionLocal()
|
|
try:
|
|
rows = _lock_and_load_demo_rows(db, scopes)
|
|
deleted_ids = [row.id for row in rows]
|
|
default_scopes = {row.owner or "" for row in rows if row.is_default}
|
|
for r in rows:
|
|
db.delete(r)
|
|
# Ensure the old default DELETE reaches the database before a
|
|
# replacement UPDATE; the unique index is enforced per statement.
|
|
db.flush()
|
|
for owner in default_scopes:
|
|
_promote_oldest_enabled(db, owner, deleted_ids)
|
|
db.commit()
|
|
print(f"removed {len(rows)} '{NAME}' account row(s).")
|
|
return 0
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
if cmd == "setup":
|
|
raise SystemExit(setup())
|
|
if cmd == "teardown":
|
|
raise SystemExit(teardown())
|
|
print(__doc__)
|
|
raise SystemExit(2)
|