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 SECRET_KEY=change-me # generér med: openssl rand -hex 32
ACCESS_TOKEN_EXPIRE_MINUTES=120 ACCESS_TOKEN_EXPIRE_MINUTES=120
ELEVATION_EXPIRE_MINUTES=5 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 secret_key: str
access_token_expire_minutes: int = 120 access_token_expire_minutes: int = 120
elevation_expire_minutes: int = 5 elevation_expire_minutes: int = 5
postal_base_url: str
postal_api_key: str
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",

View file

@ -2,7 +2,7 @@ from fastapi import FastAPI
from sqlalchemy import text from sqlalchemy import text
from app.db import SessionDep 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 = FastAPI(title="Vinindkøb Admin API")
app.include_router(auth.router) 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_offerings.router)
app.include_router(wine_categories.router) app.include_router(wine_categories.router)
app.include_router(mail_templates.router) app.include_router(mail_templates.router)
app.include_router(mail_logs.router)
@app.get("/health") @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 from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.mail_log import MailLog
from app.models.order import Order from app.models.order import Order
from app.models.route import Route from app.models.route import Route
@ -36,6 +37,9 @@ class Participant(ParticipantBase, table=True):
orders: List["Order"] = Relationship( orders: List["Order"] = Relationship(
back_populates="participant", sa_relationship_kwargs={"cascade": "all, delete-orphan"} 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): class ParticipantCreate(ParticipantBase):

View file

@ -8,6 +8,7 @@ from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING: if TYPE_CHECKING:
from app.models.mail_log import MailLog
from app.models.order import Order from app.models.order import Order
from app.models.route import Route from app.models.route import Route
from app.models.wine_offering import WineOffering from app.models.wine_offering import WineOffering
@ -61,6 +62,9 @@ class PurchaseRound(PurchaseRoundBase, table=True):
orders: List["Order"] = Relationship( orders: List["Order"] = Relationship(
back_populates="purchase_round", sa_relationship_kwargs={"cascade": "all, delete-orphan"} 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): 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 typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -12,6 +13,9 @@ from app.dependencies import (
oauth2_scheme, oauth2_scheme,
require_elevated_superuser, 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 ( from app.models.purchase_round import (
PurchaseRound, PurchaseRound,
PurchaseRoundCreate, PurchaseRoundCreate,
@ -21,6 +25,8 @@ from app.models.purchase_round import (
) )
from app.models.route import Route from app.models.route import Route
from app.models.wine_offering import WineOffering 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"]) 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) _commit_or_conflict(session)
session.refresh(new_round) session.refresh(new_round)
return 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 = [ dependencies = [
"alembic>=1.20.0", "alembic>=1.20.0",
"fastapi[standard]>=0.141.1", "fastapi[standard]>=0.141.1",
"httpx>=0.28.1",
"psycopg[binary]>=3.3.6", "psycopg[binary]>=3.3.6",
"pwdlib[argon2]>=0.3.1", "pwdlib[argon2]>=0.3.1",
"pydantic-settings>=2.15.0", "pydantic-settings>=2.15.0",

2
uv.lock generated
View file

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