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:
parent
bf0affcdc6
commit
34ed5c28d7
14 changed files with 328 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
73
app/models/mail_log.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
46
app/routers/mail_logs.py
Normal 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
|
||||
|
|
@ -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
0
app/services/__init__.py
Normal file
12
app/services/mail_rendering.py
Normal file
12
app/services/mail_rendering.py
Normal 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
47
app/services/postal.py
Normal 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"]
|
||||
50
migrations/versions/25684eb2aed5_add_mail_log.py
Normal file
50
migrations/versions/25684eb2aed5_add_mail_log.py
Normal 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 ###
|
||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue