Add Postal sending, "annoncér runde" action, and MailLog (task 7b)

POST /purchase-rounds/{id}/announce renders the route's round_announced
MailTemplate with simple {{variable}} substitution (app/services/
mail_rendering.py) for every active participant on the route, sends
via Postal's real HTTP API (app/services/postal.py — request/response
shapes verified directly against docs.postalserver.io, not guessed),
and records one MailLog row per attempt (sent/failed, Postal's
message id + token for the future webhook in 7c, denormalized
rendered_subject so the log reflects what was actually sent even if
the template changes later). A failed send for one participant doesn't
abort the rest — each attempt is isolated and committed individually.

Route gained sender_name/sender_email in 7a; both are now populated
for the real route from the historical emails ("Finn Gram - Fælles
Vinindkøb <finn@vinindkoeb.dk>") directly in the DB, since there's no
Route CRUD API yet.

Verified end-to-end against the real Postal instance: a real test
email was sent and received via an isolated throwaway Organization/
Route/Participant/PurchaseRound/MailTemplate sandbox (never the real
route's 308 participants), MailLog captured the correct Postal message
id/token, and a temporarily-invalid API key produced a clean
sent:0/failed:1 result with Postal's actual error message stored,
not a 500. All test data removed afterward; real route/participant
data confirmed untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Carsten Gram 2026-09-28 20:16:04 +02:00
parent bf0affcdc6
commit 34ed5c28d7
14 changed files with 328 additions and 1 deletions

View file

@ -3,3 +3,5 @@ ENVIRONMENT=development
SECRET_KEY=change-me # generér med: openssl rand -hex 32
ACCESS_TOKEN_EXPIRE_MINUTES=120
ELEVATION_EXPIRE_MINUTES=5
POSTAL_BASE_URL=https://postal.example.com
POSTAL_API_KEY=change-me

View file

@ -9,6 +9,8 @@ class Settings(BaseSettings):
secret_key: str
access_token_expire_minutes: int = 120
elevation_expire_minutes: int = 5
postal_base_url: str
postal_api_key: str
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -2,7 +2,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.db import SessionDep
from app.routers import auth, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings
from app.routers import auth, mail_logs, mail_templates, participants, purchase_rounds, wine_categories, wine_offerings
app = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router)
@ -11,6 +11,7 @@ app.include_router(purchase_rounds.router)
app.include_router(wine_offerings.router)
app.include_router(wine_categories.router)
app.include_router(mail_templates.router)
app.include_router(mail_logs.router)
@app.get("/health")

73
app/models/mail_log.py Normal file
View file

@ -0,0 +1,73 @@
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING, Optional
from sqlalchemy import Column
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
from app.models.mail_template import MailEventType
if TYPE_CHECKING:
from app.models.participant import Participant
from app.models.purchase_round import PurchaseRound
class MailLogStatus(str, Enum):
SENT = "sent"
FAILED = "failed"
DELIVERED = "delivered" # udfyldes først af webhook i opgave 7c
BOUNCED = "bounced" # udfyldes først af webhook i opgave 7c
class MailLog(SQLModel, table=True):
__tablename__ = "mail_log"
id: Optional[int] = Field(default=None, primary_key=True)
participant_id: int = Field(foreign_key="participant.id", ondelete="CASCADE")
purchase_round_id: int = Field(foreign_key="purchase_round.id", ondelete="CASCADE")
mail_template_id: Optional[int] = Field(default=None, foreign_key="mail_template.id", ondelete="SET NULL")
event_type: MailEventType = Field(
sa_column=Column(
SAEnum(
MailEventType,
name="mail_event_type",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
)
)
rendered_subject: str
status: MailLogStatus = Field(
sa_column=Column(
SAEnum(
MailLogStatus,
name="mail_log_status",
native_enum=False,
values_callable=lambda enum_cls: [member.value for member in enum_cls],
),
nullable=False,
)
)
postal_message_id: Optional[int] = Field(default=None)
postal_token: Optional[str] = Field(default=None)
error_message: Optional[str] = Field(default=None)
sent_at: Optional[datetime] = Field(default=None)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
participant: "Participant" = Relationship(back_populates="mail_logs")
purchase_round: "PurchaseRound" = Relationship(back_populates="mail_logs")
class MailLogPublic(SQLModel):
id: int
participant_id: int
purchase_round_id: int
event_type: MailEventType
rendered_subject: str
status: MailLogStatus
error_message: Optional[str]
sent_at: Optional[datetime]
created_at: datetime

View file

@ -5,6 +5,7 @@ from sqlalchemy import UniqueConstraint
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.mail_log import MailLog
from app.models.order import Order
from app.models.route import Route
@ -36,6 +37,9 @@ class Participant(ParticipantBase, table=True):
orders: List["Order"] = Relationship(
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
mail_logs: List["MailLog"] = Relationship(
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class ParticipantCreate(ParticipantBase):

View file

@ -8,6 +8,7 @@ from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING:
from app.models.mail_log import MailLog
from app.models.order import Order
from app.models.route import Route
from app.models.wine_offering import WineOffering
@ -61,6 +62,9 @@ class PurchaseRound(PurchaseRoundBase, table=True):
orders: List["Order"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
mail_logs: List["MailLog"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"}
)
class PurchaseRoundCreate(PurchaseRoundBase):

46
app/routers/mail_logs.py Normal file
View file

@ -0,0 +1,46 @@
from typing import Optional
from fastapi import APIRouter, HTTPException, status
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models.mail_log import MailLog, MailLogPublic
from app.models.purchase_round import PurchaseRound
from app.models.route import Route
router = APIRouter(prefix="/mail-logs", tags=["mail-logs"])
@router.get("", response_model=list[MailLogPublic])
def list_mail_logs(
session: SessionDep,
current_user: CurrentUser,
participant_id: Optional[int] = None,
purchase_round_id: Optional[int] = None,
) -> list[MailLog]:
statement = (
select(MailLog)
.join(PurchaseRound)
.join(Route)
.where(Route.organization_id == current_user.organization_id)
)
if participant_id is not None:
statement = statement.where(MailLog.participant_id == participant_id)
if purchase_round_id is not None:
statement = statement.where(MailLog.purchase_round_id == purchase_round_id)
statement = statement.order_by(MailLog.id.desc())
return list(session.exec(statement).all())
@router.get("/{log_id}", response_model=MailLogPublic)
def get_mail_log(log_id: int, session: SessionDep, current_user: CurrentUser) -> MailLog:
mail_log = session.exec(
select(MailLog)
.join(PurchaseRound)
.join(Route)
.where(MailLog.id == log_id, Route.organization_id == current_user.organization_id)
).first()
if mail_log is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail log not found")
return mail_log

View file

@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -12,6 +13,9 @@ from app.dependencies import (
oauth2_scheme,
require_elevated_superuser,
)
from app.models.mail_log import MailLog, MailLogStatus
from app.models.mail_template import MailEventType, MailTemplate
from app.models.participant import Participant
from app.models.purchase_round import (
PurchaseRound,
PurchaseRoundCreate,
@ -21,6 +25,8 @@ from app.models.purchase_round import (
)
from app.models.route import Route
from app.models.wine_offering import WineOffering
from app.services.mail_rendering import format_datetime_da, render_template
from app.services.postal import PostalSendError, send_mail
router = APIRouter(prefix="/purchase-rounds", tags=["purchase-rounds"])
@ -132,3 +138,80 @@ def copy_purchase_round(source_round_id: int, session: SessionDep, current_user:
_commit_or_conflict(session)
session.refresh(new_round)
return new_round
@router.post("/{round_id}/announce")
def announce_purchase_round(round_id: int, session: SessionDep, current_user: CurrentUser) -> dict:
purchase_round = get_round_in_organization(session, round_id, current_user.organization_id)
if purchase_round.status != PurchaseRoundStatus.OPEN:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Round must be open before announcing")
route = purchase_round.route
if not route.sender_name or not route.sender_email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Route has no sender_name/sender_email configured",
)
template = session.exec(
select(MailTemplate).where(
MailTemplate.route_id == route.id, MailTemplate.event_type == MailEventType.ROUND_ANNOUNCED
)
).first()
if template is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No round_announced template configured for this route",
)
participants = session.exec(
select(Participant).where(Participant.route_id == route.id, Participant.is_active == True) # noqa: E712
).all()
sent = failed = 0
for participant in participants:
variables = {
"participant_name": participant.name,
"route_name": route.name,
"round_name": purchase_round.name,
"opens_at": format_datetime_da(purchase_round.opens_at) if purchase_round.opens_at else "",
"order_deadline_at": format_datetime_da(purchase_round.order_deadline_at)
if purchase_round.order_deadline_at
else "",
"pickup_at": format_datetime_da(purchase_round.pickup_at) if purchase_round.pickup_at else "",
"intro_text": purchase_round.intro_text or "",
"pickup_info_text": purchase_round.pickup_info_text or "",
}
rendered_subject = render_template(template.subject, variables)
rendered_body = render_template(template.body_html, variables)
log = MailLog(
participant_id=participant.id,
purchase_round_id=purchase_round.id,
mail_template_id=template.id,
event_type=MailEventType.ROUND_ANNOUNCED,
rendered_subject=rendered_subject,
status=MailLogStatus.FAILED,
)
try:
postal_id, postal_token = send_mail(
to=participant.email,
from_name=route.sender_name,
from_email=route.sender_email,
subject=rendered_subject,
html_body=rendered_body,
tag="round_announced",
)
log.status = MailLogStatus.SENT
log.postal_message_id = postal_id
log.postal_token = postal_token
log.sent_at = datetime.now(timezone.utc)
sent += 1
except PostalSendError as exc:
log.error_message = str(exc)
failed += 1
session.add(log)
session.commit()
return {"sent": sent, "failed": failed, "total_participants": len(participants)}

0
app/services/__init__.py Normal file
View file

View file

@ -0,0 +1,12 @@
import re
from datetime import datetime
_PLACEHOLDER_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
def render_template(text: str, variables: dict[str, str]) -> str:
return _PLACEHOLDER_RE.sub(lambda m: str(variables.get(m.group(1), m.group(0))), text)
def format_datetime_da(dt: datetime) -> str:
return dt.strftime("%d-%m-%Y kl. %H:%M")

47
app/services/postal.py Normal file
View file

@ -0,0 +1,47 @@
import httpx
from app.core.config import get_settings
class PostalSendError(Exception):
pass
def send_mail(
*,
to: str,
from_name: str,
from_email: str,
subject: str,
html_body: str,
tag: str,
) -> tuple[int, str]:
"""Sender én e-mail via Postal. Returnerer (postal_message_id, token).
Rejser PostalSendError ved fejl."""
settings = get_settings()
try:
response = httpx.post(
f"{settings.postal_base_url}/api/v1/send/message",
headers={
"X-Server-API-Key": settings.postal_api_key,
"Content-Type": "application/json",
},
json={
"to": [to],
"from": f"{from_name} <{from_email}>",
"sender": from_email,
"subject": subject,
"html_body": html_body,
"tag": tag,
},
timeout=10.0,
)
data = response.json()
except httpx.HTTPError as exc:
raise PostalSendError(str(exc)) from exc
if data.get("status") != "success":
raise PostalSendError(str(data.get("data", data)))
message_info = data["data"]["messages"][to]
return message_info["id"], message_info["token"]

View file

@ -0,0 +1,50 @@
"""add mail log
Revision ID: 25684eb2aed5
Revises: a6f09a1a4779
Create Date: 2026-09-28 20:13:42.902152
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '25684eb2aed5'
down_revision: Union[str, Sequence[str], None] = 'a6f09a1a4779'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('mail_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('participant_id', sa.Integer(), nullable=False),
sa.Column('purchase_round_id', sa.Integer(), nullable=False),
sa.Column('mail_template_id', sa.Integer(), nullable=True),
sa.Column('event_type', sa.Enum('round_announced', 'order_confirmed', 'payment_confirmed', name='mail_event_type', native_enum=False), nullable=False),
sa.Column('rendered_subject', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('status', sa.Enum('sent', 'failed', 'delivered', 'bounced', name='mail_log_status', native_enum=False), nullable=False),
sa.Column('postal_message_id', sa.Integer(), nullable=True),
sa.Column('postal_token', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('error_message', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('sent_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=True),
sa.Column('created_at', sqlmodel.sql.sqltypes.UTCDateTime(), nullable=False),
sa.ForeignKeyConstraint(['mail_template_id'], ['mail_template.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['purchase_round_id'], ['purchase_round.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('mail_log')
# ### end Alembic commands ###

View file

@ -6,6 +6,7 @@ requires-python = ">=3.13"
dependencies = [
"alembic>=1.20.0",
"fastapi[standard]>=0.141.1",
"httpx>=0.28.1",
"psycopg[binary]>=3.3.6",
"pwdlib[argon2]>=0.3.1",
"pydantic-settings>=2.15.0",

2
uv.lock generated
View file

@ -1238,6 +1238,7 @@ source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pwdlib", extra = ["argon2"] },
{ name = "pydantic-settings" },
@ -1255,6 +1256,7 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.20.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.141.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.1" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },