From 8edab8a7068d350ba971386e68785c0268da0ada Mon Sep 17 00:00:00 2001 From: wilson Date: Sat, 11 Apr 2026 06:56:50 +0100 Subject: [PATCH] feat: Create the email_verification_token entity, and both scaleway and stub email cleints --- .env.example | 10 +++++ ...0410_0011_add_email_verification_tokens.py | 41 +++++++++++++++++++ api/app/config.py | 5 +++ api/app/domain/models/dictionary.py | 12 +++++- api/app/outbound/email/__init__.py | 0 api/app/outbound/email/factory.py | 25 +++++++++++ api/app/outbound/email/protocol.py | 6 +++ api/app/outbound/email/stub_client.py | 15 +++++++ api/app/outbound/scaleway_tem/__init__.py | 0 api/app/outbound/scaleway_tem/tem_client.py | 29 +++++++++++++ docker-compose.yml | 1 + 11 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/20260410_0011_add_email_verification_tokens.py create mode 100644 api/app/outbound/email/__init__.py create mode 100644 api/app/outbound/email/factory.py create mode 100644 api/app/outbound/email/protocol.py create mode 100644 api/app/outbound/email/stub_client.py create mode 100644 api/app/outbound/scaleway_tem/__init__.py create mode 100644 api/app/outbound/scaleway_tem/tem_client.py diff --git a/.env.example b/.env.example index 5df3b06..53c56b0 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/alembic/versions/20260410_0011_add_email_verification_tokens.py b/api/alembic/versions/20260410_0011_add_email_verification_tokens.py new file mode 100644 index 0000000..911b8e9 --- /dev/null +++ b/api/alembic/versions/20260410_0011_add_email_verification_tokens.py @@ -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") diff --git a/api/app/config.py b/api/app/config.py index 886b52d..91d14cb 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -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 diff --git a/api/app/domain/models/dictionary.py b/api/app/domain/models/dictionary.py index 327efac..cc14cee 100644 --- a/api/app/domain/models/dictionary.py +++ b/api/app/domain/models/dictionary.py @@ -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 diff --git a/api/app/outbound/email/__init__.py b/api/app/outbound/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/email/factory.py b/api/app/outbound/email/factory.py new file mode 100644 index 0000000..c883bb3 --- /dev/null +++ b/api/app/outbound/email/factory.py @@ -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'." + ) diff --git a/api/app/outbound/email/protocol.py b/api/app/outbound/email/protocol.py new file mode 100644 index 0000000..5b1c228 --- /dev/null +++ b/api/app/outbound/email/protocol.py @@ -0,0 +1,6 @@ +from typing import Protocol + + +class TransactionalEmailClient(Protocol): + async def send_email(self, to: str, subject: str, html_body: str) -> None: + ... diff --git a/api/app/outbound/email/stub_client.py b/api/app/outbound/email/stub_client.py new file mode 100644 index 0000000..41ecfad --- /dev/null +++ b/api/app/outbound/email/stub_client.py @@ -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, + ) diff --git a/api/app/outbound/scaleway_tem/__init__.py b/api/app/outbound/scaleway_tem/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/app/outbound/scaleway_tem/tem_client.py b/api/app/outbound/scaleway_tem/tem_client.py new file mode 100644 index 0000000..160ac73 --- /dev/null +++ b/api/app/outbound/scaleway_tem/tem_client.py @@ -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() diff --git a/docker-compose.yml b/docker-compose.yml index 659ce3b..7a8d0b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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