M-Bus Gateway
← Tilbage til blog
· Brevo· Sendinblue· email· Python· transaktionel· magic link· Celery· SMTP· SaaS· afregning

Brevo transaktionelle emails i Python — afregninger, alarmer og magic links

Brevo (Sendinblue) transaktionelle emails i Python: API-nøgle opsætning, template-baserede emails, magic link-generering, deliverability og Celery-integration.

Af M-Bus Gateway

M-Bus Gateway bruger Brevo til alle transaktionelle emails — afregninger, alarmer, magic links og velkomst-emails. Her er implementeringen.


Hvorfor Brevo frem for AWS SES eller Mailgun?

Brevo (tidl. Sendinblue):
  → GDPR-compliant (EU-hosted, Frankfurt datacenter)
  → Gratis: 300 emails/dag (nok til small SaaS MVP)
  → Betalt: 25$/md for 20.000 emails/md (~2.500 lejere)
  → Simple Python SDK (sib-api-v3-sdk eller requests)
  → Template-editor i dashboard
  → Deliverability tracking (åbninger, klik, bounces)

AWS SES:
  → Billigst ved høj volumen (0,10$/1.000)
  → Kræver DNS-opsætning og "sandkasse" godkendelse
  → Ingen EU-hosting garanti (regional service)

Mailgun:
  → God deliverability
  → Dyrere end Brevo ved lav volumen
  → EU-hosting tilgængeligt (frankfurt endpoint)

Python Brevo klient: Basis-opsætning

# server/src/email/brevo_client.py
import httpx
import structlog
from server.src.core.config import settings

logger = structlog.get_logger()

BREVO_API_URL = "https://api.brevo.com/v3"
HEADERS = {
    "api-key": settings.BREVO_API_KEY,
    "Content-Type": "application/json",
    "Accept": "application/json",
}


async def send_email(
    to_email: str,
    to_name: str,
    subject: str,
    html_content: str,
    reply_to: str | None = None,
    tags: list[str] | None = None,
) -> str | None:
    """
    Send transaktionel email via Brevo API.
    Returnerer Brevo message ID ved succes, None ved fejl.
    """
    payload = {
        "sender": {
            "name": settings.EMAIL_FROM_NAME,
            "email": settings.EMAIL_FROM_ADDRESS,
        },
        "to": [{"email": to_email, "name": to_name}],
        "subject": subject,
        "htmlContent": html_content,
        "tags": tags or [],
    }

    if reply_to:
        payload["replyTo"] = {"email": reply_to}

    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            response = await client.post(
                f"{BREVO_API_URL}/smtp/email",
                json=payload,
                headers=HEADERS,
            )
            response.raise_for_status()

        message_id = response.json().get("messageId")
        logger.info("email_sent", to=to_email, message_id=message_id)
        return message_id

    except httpx.HTTPStatusError as exc:
        logger.error("email_send_failed", to=to_email,
                     status=exc.response.status_code,
                     body=exc.response.text)
        return None

Magic link: Lejer-email med JWT

# server/src/email/templates.py
from jinja2 import Environment, FileSystemLoader
from server.src.auth.tokens import create_magic_link_token

jinja_env = Environment(
    loader=FileSystemLoader("server/templates/email"),
    autoescape=True,
)


def render_settlement_email(
    tenant_name: str,
    property_name: str,
    period_start: str,
    period_end: str,
    total_amount: float,
    currency: str,
    magic_token: str,
    portal_url: str,
) -> str:
    template = jinja_env.get_template("settlement_email.html")
    return template.render(
        tenant_name=tenant_name,
        property_name=property_name,
        period_start=period_start,
        period_end=period_end,
        total_amount=total_amount,
        currency=currency,
        magic_link=f"{portal_url}/lejer/afregning?token={magic_token}",
        year=period_end[:4],
    )


async def send_settlement_email(
    settlement_id: str,
    tenant_email: str,
    tenant_name: str,
    occupancy_id: str,
    property_name: str,
    period: tuple[str, str],
    total_amount: float,
    currency: str = "DKK",
) -> None:
    """Send årsafregning med magic link til lejer."""
    # Generer kortlivet JWT token til magic link (30 dage):
    magic_token = create_magic_link_token(
        sub=occupancy_id,
        settlement_id=settlement_id,
        expires_days=30,
    )

    html = render_settlement_email(
        tenant_name=tenant_name,
        property_name=property_name,
        period_start=period[0],
        period_end=period[1],
        total_amount=total_amount,
        currency=currency,
        magic_token=magic_token,
        portal_url=settings.PORTAL_URL,
    )

    await send_email(
        to_email=tenant_email,
        to_name=tenant_name,
        subject=f"Din årsafregning for {property_name} — {period[1][:4]}",
        html_content=html,
        tags=["settlement", "annual"],
    )

Alarm-emails til udlejer

# server/src/email/alarm_emails.py
from server.src.workers.celery_app import app as celery_app

@celery_app.task(name="workers.tasks.email.send_alarm_email", queue="critical")
def send_alarm_email(alarm_data: dict) -> None:
    """
    Send alarm-email til udlejer.
    Krit. prioritet — sendes øjeblikkeligt.
    """
    import asyncio
    asyncio.run(_send_alarm_async(alarm_data))


async def _send_alarm_async(alarm_data: dict) -> None:
    severity_label = {
        "critical": "🚨 KRITISK",
        "warning": "⚠️ Advarsel",
        "info": "ℹ️ Information",
    }.get(alarm_data.get("severity", "info"), "Alarm")

    html = f"""
    <h2>{severity_label}: {alarm_data['alarm_type']}</h2>
    <p><strong>Gateway:</strong> {alarm_data['gateway_id']}</p>
    <p><strong>Besked:</strong> {alarm_data['message']}</p>
    <p><strong>Tidspunkt:</strong> {alarm_data['timestamp']}</p>
    <p><a href="{settings.PORTAL_URL}/portal/alarms">Se alarm i platform →</a></p>
    """

    await send_email(
        to_email=alarm_data["recipient_email"],
        to_name=alarm_data["recipient_name"],
        subject=f"{severity_label}: {alarm_data['alarm_type']} — {alarm_data['gateway_id']}",
        html_content=html,
        tags=["alarm", alarm_data.get("severity", "info")],
    )

Deliverability: DNS-opsætning

Nødvendige DNS-poster for god deliverability:

SPF (Sender Policy Framework):
  TXT @ "v=spf1 include:spf.brevo.com ~all"
  → Autoriserer Brevo til at sende på vegne af dit domæne

DKIM (DomainKeys Identified Mail):
  TXT mail._domainkey  "v=DKIM1; k=rsa; p=<public-key>"
  → Brevo genererer nøgle i dashboard → kopier til DNS

DMARC (Domain-based Message Authentication):
  TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
  → Hvad modtager gør ved SPF/DKIM-fejl

Brevo dashboard:
  → Senders → Domains → Verificér domæne
  → Tjek alle DNS-poster er bekræftet (grønt ✓)

After verification:
  → Deliverability rate: > 98% (indbakke, ikke spam)
  → Bounce rate: < 2% (valid email list)
  → Complaint rate: < 0,08% (relevante emails)

Konklusion

Brevo giver GDPR-compliant, EU-hosted email-levering med simpel Python API-integration. Magic links genereres med JWT-tokens og indsættes i Jinja2 HTML-templates. Alarm-emails sendes via Celery critical-kø for minimal forsinkelse. Korrekt SPF/DKIM/DMARC DNS-opsætning er afgørende for deliverability > 98%.

Se Redis Celery kø guide eller magic link JWT auth guide.