feat: Create the email_verification_token entity, and both scaleway and
stub email cleints
This commit is contained in:
parent
7f0977d8e5
commit
8edab8a706
11 changed files with 143 additions and 1 deletions
10
.env.example
10
.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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
0
api/app/outbound/email/__init__.py
Normal file
0
api/app/outbound/email/__init__.py
Normal file
25
api/app/outbound/email/factory.py
Normal file
25
api/app/outbound/email/factory.py
Normal 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'."
|
||||
)
|
||||
6
api/app/outbound/email/protocol.py
Normal file
6
api/app/outbound/email/protocol.py
Normal 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:
|
||||
...
|
||||
15
api/app/outbound/email/stub_client.py
Normal file
15
api/app/outbound/email/stub_client.py
Normal 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,
|
||||
)
|
||||
0
api/app/outbound/scaleway_tem/__init__.py
Normal file
0
api/app/outbound/scaleway_tem/__init__.py
Normal file
29
api/app/outbound/scaleway_tem/tem_client.py
Normal file
29
api/app/outbound/scaleway_tem/tem_client.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue