import base64 import logging import requests from infrasynth.shared.enums import ChannelType from infrasynth.shared.results import Result from infrasynth.shared.settings_utils import get_setting from .base import BaseChannel logger = logging.getLogger(__name__) class SendGridChannel(BaseChannel): """Email channel that sends via the SendGrid v3 Mail Send API. Configuration keys (read from ``ChannelConfig.config``): - ``api_key`` (required) - ``from_email`` (falls back to DEFAULT_FROM_EMAIL) - ``timeout`` """ channel_type = ChannelType.EMAIL API_URL = "https://api.sendgrid.com/v3/mail/send" def __init__(self, api_key=None, from_email=None, timeout=30): self.api_key = api_key self.from_email = from_email or get_setting( "INFRASYNTH_NOTIFICATIONS", "DEFAULT_FROM_EMAIL", "noreply@example.com" ) self.timeout = timeout @classmethod def from_config(cls, config: dict) -> "SendGridChannel": config = {str(key).lower(): value for key, value in (config or {}).items()} return cls(**config) def send( self, recipient: str, subject: str, body: str, is_html: bool = True, attachments=None, ) -> Result[bool, str]: if not self.api_key: return Result.err("SendGrid API key not configured") payload = { "personalizations": [{"to": [{"email": recipient}]}], "from": {"email": self.from_email}, "subject": subject, "content": [ { "type": "text/html" if is_html else "text/plain", "value": body, } ], } if attachments: payload["attachments"] = [ { "filename": attachment.filename, "content": base64.b64encode(attachment.content).decode(), "type": attachment.mime_type, } for attachment in attachments ] try: response = requests.post( self.API_URL, headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, json=payload, timeout=self.timeout, ) except requests.RequestException as exc: logger.exception("SendGrid request failed for %s", recipient) return Result.err(str(exc)) if response.status_code >= 400: return Result.err(f"SendGrid error {response.status_code}: {response.text[:500]}") return Result.ok(True) def health_check(self) -> bool: return bool(self.api_key)