· 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%.