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>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* Email service using Microsoft Graph API.
|
||||
*
|
||||
* Sends email via M365 Graph API using client credentials flow (OAuth 2.0).
|
||||
* Used for quote submission notifications and other system emails.
|
||||
*
|
||||
* All HTTP calls use curl.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../config.php';
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
// Token cache: persists across calls within a single request
|
||||
$_graph_token_cache = [
|
||||
'access_token' => 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 .= "
|
||||
<tr>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb;\">{$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_formatted}{$freq_label}</td>
|
||||
<td style=\"padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: right;\">\${$line_formatted}{$freq_label}</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
$notes_section = '';
|
||||
if ($notes !== null && $notes !== '') {
|
||||
$notes_escaped = htmlspecialchars($notes, ENT_QUOTES, 'UTF-8');
|
||||
$notes_section = "
|
||||
<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_escaped}</p>
|
||||
</div>";
|
||||
}
|
||||
|
||||
$phone_line = $contact_phone ? '<br>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 = "<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>";
|
||||
}
|
||||
|
||||
return "
|
||||
<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_escaped}</strong><br>
|
||||
{$company_escaped}<br>
|
||||
Email: <a href=\"mailto:{$email_escaped}\">{$email_escaped}</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>
|
||||
|
||||
{$setup_section}
|
||||
|
||||
<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>
|
||||
";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
/**
|
||||
* Syncro RMM integration service (stub).
|
||||
*
|
||||
* This is a placeholder for the SyncroRMM lead creation and customer
|
||||
* lookup functionality. The full implementation will be added when
|
||||
* Syncro API credentials and endpoint details are finalized.
|
||||
*/
|
||||
|
||||
// Deny direct access
|
||||
if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
|
||||
http_response_code(403);
|
||||
exit('Direct access denied.');
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../helpers.php';
|
||||
|
||||
/**
|
||||
* Sync a quote to SyncroRMM as a lead.
|
||||
*
|
||||
* Checks for an existing customer by email/business name, then creates
|
||||
* a lead in Syncro with the quote details.
|
||||
*
|
||||
* @param PDO $db Database connection.
|
||||
* @param array $quote Quote row from database.
|
||||
* @return array Result with keys: synced, is_existing_customer, syncro_lead_id, error
|
||||
*/
|
||||
function sync_quote_to_syncro(PDO $db, array $quote): array
|
||||
{
|
||||
$result = [
|
||||
'synced' => false,
|
||||
'is_existing_customer' => false,
|
||||
'syncro_lead_id' => null,
|
||||
'error' => 'Syncro integration not yet configured',
|
||||
];
|
||||
|
||||
if (empty($quote['contact_email'])) {
|
||||
$result['error'] = 'Quote has no contact email';
|
||||
return $result;
|
||||
}
|
||||
|
||||
app_log('INFO', "Syncro sync requested for quote {$quote['id']} - integration not yet configured");
|
||||
|
||||
return $result;
|
||||
}
|
||||
Reference in New Issue
Block a user