- FastAPI skeleton, SQLAlchemy models (§13), Alembic initial migration - SchedulingProvider interface with google_calendar (free/busy read-only), partner_api (Appendix B client) and mock implementations - Proposal engine: create → provider-routed delivery → owner actions (resolve/confirm+SMS/reject) → expiry + reminders (§9) - Signed single-use action links, .ics METHOD:REQUEST attachment - Partner outcome webhook with HMAC verification + polling fallback - SmsProvider (console) with Bosnian templates (§5.5), EmailProvider (console/SMTP) - Fake partner API server in tests/ — Appendix B reference implementation - 43 tests: slot math, proposal lifecycle, action links, partner contract Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
93 lines
2.4 KiB
Python
93 lines
2.4 KiB
Python
"""EmailProvider interface: SMTP for production, console for dev/tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from email.message import EmailMessage as MimeMessage
|
|
from typing import Protocol
|
|
|
|
import aiosmtplib
|
|
|
|
from gogo.config import get_settings
|
|
|
|
log = logging.getLogger("gogo.email")
|
|
|
|
|
|
@dataclass
|
|
class EmailMessage:
|
|
to: list[str]
|
|
subject: str
|
|
text: str
|
|
html: str | None = None
|
|
# attachments: (filename, mimetype, bytes)
|
|
attachments: list[tuple[str, str, bytes]] = field(default_factory=list)
|
|
|
|
|
|
class EmailProvider(Protocol):
|
|
async def send(self, msg: EmailMessage) -> None: ...
|
|
|
|
|
|
class ConsoleEmailProvider:
|
|
"""Logs emails instead of sending; keeps them in memory for tests."""
|
|
|
|
sent: list[EmailMessage]
|
|
|
|
def __init__(self) -> None:
|
|
self.sent = []
|
|
|
|
async def send(self, msg: EmailMessage) -> None:
|
|
self.sent.append(msg)
|
|
log.info(
|
|
"EMAIL to=%s subject=%r attachments=%d\n%s",
|
|
msg.to,
|
|
msg.subject,
|
|
len(msg.attachments),
|
|
msg.text,
|
|
)
|
|
|
|
|
|
class SmtpEmailProvider:
|
|
async def send(self, msg: EmailMessage) -> None:
|
|
s = get_settings()
|
|
mime = MimeMessage()
|
|
mime["From"] = s.email_from
|
|
mime["To"] = ", ".join(msg.to)
|
|
mime["Subject"] = msg.subject
|
|
mime.set_content(msg.text)
|
|
if msg.html:
|
|
mime.add_alternative(msg.html, subtype="html")
|
|
for filename, mimetype, data in msg.attachments:
|
|
maintype, subtype = mimetype.split("/", 1)
|
|
mime.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
|
await aiosmtplib.send(
|
|
mime,
|
|
hostname=s.smtp_host,
|
|
port=s.smtp_port,
|
|
username=s.smtp_user or None,
|
|
password=s.smtp_password or None,
|
|
start_tls=s.smtp_starttls,
|
|
)
|
|
|
|
|
|
_provider: EmailProvider | None = None
|
|
|
|
|
|
def get_email_provider() -> EmailProvider:
|
|
global _provider
|
|
if _provider is None:
|
|
_provider = (
|
|
SmtpEmailProvider() if get_settings().email_provider == "smtp" else ConsoleEmailProvider()
|
|
)
|
|
return _provider
|
|
|
|
|
|
def set_email_provider(p: EmailProvider | None) -> None:
|
|
"""Test hook."""
|
|
global _provider
|
|
_provider = p
|
|
|
|
|
|
async def send_email(msg: EmailMessage) -> None:
|
|
await get_email_provider().send(msg)
|