jobhunt-platform/apps/api/app/transport.py
hermes 3035e4eac9 W2: api v1 features (CV import, AF fetch, batch scoring, today/nudges, interview prep, SMTP+clipboard transports, scheduler, 90 tests)
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.
2026-07-30 18:29:17 +00:00

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