feat: Create the email_verification_token entity, and both scaleway and

stub email cleints
This commit is contained in:
wilson 2026-04-11 06:56:50 +01:00
parent 7f0977d8e5
commit 8edab8a706
11 changed files with 143 additions and 1 deletions

View file

@ -22,3 +22,13 @@ GEMINI_API_KEY=your-gemini-api-key-here
STORAGE_ACCESS_KEY=langlearn
STORAGE_SECRET_KEY=changeme-use-a-long-random-string
STORAGE_BUCKET=langlearn
# Transactional email — set to "scaleway" in production, "stub" logs instead of sending
TRANSACTIONAL_EMAIL_PROVIDER=stub
# Scaleway Transactional Email (https://console.scaleway.com/transactional-email)
# Required when TRANSACTIONAL_EMAIL_PROVIDER=scaleway
SCALEWAY_TEM_SECRET_KEY=your-scaleway-secret-key-here
SCALEWAY_TEM_PROJECT_ID=your-scaleway-project-id-here
SCALEWAY_TEM_FROM_ADDRESS=noreply@yourdomain.com
# SCALEWAY_TEM_REGION=fr-par # default; change to nl-ams if needed

View file

@ -0,0 +1,41 @@
"""add email verification tokens table
Revision ID: 0011
Revises: 0010
Create Date: 2026-04-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0011"
down_revision: Union[str, None] = "0010"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"email_verification_tokens",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token", sa.Text(), nullable=False, unique=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_email_verification_tokens_token", "email_verification_tokens", ["token"])
op.create_index("ix_email_verification_tokens_user_id", "email_verification_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_email_verification_tokens_user_id", table_name="email_verification_tokens")
op.drop_index("ix_email_verification_tokens_token", table_name="email_verification_tokens")
op.drop_table("email_verification_tokens")

View file

@ -10,6 +10,11 @@ class Settings(BaseSettings):
gemini_api_key: str
admin_user_emails: str = "" # comma-separated list of admin email addresses
api_base_url: str = "http://localhost:8000"
transactional_email_provider: str = "stub" # "stub" | "scaleway"
scaleway_tem_secret_key: str = ""
scaleway_tem_project_id: str = ""
scaleway_tem_from_address: str = ""
scaleway_tem_region: str = "fr-par"
storage_endpoint_url: str
storage_access_key: str
storage_secret_key: str

View file

@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
@dataclass
@ -9,6 +9,15 @@ class Wordform:
tags: list[str]
@dataclass
class SenseLink:
id: str
sense_id: str
link_text: str
link_target: str
target_lemma_id: str | None
@dataclass
class Sense:
id: str
@ -17,6 +26,7 @@ class Sense:
gloss: str
topics: list[str]
tags: list[str]
links: list[SenseLink] = field(default_factory=list)
@dataclass

View file

View file

@ -0,0 +1,25 @@
from .protocol import TransactionalEmailClient
def get_email_client() -> TransactionalEmailClient:
from ...config import settings
if settings.transactional_email_provider == "scaleway":
from ..scaleway_tem.tem_client import ScalewayTEMClient
return ScalewayTEMClient(
secret_key=settings.scaleway_tem_secret_key,
from_address=settings.scaleway_tem_from_address,
project_id=settings.scaleway_tem_project_id,
region=settings.scaleway_tem_region,
)
if settings.transactional_email_provider == "stub":
from .stub_client import StubEmailClient
return StubEmailClient()
raise ValueError(
f"Unknown transactional_email_provider: {settings.transactional_email_provider!r}. "
"Valid options: 'scaleway', 'stub'."
)

View file

@ -0,0 +1,6 @@
from typing import Protocol
class TransactionalEmailClient(Protocol):
async def send_email(self, to: str, subject: str, html_body: str) -> None:
...

View file

@ -0,0 +1,15 @@
import logging
logger = logging.getLogger(__name__)
class StubEmailClient:
"""Logs emails instead of sending them. Use for local development."""
async def send_email(self, to: str, subject: str, html_body: str) -> None:
logger.info(
"STUB EMAIL — would have sent:\n To: %s\n Subject: %s\n Body:\n%s",
to,
subject,
html_body,
)

View file

@ -0,0 +1,29 @@
import httpx
_TEM_API_URL = "https://api.scaleway.com/transactional-email/v1alpha1/regions/{region}/emails"
class ScalewayTEMClient:
def __init__(self, secret_key: str, from_address: str, project_id: str, region: str = "fr-par") -> None:
self._secret_key = secret_key
self._from_address = from_address
self._project_id = project_id
self._url = _TEM_API_URL.format(region=region)
async def send_email(self, to: str, subject: str, html_body: str) -> None:
async with httpx.AsyncClient() as client:
response = await client.post(
self._url,
headers={
"X-Auth-Token": self._secret_key,
"Content-Type": "application/json",
},
json={
"project_id": self._project_id,
"from": {"email": self._from_address},
"to": [{"email": to}],
"subject": subject,
"html": html_body,
},
)
response.raise_for_status()

View file

@ -49,6 +49,7 @@ services:
STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-langlearn}
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
STORAGE_BUCKET: ${STORAGE_BUCKET:-langlearn}
TRANSACTIONAL_EMAIL_PROVIDER: ${TRANSACTIONAL_EMAIL_PROVIDER:-stub}
depends_on:
db:
condition: service_healthy