Implements docs/api-contract-v2.md. Migration 002 adds follow-up fields. EchoTransport replaced by SMTP->Clipboard selection behind unchanged approval gate. Scheduler (APScheduler) daily 07:00 fetch+score, env-gated, default off. Test image installs workspace packages; build context moved to repo root. Recovered and committed by integration lead after W2 worker hit iteration limit.
158 lines
No EOL
4.9 KiB
Python
158 lines
No EOL
4.9 KiB
Python
"""Send transport interface.
|
|
|
|
Pluggable Transport interface for the outbox send operation.
|
|
Selection order:
|
|
1. SMTP configured (SMTP_HOST set) -> SmtpTransport
|
|
2. Else -> ClipboardTransport (marks sent + stores payload for UI copy)
|
|
|
|
The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED
|
|
and enforced in the API layer before transport.send() is called.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import smtplib
|
|
import ssl
|
|
from datetime import datetime, timezone
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from typing import Any, Protocol
|
|
|
|
|
|
class Transport(Protocol):
|
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Send a payload. Returns a result dict with at least 'success' bool."""
|
|
...
|
|
|
|
|
|
class EchoTransport:
|
|
"""Default transport: records payload, returns success.
|
|
|
|
No real email is sent. Used for POC.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.sent: list[dict[str, Any]] = []
|
|
|
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
self.sent.append(payload)
|
|
return {
|
|
"success": True,
|
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
|
"echo": payload,
|
|
}
|
|
|
|
|
|
class ClipboardTransport:
|
|
"""Fallback transport: marks sent and stores payload for UI copy/paste.
|
|
|
|
No real email is sent. The payload is stored so the UI can show it
|
|
to the user for manual copy-paste into their email client.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.sent: list[dict[str, Any]] = []
|
|
|
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
self.sent.append(payload)
|
|
return {
|
|
"success": True,
|
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
|
"transport": "clipboard",
|
|
"payload": payload,
|
|
}
|
|
|
|
|
|
class SmtpTransport:
|
|
"""SMTP transport: sends real email via SMTP.
|
|
|
|
Configuration from env:
|
|
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
|
|
SSL on port 465, STARTTLS otherwise.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
host: str | None = None,
|
|
port: int | None = None,
|
|
user: str | None = None,
|
|
password: str | None = None,
|
|
from_addr: str | None = None,
|
|
) -> None:
|
|
self.host = host or os.environ.get("SMTP_HOST", "")
|
|
self.port = int(port or os.environ.get("SMTP_PORT", "587"))
|
|
self.user = user or os.environ.get("SMTP_USER", "")
|
|
self.password = password or os.environ.get("SMTP_PASS", "")
|
|
self.from_addr = from_addr or os.environ.get("SMTP_FROM", self.user)
|
|
|
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
to = payload.get("to", "")
|
|
subject = payload.get("subject", "(no subject)")
|
|
body = payload.get("body", "")
|
|
|
|
msg = MIMEMultipart()
|
|
msg["From"] = self.from_addr
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.attach(MIMEText(body, "plain"))
|
|
|
|
try:
|
|
if self.port == 465:
|
|
context = ssl.create_default_context()
|
|
with smtplib.SMTP_SSL(self.host, self.port, context=context) as server:
|
|
if self.user and self.password:
|
|
server.login(self.user, self.password)
|
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
|
else:
|
|
with smtplib.SMTP(self.host, self.port) as server:
|
|
server.ehlo()
|
|
if self.user and self.password:
|
|
server.starttls()
|
|
server.ehlo()
|
|
server.login(self.user, self.password)
|
|
server.sendmail(self.from_addr, [to], msg.as_string())
|
|
return {
|
|
"success": True,
|
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
|
"transport": "smtp",
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"success": False,
|
|
"sent_at": datetime.now(timezone.utc).isoformat(),
|
|
"transport": "smtp",
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
def is_smtp_configured() -> bool:
|
|
"""Check if SMTP is configured (SMTP_HOST is set)."""
|
|
return bool(os.environ.get("SMTP_HOST", "").strip())
|
|
|
|
|
|
# Singleton instance
|
|
_default_transport: Transport | None = None
|
|
|
|
|
|
def get_transport() -> Transport:
|
|
"""Get the transport. Selects SMTP if configured, else Clipboard."""
|
|
global _default_transport
|
|
if _default_transport is None:
|
|
if is_smtp_configured():
|
|
_default_transport = SmtpTransport()
|
|
else:
|
|
_default_transport = ClipboardTransport()
|
|
return _default_transport
|
|
|
|
|
|
def set_transport(t: Transport) -> None:
|
|
"""Override the transport (for testing)."""
|
|
global _default_transport
|
|
_default_transport = t
|
|
|
|
|
|
def reset_transport() -> None:
|
|
"""Reset to default (for testing)."""
|
|
global _default_transport
|
|
_default_transport = None |