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>
205 lines
7.7 KiB
Python
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>
|
|
"""
|