null, 'expires_at' => 0, ]; /** * Obtain an access token from Azure AD using client credentials flow. * * Caches the token in a static variable and reuses it until 60 seconds * before expiry. * * @return string Bearer access token. * @throws RuntimeException If credentials are not configured or request fails. */ function get_graph_token(): string { global $_graph_token_cache; // Return cached token if still valid (with 60s buffer) if ( $_graph_token_cache['access_token'] !== null && $_graph_token_cache['expires_at'] > time() + 60 ) { return $_graph_token_cache['access_token']; } if (empty(GRAPH_TENANT_ID) || empty(GRAPH_CLIENT_ID) || GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER') { throw new RuntimeException('Microsoft Graph API credentials not configured'); } $token_url = "https://login.microsoftonline.com/" . GRAPH_TENANT_ID . "/oauth2/v2.0/token"; $post_fields = http_build_query([ 'client_id' => GRAPH_CLIENT_ID, 'client_secret' => GRAPH_CLIENT_SECRET, 'scope' => 'https://graph.microsoft.com/.default', 'grant_type' => 'client_credentials', ]); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $token_url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $post_fields, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'], ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curl_error = curl_error($ch); curl_close($ch); if ($response === false) { app_log('ERROR', "Graph token request failed (curl): {$curl_error}"); throw new RuntimeException("Failed to obtain Graph token: {$curl_error}"); } if ($http_code !== 200) { app_log('ERROR', "Graph token request failed (HTTP {$http_code}): {$response}"); throw new RuntimeException("Failed to obtain Graph token: HTTP {$http_code}"); } $data = json_decode($response, true); if (empty($data['access_token'])) { app_log('ERROR', 'Graph token response missing access_token'); throw new RuntimeException('Invalid Graph token response'); } $_graph_token_cache['access_token'] = $data['access_token']; $_graph_token_cache['expires_at'] = time() + (int)($data['expires_in'] ?? 3600); return $data['access_token']; } /** * Send an email via Microsoft Graph API. * * @param string $to_email Recipient email address. * @param string $subject Email subject. * @param string $body_html HTML body content. * @param string|null $cc_email Optional CC recipient. * @return bool True if sent successfully, false otherwise. */ function send_email(string $to_email, string $subject, string $body_html, ?string $cc_email = null): bool { if (GRAPH_CLIENT_SECRET === 'CHANGE_ME_PLACEHOLDER' || empty(GRAPH_TENANT_ID)) { app_log('WARNING', 'Graph API not configured - skipping email send'); return false; } try { $token = get_graph_token(); } catch (RuntimeException $e) { app_log('ERROR', 'Cannot send email - token error: ' . $e->getMessage()); return false; } $message = [ 'message' => [ 'subject' => $subject, 'body' => [ 'contentType' => 'HTML', 'content' => $body_html, ], 'toRecipients' => [ ['emailAddress' => ['address' => $to_email]], ], ], 'saveToSentItems' => 'true', ]; if ($cc_email !== null) { $message['message']['ccRecipients'] = [ ['emailAddress' => ['address' => $cc_email]], ]; } $url = "https://graph.microsoft.com/v1.0/users/" . GRAPH_SENDER_EMAIL . "/sendMail"; $json_body = json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $json_body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', "Authorization: Bearer {$token}", ], ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curl_error = curl_error($ch); curl_close($ch); if ($response === false) { app_log('ERROR', "Graph sendMail curl error: {$curl_error}"); return false; } // Graph sendMail returns 202 on success (no body) if ($http_code >= 200 && $http_code < 300) { app_log('INFO', "[OK] Email sent to {$to_email}: {$subject}"); return true; } app_log('ERROR', "[ERROR] Graph sendMail failed (HTTP {$http_code}): {$response}"); return false; } /** * Build the HTML email body for a quote submission notification. * * Matches the exact template from the Python email_service.py implementation. * * @param string $company_name Company name. * @param string $contact_name Contact name. * @param string $contact_email Contact email address. * @param string|null $contact_phone Contact phone number. * @param string $monthly_total Formatted monthly total. * @param string $setup_total Formatted setup total. * @param array $items Array of item data (service_name, billing_frequency, unit_price, quantity). * @param string|null $notes Additional notes from the prospect. * @return string HTML email body. */ function build_quote_notification_html( string $company_name, string $contact_name, string $contact_email, ?string $contact_phone, string $monthly_total, string $setup_total, array $items, ?string $notes = null ): string { $items_html = ''; foreach ($items as $item) { $freq = $item['billing_frequency'] ?? 'monthly'; $freq_label = $freq === 'monthly' ? '/mo' : ' (one-time)'; $qty = (int)($item['quantity'] ?? 1); $price = $item['unit_price'] ?? '0.00'; $line_total = (float)$price * $qty; $service_name = htmlspecialchars($item['service_name'] ?? '', ENT_QUOTES, 'UTF-8'); $price_formatted = htmlspecialchars($price, ENT_QUOTES, 'UTF-8'); $line_formatted = number_format($line_total, 2, '.', ','); $items_html .= " {$service_name} {$qty} \${$price_formatted}{$freq_label} \${$line_formatted}{$freq_label} "; } $notes_section = ''; if ($notes !== null && $notes !== '') { $notes_escaped = htmlspecialchars($notes, ENT_QUOTES, 'UTF-8'); $notes_section = "
Notes:

{$notes_escaped}

"; } $phone_line = $contact_phone ? '
Phone: ' . htmlspecialchars($contact_phone, ENT_QUOTES, 'UTF-8') : ''; $contact_name_escaped = htmlspecialchars($contact_name, ENT_QUOTES, 'UTF-8'); $company_escaped = htmlspecialchars($company_name, ENT_QUOTES, 'UTF-8'); $email_escaped = htmlspecialchars($contact_email, ENT_QUOTES, 'UTF-8'); $setup_section = ''; if ((float)($setup_total ?? 0) > 0) { $setup_section = "
One-Time Costs: \${$setup_total}
"; } return "

New Quote Submission

Arizona Computer Guru - MSP Quote Wizard

Contact Information

{$contact_name_escaped}
{$company_escaped}
Email: {$email_escaped} {$phone_line}

Monthly Total \${$monthly_total}/mo
{$setup_section}

Services

{$items_html}
Service Qty Unit Price Total
{$notes_section}

Submitted via azcomputerguru.com/quote

"; }