M1: backend core + proposal engine
- 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>
This commit is contained in:
92
gogo/email/sender.py
Normal file
92
gogo/email/sender.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user