Basisimplementierung für Mailversandt.

This commit is contained in:
Frank Agerholm 2026-01-16 20:51:40 +01:00
commit 531b9cb10e
No known key found for this signature in database
14 changed files with 520 additions and 41 deletions

View file

@ -1,24 +1,46 @@
from fastapi import FastAPI, Depends, HTTPException
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Base, engine, SessionLocal
from app.db import Base, engine, get_session
from app.schemas import CreateJobRequest, JobInfo, TaskInfo
from app.schema_utils import job_to_schema
from app.services import create_job, get_job_with_tasks
from app.models import Job, Task
from app.routers.mail import router as mail_router
from typing import List
from uuid import UUID
import re, idna
app = FastAPI(title="Task Queue API", version="0.3.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
# STARTUP Beispiele
# await init_db_pool
# await init_broker()
async def get_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
# Optionale Werte Beispiele
# app.state.db_pool = get_db_pool
# app.state.broker = get_broker()
@app.on_event("startup")
async def on_startup():
async with engine.begin() as conn:
# Beispiel: DB-Update beim Startup
#await conn.run_sync(Base.metadata.create_all)
try:
yield
finally:
# SHUTDOWN Beispiele
# await close_broker()
# await close_db_pool()
pass
#await conn.run_sync(Base.metadata.create_all)
app = FastAPI(
title="Task Queue API",
version="0.3.0",
lifespan=lifespan)
app.include_router(mail_router)
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.[A-Za-z0-9-]{1,63})+$")
@ -38,33 +60,12 @@ async def create_job_endpoint(req: CreateJobRequest, session: AsyncSession = Dep
domain = _validate_domain(req.domain.strip().lower())
job = await create_job(session, req.job_type, domain, req.payload)
job = await get_job_with_tasks(session, job.id)
return _job_to_schema(job)
return job_to_schema(job)
@app.get("/jobs/{job_id}", response_model=JobInfo)
async def get_job_endpoint(job_id: UUID, session: AsyncSession = Depends(get_session)):
job = await get_job_with_tasks(session, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return _job_to_schema(job)
return job_to_schema(job)
def _job_to_schema(job: Job) -> JobInfo:
tasks: List[TaskInfo] = [
TaskInfo(
id=t.id,
name=t.name,
status=t.status.value if hasattr(t.status, "value") else str(t.status),
retries=t.retries,
max_retries=t.max_retries,
result=t.result,
error=t.error,
)
for t in job.tasks
]
return JobInfo(
id=job.id,
job_type=job.job_type,
status=job.status.value if hasattr(job.status, "value") else str(job.status),
domain=job.domain,
payload=job.payload,
tasks=tasks,
)

View file

@ -1,6 +1,28 @@
from pydantic import BaseModel
from pydantic import BaseModel, Field, AnyUrl
from typing import Optional
import os
def str_to_bool(value):
"""
Convert a string to a boolean.
Accepts common truthy/falsey string values.
Raises ValueError for unrecognized inputs.
"""
if not isinstance(value, str):
raise TypeError("Input must be a string.")
value_lower = value.strip().lower()
truthy = {"true", "1", "yes", "y", "on"}
falsey = {"false", "0", "no", "n", "off"}
if value_lower in truthy:
return True
elif value_lower in falsey:
return False
else:
raise ValueError(f"Cannot convert '{value}' to boolean.")
class Settings(BaseModel):
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/tasks")
RABBITMQ_URL: str = os.getenv("RABBITMQ_URL", "amqp://guest:guest@localhost/")
@ -12,4 +34,11 @@ class Settings(BaseModel):
SMTP_TIMEOUT: float = float(os.getenv("SMTP_TIMEOUT", "10.0"))
HTTP_TIMEOUT: float = float(os.getenv("HTTP_TIMEOUT", "8.0"))
settings = Settings()
smtp_host: str = os.getenv("SMTP_HOST", "localhost")
smtp_port: int = os.getenv("SMTP_PORT", "587")
smtp_user: Optional[str] = os.getenv("SMTP_USER")
smtp_password: Optional[str] = os.getenv("SMTP_PASSWORD")
smtp_starttls: bool = str_to_bool(os.getenv("SMTP_STARTTLS", "true"))
smtp_sender_fallback: str = os.getenv("SMTP_SENDER", "noreply@example.com")
settings = Settings()

View file

@ -5,5 +5,9 @@ from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=False, future=True)
SessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async def get_session() -> AsyncSession:
async with SessionLocal() as session:
yield session
class Base(DeclarativeBase):
pass
pass

View file

@ -9,6 +9,9 @@ JOB_GRAPH: Dict[str, Dict[str, List[str]]] = {
"CertValidity": ["MXValidation"],
"MTASTSCheck": [],
"TLSRPTCheck": [],
},
"MailSend": {
"SendClean": [],
}
}
@ -19,4 +22,4 @@ def list_tasks_for_job(job_type: str) -> List[str]:
return list(JOB_GRAPH.get(job_type, {}).keys())
def list_dependencies(job_type: str, task_name: str) -> List[str]:
return JOB_GRAPH.get(job_type, {}).get(task_name, [])
return JOB_GRAPH.get(job_type, {}).get(task_name, [])

