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_ACCESS_KEY=langlearn
|
||||||
STORAGE_SECRET_KEY=changeme-use-a-long-random-string
|
STORAGE_SECRET_KEY=changeme-use-a-long-random-string
|
||||||
STORAGE_BUCKET=langlearn
|
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
|
gemini_api_key: str
|
||||||
admin_user_emails: str = "" # comma-separated list of admin email addresses
|
admin_user_emails: str = "" # comma-separated list of admin email addresses
|
||||||
api_base_url: str = "http://localhost:8000"
|
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_endpoint_url: str
|
||||||
storage_access_key: str
|
storage_access_key: str
|
||||||
storage_secret_key: str
|
storage_secret_key: str
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -9,6 +9,15 @@ class Wordform:
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SenseLink:
|
||||||
|
id: str
|
||||||
|
sense_id: str
|
||||||
|
link_text: str
|
||||||
|
link_target: str
|
||||||
|
target_lemma_id: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Sense:
|
class Sense:
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -17,6 +26,7 @@ class Sense:
|
||||||
gloss: str
|
gloss: str
|
||||||
topics: list[str]
|
topics: list[str]
|
||||||
tags: list[str]
|
tags: list[str]
|
||||||
|
links: list[SenseLink] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@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_ACCESS_KEY: ${STORAGE_ACCESS_KEY:-langlearn}
|
||||||
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY}
|
||||||
STORAGE_BUCKET: ${STORAGE_BUCKET:-langlearn}
|
STORAGE_BUCKET: ${STORAGE_BUCKET:-langlearn}
|
||||||
|
TRANSACTIONAL_EMAIL_PROVIDER: ${TRANSACTIONAL_EMAIL_PROVIDER:-stub}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue