Files
claudetools/api/services/email_service.py
Mike Swanson fa15b03180 sync: Auto-sync from ACG-M-L5090 at 2026-03-10 19:11:00
Synced files:
- Quote wizard frontend (all components, hooks, types, config)
- API updates (config, models, routers, schemas, services)
- Client work (bg-builders, gurushow)
- Scripts (BGB Lesley termination, CIPP, Datto, migration)
- Temp files (Bardach contacts, VWP investigation, misc)
- Credentials and session logs
- Email service, PHP API, session logs

Machine: ACG-M-L5090
Timestamp: 2026-03-10 19:11:00

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:59:08 -07:00

205 lines
7.7 KiB
Python

"""
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"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb;">{item.get('service_name', '')}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: center;">{qty}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${price}{freq_label}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;">${line_total:,.2f}{freq_label}</td>
</tr>"""
notes_section = ""
if notes:
notes_section = f"""
<div style="margin-top: 20px; padding: 12px 16px; background: #f8f9fb; border-radius: 8px;">
<strong style="color: #333d49;">Notes:</strong>
<p style="margin: 4px 0 0; color: #555;">{notes}</p>
</div>"""
phone_line = f"<br>Phone: {contact_phone}" if contact_phone else ""
return f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #333d49, #113559); padding: 24px 32px; border-radius: 12px 12px 0 0;">
<h1 style="color: white; margin: 0; font-size: 22px;">New Quote Submission</h1>
<p style="color: rgba(255,255,255,0.7); margin: 4px 0 0; font-size: 14px;">Arizona Computer Guru - MSP Quote Wizard</p>
</div>
<div style="padding: 24px 32px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 12px 12px;">
<div style="margin-bottom: 20px;">
<h2 style="color: #333d49; font-size: 18px; margin: 0 0 8px;">Contact Information</h2>
<p style="margin: 0; color: #555; line-height: 1.6;">
<strong>{contact_name}</strong><br>
{company_name}<br>
Email: <a href="mailto:{contact_email}">{contact_email}</a>
{phone_line}
</p>
</div>
<div style="background: linear-gradient(135deg, #333d49, #113559); border-radius: 8px; padding: 16px 20px; margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<span style="color: rgba(255,255,255,0.8); font-size: 14px;">Monthly Total</span>
<span style="color: white; font-size: 24px; font-weight: bold;">${monthly_total}/mo</span>
</div>
{"<div style='background: #fff7ed; border-radius: 8px; padding: 12px 20px; margin-bottom: 20px;'><span style=\"color: #9a3412; font-size: 14px;\">One-Time Costs: <strong>$" + setup_total + "</strong></span></div>" if float(setup_total or 0) > 0 else ""}
<h3 style="color: #333d49; font-size: 16px; margin: 20px 0 8px;">Services</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<thead>
<tr style="background: #f8f9fb;">
<th style="padding: 8px 12px; text-align: left; color: #333d49;">Service</th>
<th style="padding: 8px 12px; text-align: center; color: #333d49;">Qty</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Unit Price</th>
<th style="padding: 8px 12px; text-align: right; color: #333d49;">Total</th>
</tr>
</thead>
<tbody>
{items_html}
</tbody>
</table>
{notes_section}
<div style="margin-top: 24px; padding-top: 16px; border-top: 2px solid #fe7400; text-align: center;">
<p style="color: #999; font-size: 12px; margin: 0;">
Submitted via <a href="https://azcomputerguru.com/quote" style="color: #fe7400;">azcomputerguru.com/quote</a>
</p>
</div>
</div>
</div>
"""