View file

@ -1,7 +1,9 @@
import enum, uuid
from datetime import datetime
from sqlalchemy import String, Enum, ForeignKey, Integer, DateTime, JSON
from sqlalchemy import LargeBinary, Index
from sqlalchemy.orm import relationship, Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from sqlalchemy.sql import func
from app.db import Base
@ -52,3 +54,22 @@ class TaskDependency(Base):
__tablename__ = "task_dependencies"
task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
depends_on_task_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True)
class Message(Base):
__tablename__ = "messages"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
mailbox_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False, index=True)
message_id: Mapped[str | None]= mapped_column(String(512), index=True, nullable=True)
subject: Mapped[str | None] = mapped_column(String(2048))
from_addr: Mapped[str | None] = mapped_column(String(2048))
to_addr: Mapped[str | None] = mapped_column(String(4096))
date_header: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True), nullable=True)
received_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), default=datetime.utcnow, nullable=False)
raw_eml: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
Index("ix_messages_date", Message.date_header)
Index("ix_messages_message_id", Message.message_id)

79
app/routers/mail.py Normal file
View file

@ -0,0 +1,79 @@
# app/routers/mail.py
from __future__ import annotations
import base64
from uuid import UUID
from fastapi import APIRouter, UploadFile, File, Form, Depends, status, HTTPException
from fastapi.encoders import jsonable_encoder
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_session
from app.schemas import SendTestMailRequest, UploadMailResponse
from app.schema_utils import job_to_schema
from app.task_enqueue import enqueue_job_with_single_task
from app.services import create_job, get_job_with_tasks
from app.job_registry import routing_key
from email import policy
from email.parser import BytesParser
router = APIRouter(prefix="/api/v1/mail", tags=["mail"])
# TODO: vorhandene Auth-Dependency einhängen
async def api_key_auth():
return True
def _guess_domain_from_addresses(addresses: str | None) -> str:
if not addresses:
return "unknown.local"
# nimmt erste Adresse, extrahiert Domain
first = addresses.split(",")[0].strip()
if "@" in first:
return first.split("@")[-1].strip().lower()
return "unknown.local"
@router.post("/send", status_code=status.HTTP_202_ACCEPTED)
async def send_test_mail(
req: SendTestMailRequest,
_=Depends(api_key_auth),
session: AsyncSession = Depends(get_session),
):
if not req.recipients:
raise HTTPException(status_code=400, detail="At least one recipient required.")
domain = req.recipients[0].split("@")[-1].lower()
job = await create_job(
session,
job_type="MailSend",
domain=domain,
payload=req.model_dump(mode="json")
)
job = await get_job_with_tasks(session, job.id)
return job_to_schema(job)
@router.post("/upload", response_model=UploadMailResponse, status_code=status.HTTP_202_ACCEPTED)
async def upload_mail(
mailbox_id: UUID = Form(...),
eml: UploadFile = File(...),
_=Depends(api_key_auth),
session: AsyncSession = Depends(get_session),
):
raw = await eml.read()
# Domain für Job.domain aus To/From ableiten
try:
msg = BytesParser(policy=policy.default).parsebytes(raw)
domain = _guess_domain_from_addresses(msg.get("To")) or _guess_domain_from_addresses(msg.get("From"))
except Exception:
domain = "unknown.local"
payload = {
"mailbox_id": str(mailbox_id),
"raw_eml_b64": base64.b64encode(raw).decode("ascii"),
}
job_id, task_id = await enqueue_job_with_single_task(
session,
job_type="mail.store",
domain=domain,
payload=payload,
task_name="mail.store",
routing_key="mail.store",
)
return UploadMailResponse(id=UUID(task_id), message_id=None)

26
app/schema_utils.py Normal file
View file

@ -0,0 +1,26 @@
from typing import List
from app.schemas import JobInfo, TaskInfo
from app.models import Job, Task
def job_to_schema(job: Job) -> JobInfo:
tasks: List[TaskInfo] = [
TaskInfo(
id=t.id,
name=t.name,
status=t.status.value if hasattr(t.status, "value") else str(t.status),
retries=t.retries,
max_retries=t.max_retries,
result=t.result,
error=t.error,
)
for t in job.tasks
]
return JobInfo(
id=job.id,
job_type=job.job_type,
status=job.status.value if hasattr(job.status, "value") else str(job.status),
domain=job.domain,
payload=job.payload,
tasks=tasks,
)

