diff --git a/.env.example b/.env.example
index c1c4b78..50a3111 100644
--- a/.env.example
+++ b/.env.example
@@ -91,6 +91,10 @@ POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=papyrus
+# Optional public deletion notice for operator-managed backup/exceptional retention.
+# Automated backups are not implemented. Planned future expiry: 30 days after deletion.
+# ACCOUNT_DELETION_RETENTION_NOTICE="Your server's actual retention policy"
+
# Optional: fine-grained GitHub token with Issues write access to PapyrusReader/client.
# Keep this secret on the server. Empty disables public support submissions.
# Attachment uploads also require the token owner to have repository write access.
diff --git a/alembic/versions/a8b9c0d1e234_order_account_notifications.py b/alembic/versions/a8b9c0d1e234_order_account_notifications.py
new file mode 100644
index 0000000..18a7b46
--- /dev/null
+++ b/alembic/versions/a8b9c0d1e234_order_account_notifications.py
@@ -0,0 +1,23 @@
+"""Keep lifecycle notices ordered independently of delivery retries."""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "a8b9c0d1e234"
+down_revision: str | Sequence[str] | None = "f7a8b9c0d123"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "account_notifications",
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("account_notifications", "created_at")
diff --git a/alembic/versions/e6f7a8b9c012_add_account_deletion.py b/alembic/versions/e6f7a8b9c012_add_account_deletion.py
new file mode 100644
index 0000000..3b2ebb0
--- /dev/null
+++ b/alembic/versions/e6f7a8b9c012_add_account_deletion.py
@@ -0,0 +1,34 @@
+"""Add scheduled account deletion and durable media cleanup."""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "e6f7a8b9c012"
+down_revision: str | Sequence[str] | None = "b5c6d7e8f901"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column("users", sa.Column("deletion_requested_at", sa.DateTime(timezone=True), nullable=True))
+ op.add_column("users", sa.Column("deletion_due_at", sa.DateTime(timezone=True), nullable=True))
+ op.create_index("ix_users_deletion_due_at", "users", ["deletion_due_at"])
+ op.add_column("auth_exchange_codes", sa.Column("code_challenge", sa.String(64), nullable=True))
+
+ op.create_table(
+ "account_media_cleanup",
+ sa.Column("user_id", sa.Uuid(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.PrimaryKeyConstraint("user_id"),
+ )
+
+
+def downgrade() -> None:
+ op.drop_table("account_media_cleanup")
+ op.drop_column("auth_exchange_codes", "code_challenge")
+ op.drop_index("ix_users_deletion_due_at", table_name="users")
+ op.drop_column("users", "deletion_due_at")
+ op.drop_column("users", "deletion_requested_at")
diff --git a/alembic/versions/f7a8b9c0d123_add_account_notifications.py b/alembic/versions/f7a8b9c0d123_add_account_notifications.py
new file mode 100644
index 0000000..5a1c171
--- /dev/null
+++ b/alembic/versions/f7a8b9c0d123_add_account_notifications.py
@@ -0,0 +1,36 @@
+"""Queue account lifecycle notifications transactionally."""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision: str = "f7a8b9c0d123"
+down_revision: str | Sequence[str] | None = "e6f7a8b9c012"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "account_notifications",
+ sa.Column("notification_id", sa.Uuid(), nullable=False),
+ sa.Column("user_id", sa.Uuid(), nullable=False),
+ sa.Column("recipient", sa.String(255), nullable=False),
+ sa.Column("subject", sa.String(255), nullable=False),
+ sa.Column("text_body", sa.Text(), nullable=False),
+ sa.Column("html_body", sa.Text(), nullable=False),
+ sa.Column("attempts", sa.Integer(), server_default=sa.text("0"), nullable=False),
+ sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(["user_id"], ["users.user_id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("notification_id"),
+ )
+
+ op.create_index("ix_account_notifications_user_id", "account_notifications", ["user_id"])
+ op.create_index("ix_account_notifications_next_attempt_at", "account_notifications", ["next_attempt_at"])
+
+
+def downgrade() -> None:
+ op.drop_table("account_notifications")
diff --git a/papyrus/api/routes/__init__.py b/papyrus/api/routes/__init__.py
index 30398bc..c24c05a 100644
--- a/papyrus/api/routes/__init__.py
+++ b/papyrus/api/routes/__init__.py
@@ -3,6 +3,7 @@
from fastapi import APIRouter, FastAPI
from papyrus.api.routes import (
+ account_deletion,
acquisition,
annotations,
auth,
@@ -29,6 +30,8 @@
api_router = APIRouter()
+api_router.include_router(account_deletion.router, prefix="/account-deletion", tags=["Account deletion"])
+
api_router.include_router(support.router, prefix="/support", tags=["Support"])
api_router.include_router(opds.router, prefix="/opds", tags=["OPDS"])
diff --git a/papyrus/api/routes/account_deletion.py b/papyrus/api/routes/account_deletion.py
new file mode 100644
index 0000000..b1eeef1
--- /dev/null
+++ b/papyrus/api/routes/account_deletion.py
@@ -0,0 +1,121 @@
+"""Public, narrowly authorized deletion and recovery routes."""
+
+from pathlib import Path
+from secrets import token_urlsafe
+from typing import Annotated
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query, Request, Response
+from fastapi.responses import HTMLResponse, RedirectResponse
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from papyrus.api.deps import CurrentSessionId, CurrentUserId
+from papyrus.api.routes.auth import _auth_rate_limit, _auth_tokens_response, _public_callback_url
+from papyrus.config import get_settings
+from papyrus.core.database import get_db
+from papyrus.core.rate_limit import limiter
+from papyrus.schemas.account_deletion import (
+ AccountActionRequest,
+ AccountDeletionAuthorization,
+ AccountDeletionStatus,
+ AccountGoogleExchangeRequest,
+ AccountPasswordRequest,
+ AccountRecoveryRequest,
+)
+from papyrus.schemas.auth import AuthTokens
+from papyrus.services import account_deletion
+from papyrus.services.auth.google import build_google_account_authorization_url
+
+
+def no_store(response: Response) -> None:
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["Referrer-Policy"] = "no-referrer"
+
+
+router = APIRouter(dependencies=[Depends(no_store)])
+DBSession = Annotated[AsyncSession, Depends(get_db)]
+
+
+@router.get("", response_class=HTMLResponse, include_in_schema=False)
+async def account_deletion_page() -> HTMLResponse:
+ nonce = token_urlsafe(24)
+ template = Path(__file__).parents[2] / "web" / "account_deletion.html"
+
+ return HTMLResponse(
+ template.read_text().replace("__NONCE__", nonce),
+ headers={
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ "Content-Security-Policy": (
+ f"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; "
+ "connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
+ ),
+ },
+ )
+
+
+@router.post("/password", response_model=AccountDeletionAuthorization)
+@limiter.limit(_auth_rate_limit)
+async def authenticate_password(
+ request: Request, payload: AccountPasswordRequest, db: DBSession
+) -> AccountDeletionAuthorization:
+ return await account_deletion.authenticate_password(db, payload)
+
+
+@router.get("/google/start")
+@limiter.limit(_auth_rate_limit)
+async def google_start(
+ request: Request,
+ redirect_uri: str,
+ challenge: str = Query(pattern=r"^[0-9a-f]{64}$"),
+ expected_user_id: UUID | None = None,
+) -> RedirectResponse:
+ url = build_google_account_authorization_url(
+ redirect_uri,
+ _public_callback_url(request, "google_oauth_callback"),
+ challenge,
+ expected_user_id,
+ )
+
+ return RedirectResponse(url, status_code=302)
+
+
+@router.post("/exchange", response_model=AccountDeletionAuthorization)
+@limiter.limit(_auth_rate_limit)
+async def exchange(
+ request: Request, payload: AccountGoogleExchangeRequest, db: DBSession
+) -> AccountDeletionAuthorization:
+ return await account_deletion.exchange_google(db, payload.code, payload.verifier)
+
+
+@router.post("/schedule", response_model=AccountDeletionStatus)
+@limiter.limit(_auth_rate_limit)
+async def schedule(request: Request, payload: AccountActionRequest, db: DBSession) -> AccountDeletionStatus:
+ return await account_deletion.schedule_deletion(db, payload.authorization_token)
+
+
+@router.post("/current", response_model=AccountDeletionStatus)
+@limiter.limit(_auth_rate_limit)
+async def schedule_current(
+ request: Request, db: DBSession, user_id: CurrentUserId, session_id: CurrentSessionId
+) -> AccountDeletionStatus:
+ return await account_deletion.schedule_current_account(db, user_id, session_id)
+
+
+@router.post("/cancel", response_model=AuthTokens)
+@limiter.limit(_auth_rate_limit)
+async def cancel(request: Request, payload: AccountRecoveryRequest, db: DBSession) -> AuthTokens:
+ result = await account_deletion.cancel_deletion(
+ db,
+ payload.authorization_token,
+ client_type=payload.client_type,
+ device_label=payload.device_label,
+ user_agent=request.headers.get("user-agent"),
+ )
+
+ return await _auth_tokens_response(db, result)
+
+
+@router.get("/policy")
+async def retention_policy() -> dict[str, str]:
+ return {"retention_notice": get_settings().account_deletion_retention_notice}
diff --git a/papyrus/api/routes/auth.py b/papyrus/api/routes/auth.py
index f390328..e601840 100644
--- a/papyrus/api/routes/auth.py
+++ b/papyrus/api/routes/auth.py
@@ -32,6 +32,7 @@
from papyrus.schemas.common import MessageResponse
from papyrus.schemas.user import User
from papyrus.services import auth as auth_service
+from papyrus.services.users import user_response
router = APIRouter()
DBSession = Annotated[AsyncSession, Depends(get_db)]
@@ -41,13 +42,13 @@ def _auth_rate_limit() -> str:
return f"{get_settings().rate_limit_auth}/minute"
-def _auth_tokens_response(result: auth_service.AuthResult) -> AuthTokens:
+async def _auth_tokens_response(db: AsyncSession, result: auth_service.AuthResult) -> AuthTokens:
return AuthTokens(
access_token=result.access_token,
refresh_token=result.refresh_token,
token_type="Bearer",
expires_in=result.expires_in,
- user=User.model_validate(result.user),
+ user=await user_response(db, result.user),
)
@@ -86,7 +87,7 @@ async def register_user(
) -> AuthTokens:
"""Create a new user account with email and password."""
result = await auth_service.register_user(db, payload, request.headers.get("user-agent"))
- return _auth_tokens_response(result)
+ return await _auth_tokens_response(db, result)
@router.post(
@@ -102,7 +103,7 @@ async def login_user(
) -> AuthTokens:
"""Authenticate a user with email and password credentials."""
result = await auth_service.login_user(db, payload, request.headers.get("user-agent"))
- return _auth_tokens_response(result)
+ return await _auth_tokens_response(db, result)
@router.get(
@@ -161,7 +162,7 @@ async def exchange_code(
) -> AuthTokens:
"""Exchange a one-time OAuth handoff code for Papyrus tokens."""
result = await auth_service.exchange_login_code(db, payload, request.headers.get("user-agent"))
- return _auth_tokens_response(result)
+ return await _auth_tokens_response(db, result)
@router.post(
@@ -177,7 +178,7 @@ async def refresh_token(
) -> AuthTokens:
"""Exchange a valid refresh token for a new access token."""
result = await auth_service.refresh_tokens(db, payload)
- return _auth_tokens_response(result)
+ return await _auth_tokens_response(db, result)
@router.post(
@@ -246,7 +247,7 @@ async def complete_google_link(
) -> User:
"""Consume a one-time code and link the Google identity to the authenticated user."""
user = await auth_service.complete_google_link(db, user_id, request.code)
- return User.model_validate(user)
+ return await user_response(db, user)
@router.post(
diff --git a/papyrus/api/routes/users.py b/papyrus/api/routes/users.py
index 9ca5263..7ca81a9 100644
--- a/papyrus/api/routes/users.py
+++ b/papyrus/api/routes/users.py
@@ -2,14 +2,14 @@
from typing import Annotated
-from fastapi import APIRouter, Depends, Response, status
+from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from papyrus.api.deps import CurrentUserId
from papyrus.core.database import get_db
from papyrus.schemas.auth import ChangePasswordRequest
from papyrus.schemas.common import MessageResponse
-from papyrus.schemas.user import DeleteAccountRequest, UpdateUserRequest, User, UserPreferences
+from papyrus.schemas.user import UpdateUserRequest, User, UserPreferences
from papyrus.services import users as user_service
router = APIRouter()
@@ -24,7 +24,7 @@
async def get_current_user(user_id: CurrentUserId, db: DBSession) -> User:
"""Return the authenticated user's profile information."""
user = await user_service.get_user_profile(db, user_id)
- return User.model_validate(user)
+ return await user_service.user_response(db, user)
@router.patch(
@@ -39,22 +39,7 @@ async def update_current_user(
) -> User:
"""Update the authenticated user's profile information."""
user = await user_service.update_user_profile(db, user_id, request)
- return User.model_validate(user)
-
-
-@router.delete(
- "/me",
- status_code=status.HTTP_204_NO_CONTENT,
- summary="Delete current user account",
-)
-async def delete_current_user(
- user_id: CurrentUserId,
- request: DeleteAccountRequest,
- db: DBSession,
-) -> Response:
- """Disable the user account and revoke active sessions."""
- await user_service.delete_user_account(db, user_id, request.password)
- return Response(status_code=status.HTTP_204_NO_CONTENT)
+ return await user_service.user_response(db, user)
@router.get(
diff --git a/papyrus/config.py b/papyrus/config.py
index 3d2954b..0507ea4 100644
--- a/papyrus/config.py
+++ b/papyrus/config.py
@@ -24,6 +24,12 @@ class Settings(BaseSettings):
postgres_db: str
secret_key: str
algorithm: str
+ account_deletion_retention_notice: str = (
+ "Automated database and media backups are not currently implemented. "
+ "The planned backup retention limit is 30 days after permanent deletion. "
+ "Self-hosted operators must disclose any separate backups or exceptional retention."
+ )
+
access_token_expire_minutes: int
refresh_token_expire_days: int
rate_limit_auth: int
diff --git a/papyrus/main.py b/papyrus/main.py
index 817a5cb..d27b21b 100644
--- a/papyrus/main.py
+++ b/papyrus/main.py
@@ -22,7 +22,7 @@
from papyrus.core.dev_pages import DEV_PAGES_DIST_DIR, DEV_PAGES_STATIC_URL
from papyrus.core.exceptions import AppError
from papyrus.core.rate_limit import limiter, rate_limit_exceeded_handler
-from papyrus.services import acquisition_monitor
+from papyrus.services import account_deletion, acquisition_monitor
settings = get_settings()
@@ -44,6 +44,9 @@ def configure_logging() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan events."""
+ cleanup_task = asyncio.create_task(
+ account_deletion.run_cleanup(async_session_maker), name="account-deletion-cleanup"
+ )
monitor_task: asyncio.Task[None] | None = None
if settings.acquisition_enabled and settings.acquisition_import_root is not None:
@@ -58,6 +61,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
try:
yield
finally:
+ cleanup_task.cancel()
+
+ with suppress(asyncio.CancelledError):
+ await cleanup_task
+
if monitor_task is not None:
monitor_task.cancel()
diff --git a/papyrus/models/__init__.py b/papyrus/models/__init__.py
index 1d8c4d2..76e3807 100644
--- a/papyrus/models/__init__.py
+++ b/papyrus/models/__init__.py
@@ -1,6 +1,13 @@
from papyrus.core.database import Base
from papyrus.models.acquisition import AcquisitionEndpoint, AcquisitionJob, AcquisitionRule
-from papyrus.models.auth import AuthExchangeCode, AuthSession, EmailActionToken, PasswordCredential, UserIdentity
+from papyrus.models.auth import (
+ AccountNotification,
+ AuthExchangeCode,
+ AuthSession,
+ EmailActionToken,
+ PasswordCredential,
+ UserIdentity,
+)
from papyrus.models.library import (
SyncAnnotation,
SyncBookmark,
@@ -11,7 +18,7 @@
SyncTag,
SyncTombstone,
)
-from papyrus.models.media import MediaAsset
+from papyrus.models.media import AccountMediaCleanup, MediaAsset
from papyrus.models.powersync_demo import PowerSyncDemoItem
from papyrus.models.sync import SyncBook
from papyrus.models.tracking import SyncGoalPeriod, SyncReadingActivity, SyncReadingGoal
@@ -32,11 +39,13 @@
"AcquisitionEndpoint",
"AcquisitionJob",
"AcquisitionRule",
+ "AccountNotification",
"AuthExchangeCode",
"AuthSession",
"Base",
"EmailActionToken",
"MediaAsset",
+ "AccountMediaCleanup",
"PasswordCredential",
"PowerSyncDemoItem",
"SyncBook",
diff --git a/papyrus/models/auth.py b/papyrus/models/auth.py
index 8f6f6bb..10b303d 100644
--- a/papyrus/models/auth.py
+++ b/papyrus/models/auth.py
@@ -5,7 +5,7 @@
from datetime import datetime
from uuid import UUID, uuid4
-from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint, Uuid, func, text
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, Uuid, func, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from papyrus.core.database import Base
@@ -96,6 +96,7 @@ class AuthExchangeCode(Base):
email_verified_at_provider: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
+ code_challenge: Mapped[str | None] = mapped_column(String(64), nullable=True)
redirect_uri: Mapped[str] = mapped_column(Text, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -117,3 +118,20 @@ class EmailActionToken(Base):
from papyrus.models.user import User # noqa: E402
+
+
+class AccountNotification(Base):
+ """Pending notices are removed after delivery, expiry, or permanent account deletion."""
+
+ __tablename__ = "account_notifications"
+
+ notification_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True, default=uuid4)
+ user_id: Mapped[UUID] = mapped_column(ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True)
+ recipient: Mapped[str] = mapped_column(String(255), nullable=False)
+ subject: Mapped[str] = mapped_column(String(255), nullable=False)
+ text_body: Mapped[str] = mapped_column(Text, nullable=False)
+ html_body: Mapped[str] = mapped_column(Text, nullable=False)
+ attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default=text("0"))
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+ next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
diff --git a/papyrus/models/media.py b/papyrus/models/media.py
index 1dc2c77..0bc36b7 100644
--- a/papyrus/models/media.py
+++ b/papyrus/models/media.py
@@ -35,3 +35,12 @@ class MediaAsset(Base):
storage_path: Mapped[str] = mapped_column(String(2048), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+
+
+class AccountMediaCleanup(Base):
+ """Durable cleanup work surviving the account's cascading database deletion."""
+
+ __tablename__ = "account_media_cleanup"
+
+ user_id: Mapped[UUID] = mapped_column(Uuid, primary_key=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
diff --git a/papyrus/models/user.py b/papyrus/models/user.py
index f13b041..18d14e7 100644
--- a/papyrus/models/user.py
+++ b/papyrus/models/user.py
@@ -32,6 +32,9 @@ class User(Base):
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ deletion_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ deletion_due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
+
identities: Mapped[list[UserIdentity]] = relationship(
back_populates="user",
cascade="all, delete-orphan",
diff --git a/papyrus/schemas/account_deletion.py b/papyrus/schemas/account_deletion.py
new file mode 100644
index 0000000..2387755
--- /dev/null
+++ b/papyrus/schemas/account_deletion.py
@@ -0,0 +1,42 @@
+"""Restricted account deletion and recovery contracts."""
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, EmailStr, Field
+
+from papyrus.schemas.auth import ClientType
+
+
+class AccountPasswordRequest(BaseModel):
+ email: EmailStr
+ password: str = Field(min_length=1, max_length=128)
+ expected_user_id: UUID | None = None
+
+
+class AccountGoogleExchangeRequest(BaseModel):
+ code: str = Field(min_length=1, max_length=512)
+ verifier: str = Field(min_length=43, max_length=128)
+
+
+class AccountActionRequest(BaseModel):
+ authorization_token: str = Field(min_length=1, max_length=512)
+
+
+class AccountRecoveryRequest(AccountActionRequest):
+ client_type: ClientType = "unknown"
+ device_label: str | None = Field(default=None, max_length=255)
+
+
+class AccountDeletionAuthorization(BaseModel):
+ authorization_token: str
+ user_id: UUID
+ email: str | None
+ deletion_pending: bool
+ deletion_due_at: datetime
+ expires_at: datetime
+
+
+class AccountDeletionStatus(BaseModel):
+ deletion_due_at: datetime | None
+ message: str
diff --git a/papyrus/schemas/user.py b/papyrus/schemas/user.py
index 95bf0a6..134609e 100644
--- a/papyrus/schemas/user.py
+++ b/papyrus/schemas/user.py
@@ -18,6 +18,7 @@ class User(BaseModel):
email_verified: bool = False
created_at: datetime
last_login_at: datetime | None = None
+ connected_providers: list[str] = Field(default_factory=list)
class UpdateUserRequest(BaseModel):
@@ -36,9 +37,3 @@ class UserPreferences(BaseModel):
notifications_enabled: bool | None = Field(None, examples=[True])
default_shelf: str | None = Field(None, examples=["Currently Reading"])
sync_wifi_only: bool | None = Field(None, examples=[False])
-
-
-class DeleteAccountRequest(BaseModel):
- """Account deletion request."""
-
- password: str = Field(..., description="Current password for confirmation")
diff --git a/papyrus/services/account_deletion.py b/papyrus/services/account_deletion.py
new file mode 100644
index 0000000..945ad50
--- /dev/null
+++ b/papyrus/services/account_deletion.py
@@ -0,0 +1,312 @@
+"""Reauthentication capabilities, explicit recovery, and retryable account erasure."""
+
+import asyncio
+import logging
+import secrets
+import shutil
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import NoReturn
+from uuid import UUID
+
+from sqlalchemy import delete, select, update
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from papyrus.config import get_settings
+from papyrus.core.exceptions import AppError, ForbiddenError, UnauthorizedError, ValidationError
+from papyrus.core.security import generate_opaque_token, hash_opaque_token, verify_password
+from papyrus.models import (
+ AccountMediaCleanup,
+ AuthExchangeCode,
+ AuthSession,
+ EmailActionToken,
+ PasswordCredential,
+ User,
+)
+from papyrus.schemas.account_deletion import AccountDeletionAuthorization, AccountDeletionStatus, AccountPasswordRequest
+from papyrus.services.account_notifications import deliver_pending_notifications, queue_account_notification
+from papyrus.services.auth._core import _create_session_for_user
+from papyrus.services.auth.types import AuthResult
+from papyrus.services.media import media_root
+
+RECOVERY_WINDOW = timedelta(days=30)
+AUTHORIZATION_LIFETIME = timedelta(minutes=5)
+AUTHORIZATION_PURPOSE = "account_deletion"
+GOOGLE_HANDOFF_PURPOSE = "account_deletion_google"
+logger = logging.getLogger(__name__)
+
+
+def _now() -> datetime:
+ return datetime.now(UTC)
+
+
+async def _lock_user(session: AsyncSession, user_id: UUID) -> User:
+ user = await session.scalar(
+ select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True)
+ )
+
+ if user is None:
+ raise UnauthorizedError("Account is unavailable")
+
+ if user.deletion_due_at is not None and user.deletion_due_at <= _now():
+ raise ForbiddenError("The recovery deadline has passed")
+
+ if user.disabled_at is not None and user.deletion_due_at is None:
+ raise ForbiddenError("User account is disabled")
+
+ return user
+
+
+async def _authorize(session: AsyncSession, user: User) -> AccountDeletionAuthorization:
+ now = _now()
+ token = generate_opaque_token()
+ expires_at = now + AUTHORIZATION_LIFETIME
+
+ session.add(
+ AuthExchangeCode(
+ code_hash=hash_opaque_token(token),
+ purpose=AUTHORIZATION_PURPOSE,
+ user_id=user.user_id,
+ redirect_uri="",
+ created_at=now,
+ expires_at=expires_at,
+ )
+ )
+
+ await session.commit()
+
+ return AccountDeletionAuthorization(
+ authorization_token=token,
+ user_id=user.user_id,
+ email=user.primary_email,
+ deletion_pending=user.deletion_due_at is not None,
+ deletion_due_at=user.deletion_due_at or now + RECOVERY_WINDOW,
+ expires_at=expires_at,
+ )
+
+
+async def require_account_recovery(session: AsyncSession, user_id: UUID) -> NoReturn:
+ """Offer recovery after successful sign-in without granting a session."""
+ user = await _lock_user(session, user_id)
+
+ if user.deletion_due_at is None:
+ raise UnauthorizedError("Account status changed. Sign in again.")
+
+ authorization = await _authorize(session, user)
+
+ raise AppError(
+ "This account is scheduled for deletion.",
+ code="ACCOUNT_DELETION_PENDING",
+ status_code=403,
+ details={"authorization": authorization.model_dump(mode="json")},
+ )
+
+
+async def authenticate_password(
+ session: AsyncSession,
+ request: AccountPasswordRequest,
+) -> AccountDeletionAuthorization:
+ result = await session.execute(
+ select(User.user_id, PasswordCredential.password_hash)
+ .join(PasswordCredential, PasswordCredential.user_id == User.user_id)
+ .where(User.primary_email == request.email.strip().lower())
+ )
+
+ credentials = result.one_or_none()
+
+ if credentials is None or not verify_password(request.password, credentials.password_hash):
+ raise UnauthorizedError("Invalid email or password")
+
+ if request.expected_user_id is not None and credentials.user_id != request.expected_user_id:
+ raise ForbiddenError("Authenticate with the account currently signed in")
+
+ user = await _lock_user(session, credentials.user_id)
+ return await _authorize(session, user)
+
+
+async def _lock_authorization(session: AsyncSession, token: str, purpose: str) -> tuple[User, AuthExchangeCode]:
+ """Lock the owner before the capability, matching scheduling and purge lock order."""
+ token_hash = hash_opaque_token(token)
+
+ user_id = await session.scalar(
+ select(AuthExchangeCode.user_id).where(
+ AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose
+ )
+ )
+
+ if user_id is None:
+ raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.")
+
+ user = await _lock_user(session, user_id)
+
+ authorization = await session.scalar(
+ select(AuthExchangeCode)
+ .where(AuthExchangeCode.code_hash == token_hash, AuthExchangeCode.purpose == purpose)
+ .with_for_update()
+ .execution_options(populate_existing=True)
+ )
+
+ if authorization is None or authorization.used_at is not None or authorization.expires_at <= _now():
+ raise UnauthorizedError("Authorization is invalid or expired. Authenticate again.")
+
+ return user, authorization
+
+
+async def exchange_google(session: AsyncSession, code: str, verifier: str) -> AccountDeletionAuthorization:
+ user, handoff = await _lock_authorization(session, code, GOOGLE_HANDOFF_PURPOSE)
+
+ if handoff.code_challenge is None or not secrets.compare_digest(
+ handoff.code_challenge, hash_opaque_token(verifier)
+ ):
+ raise UnauthorizedError("Google authorization does not match this browser session")
+
+ handoff.used_at = _now()
+ return await _authorize(session, user)
+
+
+async def _revoke_credentials(session: AsyncSession, user_id: UUID) -> None:
+ now = _now()
+ await session.execute(update(AuthSession).where(AuthSession.user_id == user_id).values(revoked_at=now))
+ await session.execute(update(AuthExchangeCode).where(AuthExchangeCode.user_id == user_id).values(used_at=now))
+ await session.execute(update(EmailActionToken).where(EmailActionToken.user_id == user_id).values(used_at=now))
+
+
+async def schedule_deletion(session: AsyncSession, token: str) -> AccountDeletionStatus:
+ user, authorization = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE)
+ return await _schedule_deletion(session, user, authorization.created_at)
+
+
+async def schedule_current_account(session: AsyncSession, user_id: UUID, session_id: UUID) -> AccountDeletionStatus:
+ """Serialize deletion with recovery and verify that the initiating session remains active."""
+ user = await _lock_user(session, user_id)
+
+ active_session = await session.scalar(
+ select(AuthSession)
+ .where(
+ AuthSession.session_id == session_id,
+ AuthSession.user_id == user_id,
+ AuthSession.revoked_at.is_(None),
+ AuthSession.expires_at > _now(),
+ )
+ .execution_options(populate_existing=True)
+ )
+
+ if active_session is None:
+ raise UnauthorizedError("Session is no longer active")
+
+ return await _schedule_deletion(session, user, _now())
+
+
+async def _schedule_deletion(session: AsyncSession, user: User, requested_at: datetime) -> AccountDeletionStatus:
+ if user.deletion_due_at is not None:
+ raise ValidationError("Deletion is already scheduled")
+
+ user.deletion_requested_at = requested_at
+ user.deletion_due_at = requested_at + RECOVERY_WINDOW
+ user.disabled_at = _now()
+ await _revoke_credentials(session, user.user_id)
+ await queue_account_notification(session, user)
+ await session.commit()
+
+ return AccountDeletionStatus(
+ deletion_due_at=user.deletion_due_at,
+ message="Account deletion scheduled. All sessions have been revoked. Local device data has not been deleted.",
+ )
+
+
+async def cancel_deletion(
+ session: AsyncSession,
+ token: str,
+ *,
+ client_type: str = "unknown",
+ device_label: str | None = None,
+ user_agent: str | None = None,
+) -> AuthResult:
+ """Consume the recovery proof and restore access with a new session in one transaction."""
+ user, _ = await _lock_authorization(session, token, AUTHORIZATION_PURPOSE)
+
+ if user.deletion_due_at is None:
+ raise ValidationError("No account deletion is scheduled")
+
+ user.deletion_requested_at = None
+ user.deletion_due_at = None
+ user.disabled_at = None
+ await _revoke_credentials(session, user.user_id)
+ result = await _create_session_for_user(session, user, client_type, device_label, user_agent)
+ await queue_account_notification(session, user)
+ await session.commit()
+ return result
+
+
+async def purge_due_accounts(session: AsyncSession) -> int:
+ """Commit cascade deletion and cleanup work atomically before touching physical files."""
+ users = (
+ await session.scalars(
+ select(User).where(User.deletion_due_at <= _now()).with_for_update(skip_locked=True).limit(100)
+ )
+ ).all()
+
+ for user in users:
+ session.add(AccountMediaCleanup(user_id=user.user_id))
+ await session.execute(delete(User).where(User.user_id == user.user_id))
+
+ await session.commit()
+ return len(users)
+
+
+def _remove_owner_files(user_id: UUID) -> None:
+ """Only remove the UUID-scoped directories, never follow an owner symlink."""
+ roots = [media_root()]
+ import_root = get_settings().acquisition_import_root
+
+ if import_root is not None:
+ roots.append(Path(import_root))
+
+ for root in roots:
+ path = root / str(user_id)
+
+ if path.is_symlink():
+ path.unlink()
+ elif path.exists():
+ shutil.rmtree(path)
+
+
+async def cleanup_deleted_media(session: AsyncSession) -> int:
+ cleaned = 0
+ user_ids = (await session.scalars(select(AccountMediaCleanup.user_id).limit(100))).all()
+ await session.commit()
+
+ for user_id in user_ids:
+ try:
+ job = await session.scalar(
+ select(AccountMediaCleanup)
+ .where(AccountMediaCleanup.user_id == user_id)
+ .with_for_update(skip_locked=True)
+ )
+
+ if job is None:
+ await session.rollback()
+ continue
+
+ await asyncio.to_thread(_remove_owner_files, user_id)
+ await session.delete(job)
+ await session.commit()
+ cleaned += 1
+ except Exception:
+ await session.rollback()
+ logger.exception("Account media cleanup failed and will be retried")
+
+ return cleaned
+
+
+async def run_cleanup(session_maker: async_sessionmaker[AsyncSession]) -> None:
+ while True:
+ try:
+ async with session_maker() as session:
+ await purge_due_accounts(session)
+ await cleanup_deleted_media(session)
+ await deliver_pending_notifications(session)
+ except Exception:
+ logger.exception("Account deletion cleanup cycle failed and will be retried")
+
+ await asyncio.sleep(60)
diff --git a/papyrus/services/account_notifications.py b/papyrus/services/account_notifications.py
new file mode 100644
index 0000000..3967990
--- /dev/null
+++ b/papyrus/services/account_notifications.py
@@ -0,0 +1,207 @@
+"""Branded account notices and a transactional, retryable delivery queue."""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+from urllib.parse import urlsplit
+
+from sqlalchemy import delete, exists, select, tuple_, update
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import aliased
+
+from papyrus.config import get_settings
+from papyrus.models import AccountNotification, User
+from papyrus.services import email
+
+logger = logging.getLogger(__name__)
+NOTICE_LIFETIME = timedelta(days=7)
+DELIVERY_LEASE = timedelta(minutes=5)
+
+
+@dataclass(frozen=True)
+class AccountEmail:
+ subject: str
+ text: str
+ html: str
+
+
+def _sign_in_url() -> str | None:
+ settings = get_settings()
+
+ if settings.app_public_base_url:
+ url = f"{settings.app_public_base_url.rstrip('/')}/login"
+ elif settings.public_base_url:
+ url = f"{settings.public_base_url.rstrip('/')}{settings.api_prefix}/account-deletion"
+ else:
+ return None
+
+ parsed = urlsplit(url)
+ return url if parsed.scheme in {"https", "http"} and parsed.netloc else None
+
+
+def render_account_email(display_name: str, deletion_due_at: datetime | None) -> AccountEmail:
+ """Keep email links informational: recovery still requires sign-in and explicit consent."""
+ greeting = f"Hi {display_name}," if display_name else "Hello,"
+ detail = None
+ deadline_text = ""
+
+ if deletion_due_at is not None:
+ deadline = deletion_due_at.astimezone(UTC).strftime("%B %d, %Y at %H:%M UTC")
+ subject = "Your Papyrus account is scheduled for deletion"
+ heading = "Your account is scheduled for deletion"
+ preheader = f"You can keep your account until {deadline}."
+ summary = (
+ "We received your request to delete your Papyrus account. "
+ "Your profile, synced library and reading data will be permanently deleted after the deadline below."
+ )
+
+ guidance = "Changed your mind? Sign in before this date and choose Keep account."
+
+ security_note = (
+ "If you didn't request this, sign in and choose Keep account before the deadline, "
+ "then secure the password or Google account you use to sign in."
+ )
+
+ action_label = "Sign in to keep your account"
+ deadline_text = f"Recovery deadline: {deadline}\n\n"
+ detail = ("RECOVERY DEADLINE", deadline)
+ else:
+ subject = "Your Papyrus account deletion was cancelled"
+ heading = "Your account is staying"
+ preheader = "Deletion has been cancelled. Your library and reading data are still here."
+ summary = "Your account deletion request has been cancelled. Your profile, library and reading data are safe."
+ guidance = "You can continue using Papyrus as usual. No further action is needed."
+
+ security_note = (
+ "If you didn't keep this account, secure the password or Google account you use to sign in. "
+ "You can review your account in Papyrus."
+ )
+
+ action_label = "Open Papyrus"
+
+ url = _sign_in_url()
+ action_text = f"{action_label}: {url}\n\n" if url is not None else ""
+
+ html = email.render_email_html(
+ subject=subject,
+ preheader=preheader,
+ heading=heading,
+ greeting=greeting,
+ summary=summary,
+ guidance=guidance,
+ security_note=security_note,
+ action_label=action_label,
+ action_url=url,
+ detail=detail,
+ )
+
+ text = (
+ f"Papyrus\n\n{heading}\n\n{greeting}\n\n{summary}\n\n"
+ f"{deadline_text}{guidance}\n\n{action_text}{security_note}\n\n"
+ "This is an automatic account notification from Papyrus."
+ )
+
+ return AccountEmail(subject=subject, text=text, html=html)
+
+
+async def queue_account_notification(session: AsyncSession, user: User) -> None:
+ """Keep a notice for each committed lifecycle event, including rapid deletion and recovery."""
+ if not user.primary_email or not user.primary_email_verified:
+ return
+
+ content = render_account_email(user.display_name, user.deletion_due_at)
+ now = datetime.now(UTC)
+ expires_at = now + NOTICE_LIFETIME
+
+ if user.deletion_due_at is not None:
+ expires_at = min(expires_at, user.deletion_due_at)
+
+ session.add(
+ AccountNotification(
+ user_id=user.user_id,
+ recipient=user.primary_email,
+ subject=content.subject,
+ text_body=content.text,
+ html_body=content.html,
+ created_at=now,
+ next_attempt_at=now,
+ expires_at=expires_at,
+ )
+ )
+
+
+async def deliver_pending_notifications(session: AsyncSession) -> int:
+ """Lease before SMTP so delivery never holds account locks or blocks recovery.
+
+ SMTP is at-least-once: a crash after acceptance but before deletion can repeat
+ a notice. Later notices for the same account wait for earlier deliveries or
+ their expiry. Pending messages expire after seven days and contain no auth tokens.
+ """
+ await session.execute(delete(AccountNotification).where(AccountNotification.expires_at <= datetime.now(UTC)))
+ await session.commit()
+
+ if not email.is_email_delivery_configured():
+ return 0
+
+ delivered = 0
+ earlier = aliased(AccountNotification)
+
+ has_earlier_notice = exists().where(
+ earlier.user_id == AccountNotification.user_id,
+ tuple_(earlier.created_at, earlier.notification_id)
+ < tuple_(AccountNotification.created_at, AccountNotification.notification_id),
+ )
+
+ for _ in range(100):
+ now = datetime.now(UTC)
+
+ notice = await session.scalar(
+ select(AccountNotification)
+ .where(
+ AccountNotification.next_attempt_at <= now,
+ AccountNotification.expires_at > now,
+ ~has_earlier_notice,
+ )
+ .order_by(AccountNotification.created_at, AccountNotification.notification_id)
+ .with_for_update(skip_locked=True)
+ .limit(1)
+ )
+
+ if notice is None:
+ await session.rollback()
+ break
+
+ notice.attempts += 1
+ notice.next_attempt_at = now + DELIVERY_LEASE
+ await session.commit()
+
+ try:
+ await asyncio.to_thread(
+ email.send_email,
+ notice.recipient,
+ notice.subject,
+ notice.text_body,
+ html_body=notice.html_body,
+ )
+ except Exception:
+ retry_at = datetime.now(UTC) + timedelta(seconds=min(60 * 2 ** min(notice.attempts - 1, 6), 3600))
+
+ await session.execute(
+ update(AccountNotification)
+ .where(AccountNotification.notification_id == notice.notification_id)
+ .values(next_attempt_at=retry_at)
+ )
+
+ await session.commit()
+ logger.warning("Account notification delivery failed and will be retried")
+ continue
+
+ await session.execute(
+ delete(AccountNotification).where(AccountNotification.notification_id == notice.notification_id)
+ )
+
+ await session.commit()
+ delivered += 1
+
+ return delivered
diff --git a/papyrus/services/acquisition_monitor.py b/papyrus/services/acquisition_monitor.py
index b265efa..4a333c9 100644
--- a/papyrus/services/acquisition_monitor.py
+++ b/papyrus/services/acquisition_monitor.py
@@ -15,7 +15,7 @@
from papyrus.config import get_settings
from papyrus.core.exceptions import AppError, ConflictError, ForbiddenError, NotFoundError, ValidationError
-from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset
+from papyrus.models import AcquisitionEndpoint, AcquisitionJob, MediaAsset, User
from papyrus.services import media as media_service
from papyrus.services.acquisition import QbittorrentClient, QbittorrentFile, QbittorrentTorrent
@@ -62,7 +62,9 @@ async def claim_due_jobs(
AcquisitionEndpoint,
AcquisitionEndpoint.endpoint_id == AcquisitionJob.endpoint_id,
)
+ .join(User, User.user_id == AcquisitionJob.owner_user_id)
.where(
+ User.disabled_at.is_(None),
AcquisitionEndpoint.kind == "qbittorrent",
AcquisitionJob.status.in_(ACTIVE_JOB_STATUSES),
AcquisitionJob.rule_id.is_(None),
diff --git a/papyrus/services/auth/_core.py b/papyrus/services/auth/_core.py
index ffcf86a..20dd47d 100644
--- a/papyrus/services/auth/_core.py
+++ b/papyrus/services/auth/_core.py
@@ -18,6 +18,7 @@
)
from papyrus.models import AuthExchangeCode, AuthSession, EmailActionToken, User
from papyrus.services.auth.types import AuthResult, GoogleIdentity
+from papyrus.services.email import render_email_html
def _now() -> datetime:
@@ -120,6 +121,7 @@ async def _create_exchange_code(
email_verified_at_provider: datetime | None = None,
display_name: str | None = None,
avatar_url: str | None = None,
+ code_challenge: str | None = None,
) -> str:
plain_code = generate_opaque_token()
@@ -135,6 +137,7 @@ async def _create_exchange_code(
display_name=display_name,
avatar_url=avatar_url,
redirect_uri=redirect_uri,
+ code_challenge=code_challenge,
expires_at=_now() + timedelta(minutes=get_settings().auth_exchange_code_expire_minutes),
)
)
@@ -278,3 +281,20 @@ def _password_reset_email_body(token: str) -> str:
)
return "\n".join(lines)
+
+
+def _password_reset_email_html(token: str, display_name: str) -> str:
+ expiry = get_settings().password_reset_token_expire_minutes
+
+ return render_email_html(
+ subject="Reset your Papyrus password",
+ preheader=f"Choose a new password. This link expires in {expiry} minutes.",
+ category="ACCOUNT SECURITY",
+ heading="Reset your password",
+ greeting=f"Hi {display_name}," if display_name else "Hello,",
+ summary="We received a request to reset your Papyrus password. Choose a new password using the button below.",
+ guidance=f"This link expires in {expiry} minutes and can only be used once.",
+ security_note="If you didn't request this, you can ignore this email. Your password will stay the same.",
+ action_label="Reset password",
+ action_url=_build_app_url("/reset-password", {"token": token}),
+ )
diff --git a/papyrus/services/auth/email_flows.py b/papyrus/services/auth/email_flows.py
index 58fc16a..d800096 100644
--- a/papyrus/services/auth/email_flows.py
+++ b/papyrus/services/auth/email_flows.py
@@ -17,6 +17,7 @@
_normalize_email,
_now,
_password_reset_email_body,
+ _password_reset_email_html,
_revoke_user_sessions,
_verification_email_body,
)
@@ -81,6 +82,7 @@ async def begin_password_reset(session: AsyncSession, email: str) -> str:
normalized_email,
"Reset your Papyrus password",
_password_reset_email_body(token),
+ html_body=_password_reset_email_html(token, user.display_name),
)
await session.commit()
diff --git a/papyrus/services/auth/google.py b/papyrus/services/auth/google.py
index 5c6b0f2..2dda05a 100644
--- a/papyrus/services/auth/google.py
+++ b/papyrus/services/auth/google.py
@@ -4,7 +4,7 @@
import json
from urllib.error import HTTPError, URLError
-from urllib.parse import urlencode, urlsplit
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from urllib.request import Request, urlopen
from uuid import UUID
@@ -109,6 +109,7 @@ def exchange_code_for_identity(self, code: str, callback_uri: str) -> GoogleIden
email_verified=bool(claims.get("email_verified", False)),
display_name=str(claims["name"]) if isinstance(claims.get("name"), str) else None,
avatar_url=str(claims["picture"]) if isinstance(claims.get("picture"), str) else None,
+ nonce=claims.get("nonce"),
)
@@ -176,6 +177,29 @@ def build_google_login_authorization_url(redirect_uri: str, callback_uri: str) -
return google_oauth_client.build_authorization_url(callback_uri, state)
+def build_google_account_authorization_url(
+ redirect_uri: str,
+ callback_uri: str,
+ challenge: str,
+ expected_user_id: UUID | None = None,
+) -> str:
+ _validate_oauth_redirect_uri(redirect_uri)
+
+ state = create_state_token(
+ {
+ "redirect_uri": redirect_uri,
+ "mode": "account_deletion",
+ "challenge": challenge,
+ "user_id": str(expected_user_id) if expected_user_id is not None else None,
+ }
+ )
+
+ url = urlsplit(google_oauth_client.build_authorization_url(callback_uri, state))
+ query = dict(parse_qsl(url.query))
+ query.update({"prompt": "select_account consent", "nonce": challenge})
+ return urlunsplit((url.scheme, url.netloc, url.path, urlencode(query), url.fragment))
+
+
async def build_google_link_authorization_url(user_id: UUID, redirect_uri: str, callback_uri: str) -> str:
_validate_oauth_redirect_uri(redirect_uri)
@@ -198,6 +222,9 @@ async def _resolve_google_login_user(session: AsyncSession, identity: GoogleIden
existing_identity = identity_result.scalar_one_or_none()
if existing_identity is not None:
+ if existing_identity.user.deletion_due_at is not None:
+ return existing_identity.user, None
+
if existing_identity.user.disabled_at is not None:
return None, "account_disabled"
@@ -262,6 +289,39 @@ async def handle_google_callback(
identity = google_oauth_client.exchange_code_for_identity(code, callback_uri)
mode = state.get("mode")
+ if mode == "account_deletion":
+ challenge = state.get("challenge")
+
+ if not isinstance(challenge, str) or identity.nonce != challenge:
+ raise ValidationError("Google authorization does not match this request")
+
+ user_id = await session.scalar(
+ select(UserIdentity.user_id).where(
+ UserIdentity.provider == GOOGLE_PROVIDER,
+ UserIdentity.provider_subject == identity.subject,
+ )
+ )
+
+ expected_user_id = state.get("user_id")
+
+ if user_id is None or (expected_user_id is not None and str(user_id) != expected_user_id):
+ return _build_redirect_uri(redirect_uri, {"error": "Use the Google account linked to your Papyrus account"})
+
+ from papyrus.services.account_deletion import GOOGLE_HANDOFF_PURPOSE, _lock_user
+
+ await _lock_user(session, user_id)
+
+ exchange_code = await _create_exchange_code(
+ session,
+ purpose=GOOGLE_HANDOFF_PURPOSE,
+ redirect_uri=redirect_uri,
+ user_id=user_id,
+ code_challenge=challenge,
+ )
+
+ await session.commit()
+ return _build_redirect_uri(redirect_uri, {"code": exchange_code})
+
if mode == "login":
user, callback_error = await _resolve_google_login_user(session, identity)
diff --git a/papyrus/services/auth/sessions.py b/papyrus/services/auth/sessions.py
index e2e6e5d..afbd1d7 100644
--- a/papyrus/services/auth/sessions.py
+++ b/papyrus/services/auth/sessions.py
@@ -88,12 +88,17 @@ async def login_user(
if user is None or user.password_credential is None:
raise UnauthorizedError("Invalid email or password")
- if user.disabled_at is not None:
- raise ForbiddenError("User account is disabled")
-
if not verify_password(request.password, user.password_credential.password_hash):
raise UnauthorizedError("Invalid email or password")
+ if user.deletion_due_at is not None:
+ from papyrus.services.account_deletion import require_account_recovery
+
+ await require_account_recovery(session, user.user_id)
+
+ if user.disabled_at is not None:
+ raise ForbiddenError("User account is disabled")
+
auth_result = await _create_session_for_user(
session,
user,
@@ -173,6 +178,13 @@ async def exchange_login_code(
if exchange_code.user_id is None:
raise UnauthorizedError("Exchange code is missing a user")
+ user = await session.get(User, exchange_code.user_id)
+
+ if user is not None and user.deletion_due_at is not None:
+ from papyrus.services.account_deletion import require_account_recovery
+
+ await require_account_recovery(session, user.user_id)
+
user = await _get_active_user(session, exchange_code.user_id)
auth_result = await _create_session_for_user(
diff --git a/papyrus/services/auth/types.py b/papyrus/services/auth/types.py
index 3594700..37594af 100644
--- a/papyrus/services/auth/types.py
+++ b/papyrus/services/auth/types.py
@@ -30,3 +30,4 @@ class GoogleIdentity:
email_verified: bool
display_name: str | None
avatar_url: str | None
+ nonce: str | None = None
diff --git a/papyrus/services/email.py b/papyrus/services/email.py
index ee4adbd..b8cc810 100644
--- a/papyrus/services/email.py
+++ b/papyrus/services/email.py
@@ -1,23 +1,83 @@
-"""SMTP email delivery service."""
+"""Shared transactional email styling and SMTP delivery."""
from __future__ import annotations
import smtplib
import ssl
from email.message import EmailMessage
+from html import escape
+from pathlib import Path
+from string import Template
from papyrus.config import get_settings
from papyrus.core.exceptions import ServiceUnavailableError, ValidationError
+def render_email_html(
+ *,
+ subject: str,
+ preheader: str,
+ heading: str,
+ greeting: str,
+ summary: str,
+ guidance: str,
+ security_note: str,
+ action_label: str,
+ action_url: str | None,
+ category: str = "ACCOUNT UPDATE",
+ detail: tuple[str, str] | None = None,
+) -> str:
+ """Escape message content and links inside the shared email layout."""
+ detail_html = ""
+
+ if detail is not None:
+ label, value = detail
+
+ detail_html = (
+ '
'
+ f'
{escape(label)}
'
+ f'
{escape(value)}
'
+ )
+
+ action_html = ""
+
+ if action_url is not None:
+ escaped_url = escape(action_url, quote=True)
+
+ action_html = (
+ '"
+ ''
+ f'Or open {escaped_url}
'
+ )
+
+ template = Template((Path(__file__).parents[1] / "templates" / "transactional_email.html").read_text())
+
+ return template.substitute(
+ subject=escape(subject),
+ preheader=escape(preheader),
+ category=escape(category),
+ heading=escape(heading),
+ greeting=escape(greeting),
+ summary=escape(summary),
+ detail=detail_html,
+ guidance=escape(guidance),
+ action=action_html,
+ security_note=escape(security_note),
+ )
+
+
def is_email_delivery_configured() -> bool:
"""Return whether SMTP-backed email delivery is configured."""
settings = get_settings()
return bool(settings.email_delivery_enabled and settings.smtp_host and settings.smtp_from_email)
-def send_email(recipient: str, subject: str, body: str) -> None:
- """Send a plain-text email through the configured SMTP server."""
+def send_email(recipient: str, subject: str, body: str, *, html_body: str | None = None) -> None:
+ """Send plain text with an optional HTML alternative through the configured SMTP server."""
settings = get_settings()
if not is_email_delivery_configured():
@@ -37,6 +97,10 @@ def send_email(recipient: str, subject: str, body: str) -> None:
message["To"] = recipient
message["Subject"] = subject
message.set_content(body)
+
+ if html_body is not None:
+ message.add_alternative(html_body, subtype="html")
+
smtp_host = settings.smtp_host
if smtp_host is None:
diff --git a/papyrus/services/media.py b/papyrus/services/media.py
index e1ed681..0554ce5 100644
--- a/papyrus/services/media.py
+++ b/papyrus/services/media.py
@@ -270,7 +270,12 @@ async def _used_bytes(session: AsyncSession, user_id: UUID) -> int:
async def _lock_user_uploads(session: AsyncSession, user_id: UUID) -> None:
"""Serialize quota and replacement decisions for one user's uploads."""
- await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update())
+ user = await session.scalar(
+ select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True)
+ )
+
+ if user is None or user.disabled_at is not None:
+ raise ForbiddenError("User account is unavailable")
async def _write_upload_to_temp_file(
diff --git a/papyrus/services/sync.py b/papyrus/services/sync.py
index b7d4bd5..9681a46 100644
--- a/papyrus/services/sync.py
+++ b/papyrus/services/sync.py
@@ -6,6 +6,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from papyrus.core.exceptions import ForbiddenError
from papyrus.models import User
from papyrus.schemas.sync import PowerSyncCrudMutation
from papyrus.services import media as media_service
@@ -14,7 +15,12 @@
async def lock_owner_mutations(session: AsyncSession, user_id: UUID) -> None:
"""Hold the owner's mutation lock until the surrounding transaction ends."""
- await session.execute(select(User.user_id).where(User.user_id == user_id).with_for_update())
+ user = await session.scalar(
+ select(User).where(User.user_id == user_id).with_for_update().execution_options(populate_existing=True)
+ )
+
+ if user is None or user.disabled_at is not None:
+ raise ForbiddenError("User account is unavailable")
async def apply_powersync_upload_batch(
diff --git a/papyrus/services/users.py b/papyrus/services/users.py
index 17b8f4d..0e4be63 100644
--- a/papyrus/services/users.py
+++ b/papyrus/services/users.py
@@ -10,8 +10,9 @@
from papyrus.core.exceptions import ForbiddenError, NotFoundError, UnauthorizedError, ValidationError
from papyrus.core.security import hash_password, verify_password
-from papyrus.models import AuthSession, PasswordCredential, User
+from papyrus.models import AuthSession, PasswordCredential, User, UserIdentity
from papyrus.schemas.user import UpdateUserRequest
+from papyrus.schemas.user import User as UserResponse
def _now() -> datetime:
@@ -57,18 +58,6 @@ async def update_user_profile(session: AsyncSession, user_id: UUID, request: Upd
return user
-async def delete_user_account(session: AsyncSession, user_id: UUID, password: str) -> None:
- user = await _get_active_user(session, user_id)
- credential = await session.get(PasswordCredential, user_id)
-
- if credential is not None and not verify_password(password, credential.password_hash):
- raise UnauthorizedError("Current password is incorrect")
-
- user.disabled_at = _now()
- await _revoke_user_sessions(session, user_id)
- await session.commit()
-
-
async def change_user_password(session: AsyncSession, user_id: UUID, current_password: str, new_password: str) -> None:
user = await _get_active_user(session, user_id)
credential = await session.get(PasswordCredential, user.user_id)
@@ -83,3 +72,10 @@ async def change_user_password(session: AsyncSession, user_id: UUID, current_pas
credential.password_changed_at = _now()
await _revoke_user_sessions(session, user_id)
await session.commit()
+
+
+async def user_response(session: AsyncSession, user: User) -> UserResponse:
+ providers = await session.scalars(select(UserIdentity.provider).where(UserIdentity.user_id == user.user_id))
+ response = UserResponse.model_validate(user)
+ response.connected_providers = sorted(set(providers))
+ return response
diff --git a/papyrus/templates/transactional_email.html b/papyrus/templates/transactional_email.html
new file mode 100644
index 0000000..0b15366
--- /dev/null
+++ b/papyrus/templates/transactional_email.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+$subject
+
+
+$preheader
+
+
+
+| Papyrus |
+|
+ $category
+$heading
+$greeting
+$summary
+$detail
+$guidance
+$action
+$security_note
+ |
+
+This is an automatic account notification from Papyrus. You received it because this email address is linked to your account.
+ |
+
+ |
+
+
+
diff --git a/papyrus/web/account_deletion.html b/papyrus/web/account_deletion.html
new file mode 100644
index 0000000..2875ebd
--- /dev/null
+++ b/papyrus/web/account_deletion.html
@@ -0,0 +1,153 @@
+
+
+
+
+Delete your Papyrus account
+
+
+
+
+Papyrus
+Delete your account
+Your account and synced library will be permanently deleted after 30 days. Sign in to continue.
+
+
+
+
+
+What happens to my data?
+Your profile, synced books, notes, bookmarks and reading history will be permanently deleted after 30 days. Sign in before then and choose Keep account to cancel deletion.
+Files on your devices or in external storage won’t be removed.
+
+
+
+
+
+
diff --git a/tests/api/routes/test_account_deletion.py b/tests/api/routes/test_account_deletion.py
new file mode 100644
index 0000000..a6c6924
--- /dev/null
+++ b/tests/api/routes/test_account_deletion.py
@@ -0,0 +1,335 @@
+"""Deletion capabilities cannot be used as ordinary login credentials."""
+
+from datetime import UTC, datetime, timedelta
+from urllib.parse import parse_qs, urlsplit
+from uuid import UUID, uuid4
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from papyrus.config import get_settings
+from papyrus.core.security import hash_opaque_token
+from papyrus.models import AuthSession, User, UserIdentity
+from papyrus.services import account_deletion, auth
+
+
+async def authorize(client: AsyncClient, auth_user: dict[str, str]) -> dict:
+ response = await client.post(
+ "/v1/account-deletion/password",
+ json={"email": "user@example.com", "password": auth_user["password"]},
+ )
+
+ assert response.status_code == 200, response.text
+ return response.json()
+
+
+async def test_password_schedule_recovery_requires_explicit_cancellation(
+ client: AsyncClient,
+ auth_user: dict[str, str],
+ auth_headers: dict[str, str],
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ now = datetime(2026, 10, 23, 12, tzinfo=UTC)
+ monkeypatch.setattr(account_deletion, "_now", lambda: now)
+ preview = await authorize(client, auth_user)
+ assert preview["deletion_pending"] is False
+ assert datetime.fromisoformat(preview["deletion_due_at"]) == now + timedelta(days=30)
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 200
+ second_preview = await authorize(client, auth_user)
+
+ scheduled = await client.post(
+ "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]}
+ )
+
+ assert scheduled.status_code == 200
+ assert scheduled.json()["deletion_due_at"] == preview["deletion_due_at"]
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+
+ replay = await client.post(
+ "/v1/account-deletion/schedule", json={"authorization_token": second_preview["authorization_token"]}
+ )
+
+ assert replay.status_code == 401
+ login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]})
+ assert login.status_code == 403
+ assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING"
+ recovery = login.json()["error"]["details"]["authorization"]
+ assert recovery["deletion_pending"] is True
+ assert recovery["deletion_due_at"] == preview["deletion_due_at"]
+ user = await db_session.get(User, UUID(auth_user["user_id"]))
+ assert user is not None
+ await db_session.refresh(user)
+ assert user.disabled_at is not None
+
+ restricted = await client.get(
+ "/v1/users/me", headers={"Authorization": f"Bearer {recovery['authorization_token']}"}
+ )
+ assert restricted.status_code == 401
+
+ cancelled = await client.post(
+ "/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]}
+ )
+
+ assert cancelled.status_code == 200
+ restored = cancelled.json()
+ assert restored["user"]["user_id"] == auth_user["user_id"]
+ assert (
+ await client.get("/v1/users/me", headers={"Authorization": f"Bearer {restored['access_token']}"})
+ ).status_code == 200
+ assert (await client.post("/v1/auth/refresh", json={"refresh_token": restored["refresh_token"]})).status_code == 200
+ assert (
+ await client.post("/v1/account-deletion/cancel", json={"authorization_token": recovery["authorization_token"]})
+ ).status_code == 401
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+
+
+async def test_signed_in_deletion_uses_current_session_without_reauthentication(
+ client: AsyncClient, auth_user: dict[str, str], auth_headers: dict[str, str], db_session: AsyncSession
+) -> None:
+ other = User(display_name="Other account")
+ db_session.add(other)
+ await db_session.commit()
+ assert (await client.post("/v1/account-deletion/current")).status_code == 401
+ before = datetime.now(UTC)
+ scheduled = await client.post("/v1/account-deletion/current", headers=auth_headers)
+ assert scheduled.status_code == 200
+ due = datetime.fromisoformat(scheduled.json()["deletion_due_at"])
+ assert before + timedelta(days=30) <= due <= datetime.now(UTC) + timedelta(days=30)
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+ assert (await client.post("/v1/account-deletion/current", headers=auth_headers)).status_code == 401
+ await db_session.refresh(other)
+ assert other.disabled_at is None
+ assert other.deletion_due_at is None
+ login = await client.post("/v1/auth/login", json={"email": "user@example.com", "password": auth_user["password"]})
+ assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING"
+ assert (
+ await db_session.scalar(select(func.count()).select_from(AuthSession).where(AuthSession.revoked_at.is_(None)))
+ == 0
+ )
+
+
+async def test_password_reauthentication_and_expected_account_are_required(
+ client: AsyncClient, auth_user: dict[str, str]
+) -> None:
+ response = await client.post(
+ "/v1/account-deletion/password", json={"email": "user@example.com", "password": "incorrect"}
+ )
+
+ assert response.status_code == 401
+
+ response = await client.post(
+ "/v1/account-deletion/password",
+ json={"email": "user@example.com", "password": auth_user["password"], "expected_user_id": str(uuid4())},
+ )
+
+ assert response.status_code == 403
+ assert (
+ await client.post("/v1/account-deletion/schedule", json={"authorization_token": "invalid"})
+ ).status_code == 401
+
+
+async def test_expired_preview_and_recovery_deadline_are_enforced(
+ client: AsyncClient, auth_user: dict[str, str], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ now = datetime(2026, 10, 23, 12, tzinfo=UTC)
+ monkeypatch.setattr(account_deletion, "_now", lambda: now)
+ preview = await authorize(client, auth_user)
+ now += timedelta(minutes=5)
+
+ response = await client.post(
+ "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]}
+ )
+
+ assert response.status_code == 401
+ preview = await authorize(client, auth_user)
+
+ response = await client.post(
+ "/v1/account-deletion/schedule", json={"authorization_token": preview["authorization_token"]}
+ )
+
+ assert response.status_code == 200
+ now += timedelta(days=30)
+
+ response = await client.post(
+ "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]}
+ )
+
+ assert response.status_code == 403
+
+
+async def test_disabled_accounts_cannot_be_recovered_without_scheduled_deletion(
+ client: AsyncClient, auth_user: dict[str, str], db_session: AsyncSession
+) -> None:
+ user = await db_session.get(User, UUID(auth_user["user_id"]))
+ assert user is not None
+ user.disabled_at = datetime.now(UTC)
+ await db_session.commit()
+
+ response = await client.post(
+ "/v1/account-deletion/password", json={"email": "user@example.com", "password": auth_user["password"]}
+ )
+
+ assert response.status_code == 403
+
+
+async def test_google_reauthentication_is_bound_to_identity_and_browser(
+ client: AsyncClient,
+ auth_user: dict[str, str],
+ auth_headers: dict[str, str],
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ settings = get_settings()
+ monkeypatch.setattr(settings, "google_oauth_client_id", "test-client")
+ monkeypatch.setattr(settings, "google_oauth_client_secret", "test-secret")
+ verifier = "v" * 43
+ challenge = hash_opaque_token(verifier)
+
+ db_session.add(
+ UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked-subject")
+ )
+ await db_session.commit()
+
+ monkeypatch.setattr(
+ auth.google_oauth_client,
+ "exchange_code_for_identity",
+ lambda *_: auth.GoogleIdentity(
+ subject="linked-subject",
+ email="different-provider-email@example.com",
+ email_verified=True,
+ display_name=None,
+ avatar_url=None,
+ nonce=challenge,
+ ),
+ )
+
+ start = await client.get(
+ "/v1/account-deletion/google/start",
+ params={
+ "redirect_uri": "papyrus://auth/callback",
+ "challenge": challenge,
+ "expected_user_id": auth_user["user_id"],
+ },
+ )
+
+ parameters = parse_qs(urlsplit(start.headers["location"]).query)
+ assert parameters["prompt"] == ["select_account consent"]
+ assert parameters["nonce"] == [challenge]
+
+ callback = await client.get(
+ "/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": parameters["state"][0]}
+ )
+
+ code = parse_qs(urlsplit(callback.headers["location"]).query)["code"][0]
+ assert (await client.post("/v1/auth/exchange-code", json={"code": code})).status_code == 401
+
+ wrong_browser = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": "x" * 43})
+ assert wrong_browser.status_code == 401
+ response = await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier})
+ assert response.status_code == 200
+ assert response.json()["user_id"] == auth_user["user_id"]
+ assert (
+ await client.post("/v1/account-deletion/exchange", json={"code": code, "verifier": verifier})
+ ).status_code == 401
+ assert (await client.get("/v1/users/me", headers=auth_headers)).json()["connected_providers"] == ["google"]
+ assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1
+ authorization = response.json()["authorization_token"]
+ response = await client.post("/v1/account-deletion/schedule", json={"authorization_token": authorization})
+ assert response.status_code == 200
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+
+ login_start = await client.get("/v1/auth/oauth/google/start", params={"redirect_uri": "papyrus://auth/callback"})
+
+ login_state = parse_qs(urlsplit(login_start.headers["location"]).query)["state"][0]
+
+ login_callback = await client.get(
+ "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": login_state}
+ )
+
+ login_code = parse_qs(urlsplit(login_callback.headers["location"]).query)["code"][0]
+ login = await client.post("/v1/auth/exchange-code", json={"code": login_code})
+ assert login.status_code == 403
+ assert login.json()["error"]["code"] == "ACCOUNT_DELETION_PENDING"
+ assert login.json()["error"]["details"]["authorization"]["deletion_pending"] is True
+ assert (await client.post("/v1/auth/exchange-code", json={"code": login_code})).status_code == 401
+ assert await db_session.scalar(select(func.count()).select_from(AuthSession)) == 1
+
+ recovery_callback = await client.get(
+ "/v1/auth/oauth/google/callback", params={"code": "fresh-provider-code", "state": parameters["state"][0]}
+ )
+
+ recovery_code = parse_qs(urlsplit(recovery_callback.headers["location"]).query)["code"][0]
+ recovery = await client.post("/v1/account-deletion/exchange", json={"code": recovery_code, "verifier": verifier})
+ assert recovery.status_code == 200
+ assert recovery.json()["deletion_pending"] is True
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+
+ cancelled = await client.post(
+ "/v1/account-deletion/cancel", json={"authorization_token": recovery.json()["authorization_token"]}
+ )
+
+ assert cancelled.status_code == 200
+ restored_headers = {"Authorization": f"Bearer {cancelled.json()['access_token']}"}
+ assert (await client.get("/v1/users/me", headers=restored_headers)).status_code == 200
+ assert (await client.get("/v1/users/me", headers=auth_headers)).status_code == 401
+
+
+@pytest.mark.parametrize("mismatch", ["identity", "nonce", "account"])
+async def test_google_cannot_authorize_a_different_account_or_flow(
+ client: AsyncClient,
+ auth_user: dict[str, str],
+ db_session: AsyncSession,
+ monkeypatch: pytest.MonkeyPatch,
+ mismatch: str,
+) -> None:
+ monkeypatch.setattr(get_settings(), "google_oauth_client_id", "test-client")
+ challenge = hash_opaque_token("v" * 43)
+ db_session.add(UserIdentity(user_id=UUID(auth_user["user_id"]), provider="google", provider_subject="linked"))
+ await db_session.commit()
+
+ monkeypatch.setattr(
+ auth.google_oauth_client,
+ "exchange_code_for_identity",
+ lambda *_: auth.GoogleIdentity(
+ subject="unlinked" if mismatch == "identity" else "linked",
+ email="user@example.com",
+ email_verified=True,
+ display_name=None,
+ avatar_url=None,
+ nonce="wrong" if mismatch == "nonce" else challenge,
+ ),
+ )
+
+ start = await client.get(
+ "/v1/account-deletion/google/start",
+ params={
+ "redirect_uri": "papyrus://auth/callback",
+ "challenge": challenge,
+ "expected_user_id": str(uuid4()) if mismatch == "account" else auth_user["user_id"],
+ },
+ )
+
+ state = parse_qs(urlsplit(start.headers["location"]).query)["state"][0]
+ callback = await client.get("/v1/auth/oauth/google/callback", params={"code": "provider-code", "state": state})
+
+ if mismatch == "nonce":
+ assert callback.status_code == 400
+ else:
+ assert callback.status_code == 302
+ assert "code" not in parse_qs(urlsplit(callback.headers["location"]).query)
+
+ assert await db_session.scalar(select(func.count()).select_from(User)) == 1
+
+
+async def test_public_resource_and_policy(client: AsyncClient) -> None:
+ response = await client.get("/v1/account-deletion")
+ assert response.status_code == 200
+ assert "Continue with Google" in response.text
+ assert "Keep account" in response.text
+ assert response.headers["cache-control"] == "no-store"
+ assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
+ policy = await client.get("/v1/account-deletion/policy")
+ assert "not currently implemented" in policy.json()["retention_notice"]
diff --git a/tests/api/routes/test_auth.py b/tests/api/routes/test_auth.py
index 4d37d0b..07917dc 100644
--- a/tests/api/routes/test_auth.py
+++ b/tests/api/routes/test_auth.py
@@ -80,16 +80,16 @@ def unconfigured_powersync(monkeypatch: pytest.MonkeyPatch) -> None:
@pytest.fixture
-def configured_email_delivery(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str]]:
+def configured_email_delivery(monkeypatch: pytest.MonkeyPatch) -> list[tuple[str, str, str, str | None]]:
settings = get_settings()
monkeypatch.setattr(settings, "email_delivery_enabled", True)
monkeypatch.setattr(settings, "smtp_host", "smtp.example.test")
monkeypatch.setattr(settings, "smtp_from_email", "noreply@example.test")
monkeypatch.setattr(settings, "app_public_base_url", "http://papyrus.localhost:3000")
- sent_messages: list[tuple[str, str, str]] = []
+ sent_messages: list[tuple[str, str, str, str | None]] = []
- def fake_send_email(recipient: str, subject: str, body: str) -> None:
- sent_messages.append((recipient, subject, body))
+ def fake_send_email(recipient: str, subject: str, body: str, *, html_body: str | None = None) -> None:
+ sent_messages.append((recipient, subject, body, html_body))
monkeypatch.setattr(email_service, "send_email", fake_send_email)
return sent_messages
@@ -368,6 +368,7 @@ async def test_google_oauth_flow_creates_user_and_exchanges_code(
assert exchange_response.status_code == 200
data = exchange_response.json()
assert data["user"]["email"] == "google_user@example.com"
+ assert data["user"]["connected_providers"] == ["google"]
assert data["user"]["display_name"] == "Google User"
@@ -514,6 +515,7 @@ async def test_google_link_flow_links_identity_to_existing_user(
)
assert complete_response.status_code == 200
+ assert complete_response.json()["connected_providers"] == ["google"]
identity_result = await db_session.execute(
select(UserIdentity).where(UserIdentity.provider_subject == "google-sub-link")
@@ -614,7 +616,7 @@ async def test_verify_email_rejects_expired_token(client: AsyncClient, db_sessio
async def test_resend_verification_sends_email_when_configured(
client: AsyncClient,
- configured_email_delivery: list[tuple[str, str, str]],
+ configured_email_delivery: list[tuple[str, str, str, str | None]],
):
"""Test resend verification sends an email when SMTP is configured."""
register_response = await client.post(
@@ -636,10 +638,11 @@ async def test_resend_verification_sends_email_when_configured(
assert response.status_code == 200
assert response.json()["message"] == "If the email is registered, a verification link has been sent"
assert len(configured_email_delivery) == 1
- recipient, subject, body = configured_email_delivery[0]
+ recipient, subject, body, html = configured_email_delivery[0]
assert recipient == "test@example.com"
assert subject == "Verify your Papyrus email address"
assert "Verification token:" in body
+ assert html is None
async def test_forgot_password_returns_configuration_message(
@@ -670,7 +673,7 @@ async def test_forgot_password_returns_configuration_message(
async def test_forgot_password_requires_app_public_base_url(
client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
- configured_email_delivery: list[tuple[str, str, str]],
+ configured_email_delivery: list[tuple[str, str, str, str | None]],
):
"""Test forgot password does not send linkless email when app URL is missing."""
register_response = await client.post(
@@ -698,7 +701,7 @@ async def test_forgot_password_requires_app_public_base_url(
async def test_forgot_password_sends_email_when_configured(
client: AsyncClient,
- configured_email_delivery: list[tuple[str, str, str]],
+ configured_email_delivery: list[tuple[str, str, str, str | None]],
):
"""Test forgot password issues a token and sends an email when SMTP is configured."""
register_response = await client.post(
@@ -706,7 +709,7 @@ async def test_forgot_password_sends_email_when_configured(
json={
"email": "test@example.com",
"password": "SecureP@ss123",
- "display_name": "Test User",
+ "display_name": '',
},
)
@@ -720,13 +723,26 @@ async def test_forgot_password_sends_email_when_configured(
assert response.status_code == 200
assert response.json()["message"] == "If the email is registered, a reset link has been sent"
assert len(configured_email_delivery) == 1
- recipient, subject, body = configured_email_delivery[0]
+ recipient, subject, body, html = configured_email_delivery[0]
assert recipient == "test@example.com"
assert subject == "Reset your Papyrus password"
assert "http://papyrus.localhost:3000/reset-password?token=" in body
assert "This link expires in 60 minutes." in body
assert "Reset token:" not in body
assert "/v1/auth/reset-password" not in body
+ assert html is not None
+ assert "Reset your password" in html
+ assert "This link expires in 60 minutes and can only be used once." in html
+ assert "If you didn't request this" in html
+ assert "', deadline)
+ email.send_email("reader@example.test", content.subject, content.text, html_body=content.html)
+ message = smtp_messages[0]
+ assert message.get_content_type() == "multipart/alternative"
+ plain = message.get_body(preferencelist=("plain",)).get_content()
+ html = message.get_body(preferencelist=("html",)).get_content()
+ assert "November 22, 2026 at 14:04 UTC" in plain
+ assert "November 22, 2026 at 14:04 UTC" in html
+ assert "