""" Email service using Microsoft Graph API. Sends email via M365 Graph API using client credentials flow. Used for quote submission notifications and other system emails. """ import logging from typing import Optional import httpx from api.config import get_settings logger = logging.getLogger(__name__) # Cache the access token to avoid requesting a new one for every email _token_cache: dict = {"access_token": None, "expires_at": 0} async def _get_graph_token() -> str: """Obtain an access token from Azure AD using client credentials.""" import time if _token_cache["access_token"] and _token_cache["expires_at"] > time.time() + 60: return _token_cache["access_token"] settings = get_settings() if not settings.GRAPH_TENANT_ID or not settings.GRAPH_CLIENT_ID: raise RuntimeError("Microsoft Graph API credentials not configured") token_url = f"https://login.microsoftonline.com/{settings.GRAPH_TENANT_ID}/oauth2/v2.0/token" async with httpx.AsyncClient(timeout=15) as client: response = await client.post( token_url, data={ "client_id": settings.GRAPH_CLIENT_ID, "client_secret": settings.GRAPH_CLIENT_SECRET, "scope": "https://graph.microsoft.com/.default", "grant_type": "client_credentials", }, ) response.raise_for_status() data = response.json() _token_cache["access_token"] = data["access_token"] _token_cache["expires_at"] = time.time() + data.get("expires_in", 3600) return data["access_token"] async def send_email( to_email: str, subject: str, body_html: str, cc_email: Optional[str] = None, ) -> bool: """ Send an email via Microsoft Graph API. Args: to_email: Recipient email address subject: Email subject body_html: HTML body content cc_email: Optional CC recipient Returns: True if sent successfully, False otherwise """ settings = get_settings() if not settings.GRAPH_TENANT_ID: logger.warning("Graph API not configured - skipping email send") return False try: token = await _get_graph_token() message: dict = { "message": { "subject": subject, "body": { "contentType": "HTML", "content": body_html, }, "toRecipients": [ {"emailAddress": {"address": to_email}} ], }, "saveToSentItems": "true", } if cc_email: message["message"]["ccRecipients"] = [ {"emailAddress": {"address": cc_email}} ] sender = settings.GRAPH_SENDER_EMAIL url = f"https://graph.microsoft.com/v1.0/users/{sender}/sendMail" async with httpx.AsyncClient(timeout=15) as client: response = await client.post( url, json=message, headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() logger.info(f"Email sent to {to_email}: {subject}") return True except Exception as e: logger.error(f"Failed to send email to {to_email}: {e}") return False def build_quote_notification_html( company_name: str, contact_name: str, contact_email: str, contact_phone: Optional[str], monthly_total: str, setup_total: str, items: list, notes: Optional[str] = None, ) -> str: """Build HTML email body for quote submission notification.""" items_html = "" for item in items: freq = item.get("billing_frequency", "monthly") freq_label = "/mo" if freq == "monthly" else " (one-time)" qty = item.get("quantity", 1) price = item.get("unit_price", "0.00") line_total = float(price) * qty items_html += f""" {item.get('service_name', '')} {qty} ${price}{freq_label} ${line_total:,.2f}{freq_label} """ notes_section = "" if notes: notes_section = f"""
Notes:

{notes}

""" phone_line = f"
Phone: {contact_phone}" if contact_phone else "" return f"""

New Quote Submission

Arizona Computer Guru - MSP Quote Wizard

Contact Information

{contact_name}
{company_name}
Email: {contact_email} {phone_line}

Monthly Total ${monthly_total}/mo
{"
One-Time Costs: $" + setup_total + "
" if float(setup_total or 0) > 0 else ""}

Services

{items_html}
Service Qty Unit Price Total
{notes_section}

Submitted via azcomputerguru.com/quote

"""