View file

@ -1,4 +1,4 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, List
from uuid import UUID
@ -22,4 +22,17 @@ class JobInfo(BaseModel):
status: str
domain: str
payload: dict | None
tasks: List[TaskInfo]
tasks: List[TaskInfo]
class SendTestMailRequest(BaseModel):
mailbox_id: UUID
recipients: List[EmailStr]
subject: str = Field(max_length=998)
body_text: Optional[str] = None
body_html: Optional[str] = None
# Anhänge könnt ihr später ergänzen
class UploadMailResponse(BaseModel):
id: UUID
message_id: Optional[str] = None

View file

@ -0,0 +1,67 @@
# app/workers/mail_send_worker.py
from __future__ import annotations
import smtplib, ssl, email.utils
from email.message import EmailMessage
from typing import List, Optional
from pydantic import BaseModel, EmailStr
from app.workers.base_worker import BaseWorker
from app.config import Settings
from app.db import SessionLocal # <- AsyncSession factory
from app.models import Task, TaskStatus
from sqlalchemy import update
from app.job_registry import routing_key
from app.config import settings
class _SendPayload(BaseModel):
mailbox_id: str
recipients: List[EmailStr]
subject: str
body_text: Optional[str] = None
body_html: Optional[str] = None
sender: Optional[EmailStr] = None
class MailSendWorker(BaseWorker):
async def process(self, payload: dict, **meta) -> dict:
"""
Erwartet: task.job.payload (JSON) mit Feldern wie in _SendPayload.
Setzt Task-Status/Result analog zu eurem BaseWorker-Pattern.
"""
p = _SendPayload(**payload)
sender = str(p.sender or settings.smtp_sender_fallback)
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join([str(r) for r in p.recipients])
msg["Subject"] = p.subject
msg["Date"] = email.utils.formatdate(localtime=True)
msg["Message-ID"] = email.utils.make_msgid()
if p.body_html and p.body_text:
msg.set_content(p.body_text)
msg.add_alternative(p.body_html, subtype="html")
elif p.body_html:
msg.add_alternative(p.body_html, subtype="html")
else:
msg.set_content(p.body_text or "")
# SMTP synchrones I/O im Worker-Kontext ist i.d.R. ok.
context = ssl.create_default_context()
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
if settings.smtp_starttls:
server.starttls(context=context)
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
return {"message_id": msg["Message-ID"]}
async def main():
worker =MailSendWorker(routing_key("MailSend", "SendClean"), worker_id="mail-send-clean-1")
await worker.run()
if __name__ == "__main__":
import asyncio as _a
_a.run(main())

View file

@ -0,0 +1,70 @@
# app/workers/mail_store_worker.py
from __future__ import annotations
import base64, email.utils
from email import policy
from email.parser import BytesParser
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, update
from app.workers.base_worker import BaseWorker
from app.db import SessionLocal
from app.models import Message, Task, TaskStatus
class MailStoreWorker(BaseWorker):
routing_key = "mail.store"
async def handle(self, task) -> dict:
payload = task.job.payload # payload liegt auf dem Job
raw = base64.b64decode(payload["raw_eml_b64"])
mailbox_id = UUID(payload["mailbox_id"])
msg = BytesParser(policy=policy.default).parsebytes(raw)
message_id = msg.get("Message-ID")
subject = msg.get("Subject")
from_addr = msg.get("From")
to_addr = msg.get("To")
dt = None
try:
dt = email.utils.parsedate_to_datetime(msg.get("Date")) if msg.get("Date") else None
except Exception:
pass
async with SessionLocal() as session:
# Idempotenz: bei Message-ID prüfen
if message_id:
q = select(Message).where(Message.message_id == message_id)
existing = (await session.execute(q)).scalar_one_or_none()
if existing:
result = {"id": str(existing.id), "message_id": message_id, "dedup": True}
await session.execute(
update(Task)
.where(Task.id == task.id)
.values(status=TaskStatus.FINISHED, result=result, worker_id=self.worker_id)
)
await session.commit()
return result
m = Message(
mailbox_id=mailbox_id,
message_id=message_id,
subject=subject,
from_addr=from_addr,
to_addr=to_addr,
date_header=dt,
received_at=datetime.utcnow(),
raw_eml=raw,
)
session.add(m)
await session.commit() # id verfügbar, da PG/UUID serverseitig
result = {"id": str(m.id), "message_id": message_id}
await session.execute(
update(Task)
.where(Task.id == task.id)
.values(status=TaskStatus.FINISHED, result=result, worker_id=self.worker_id)
)
await session.commit()
return result