PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); // Create table if missing $pdo->exec("CREATE TABLE IF NOT EXISTS contract_status ( clientid TEXT PRIMARY KEY, sent INTEGER DEFAULT 0, sent_at TEXT NULL, signed INTEGER DEFAULT 0, signed_at TEXT NULL, last_email_to TEXT NULL, pdf_path TEXT NULL, signer_name TEXT NULL, signer_ip TEXT NULL )"); return $pdo; } function get_status(string $clientid): array { $pdo = ensure_db(); $stmt = $pdo->prepare("SELECT * FROM contract_status WHERE clientid = :id"); $stmt->execute([':id' => $clientid]); $row = $stmt->fetch(); if (!$row) { return [ 'clientid' => $clientid, 'sent' => 0, 'sent_at' => null, 'signed' => 0, 'signed_at' => null, 'last_email_to' => null, 'pdf_path' => null, 'signer_name' => null, 'signer_ip' => null, ]; } return $row; } function set_sent(string $clientid, string $email): void { $pdo = ensure_db(); $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, sent, sent_at, last_email_to) VALUES (:id, 1, :now, :email) ON CONFLICT(clientid) DO UPDATE SET sent = 1, sent_at = :now, last_email_to = :email"); $stmt->execute([ ':id' => $clientid, ':now' => date('Y-m-d H:i:s'), ':email' => $email ]); } function set_signed(string $clientid, ?string $signerName, ?string $signerIp, ?string $pdfPath = null): void { $pdo = ensure_db(); $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, signed, signed_at, signer_name, signer_ip, pdf_path) VALUES (:id, 1, :now, :name, :ip, :pdf) ON CONFLICT(clientid) DO UPDATE SET signed = 1, signed_at = :now, signer_name = COALESCE(:name, signer_name), signer_ip = COALESCE(:ip, signer_ip), pdf_path = COALESCE(:pdf, pdf_path)"); $stmt->execute([ ':id' => $clientid, ':now' => date('Y-m-d H:i:s'), ':name' => $signerName, ':ip' => $signerIp, ':pdf' => $pdfPath ]); } function extract_front_matter_fields(string $file): array { $out = []; $txt = @file_get_contents($file); if (!$txt) return $out; // Grab the first front matter block if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out; $fm = $m[1]; // Very simple line-based pulls (keeps dependencies out) // client: if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) { $clientBlock = $block[1]; if (preg_match('/^\s*name\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_name'] = trim($mm[1]); if (preg_match('/^\s*email\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_email'] = trim($mm[1]); if (preg_match('/^\s*id\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_id'] = trim($mm[1]); } if (preg_match('/^\s*project\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['project'] = trim($mm[1]); if (preg_match('/^\s*job\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['job'] = trim($mm[1]); if (preg_match('/^\s*user\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_user'] = trim($mm[1]); if (preg_match('/^\s*pass\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_pass'] = trim($mm[1]); if (preg_match('/^\s*secret\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_secret'] = trim($mm[1]); return $out; } function url_join(string $base, string $path): string { return rtrim($base, '/') . '/' . ltrim($path, '/'); } function contract_public_url(string $clientid): string { $meta = extract_front_matter_fields(contract_path($clientid)); $secret = $meta['admin_secret'] ?? ($meta['admin']['secret'] ?? ''); if ($secret === '') { throw new RuntimeException("Missing admin secret for client ID: {$clientid}"); } $token = hash_hmac('sha256', $clientid, $secret); $signUrl = url_join(BASE_URL, 'contract.php'); return $signUrl . '?clientid=' . rawurlencode($clientid) . '&token=' . rawurlencode($token); } function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 200): string { if ($dataUrl === '') return ''; // Fast check for the PNG data URL prefix $prefix = 'data:image/png;base64,'; if (stripos($dataUrl, $prefix) !== 0) return ''; $bin = base64_decode(substr($dataUrl, strlen($prefix)), true); if ($bin === false) return ''; // Stable CID so replies and forwards keep the reference $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos'; $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png'); return '' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . ''; } function salutationFromName(string $fullName): string { // Normalize whitespace (incl. NBSP) and collapse runs $name = str_replace("\xC2\xA0", ' ', $fullName); $name = trim(preg_replace('/\s+/u', ' ', $name)); if ($name === '') return 'there'; // Strip a single pair of surrounding quotes if present if ($name !== '') { $q0 = $name[0]; $q1 = substr($name, -1); if (($q0 === '"' && $q1 === '"') || ($q0 === "'" && $q1 === "'")) { $name = substr($name, 1, -1); $name = trim($name); } } // Strip common trailing suffixes $name = preg_replace( '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu', '', $name ); // Remove one or more leading honorifics (with optional dot) $honorifics = '(mr|mrs|ms|miss|mx|dr|prof|sir|dame|lord|lady|hon|rev|fr|father|pastor|rabbi|imam|capt|cpt|gen|col|maj|sgt|officer|chief|coach|pres|sen|rep)'; while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) { $name = preg_replace('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', '', $name, 1); } // First non-initial token foreach (preg_split('/[\s\x{00A0}]+/u', $name) as $tok) { $t = rtrim($tok, '.'); if (!preg_match('/^[A-Za-z]\.?$/u', $t)) return $t; } return 'there'; } function send_contract_email(string $email, string $clientid): bool { global $cfg; $mail = new PHPMailer(true); // Build the public link and load metadata from the .md file $link = contract_public_url($clientid); $mdPath = contract_path($clientid); $meta = extract_front_matter_fields($mdPath); // name, email, project, job includes client_name, job, admin.secret, etc. $clientName = $meta['client_name'] ?? ''; $firstName = salutationFromName($clientName); $safeFirst = htmlspecialchars($firstName ?: 'there', ENT_QUOTES, 'UTF-8'); $safeCompany = htmlspecialchars($cfg['company_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design'), ENT_QUOTES, 'UTF-8'); $safeJob = htmlspecialchars($meta['job'] ?? $clientid, ENT_QUOTES, 'UTF-8'); $safeSignature = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100); // Prepared date from file mtime (or today if missing) $prepTs = @filemtime($mdPath) ?: time(); $preparedPart = ' (prepared ' . date('j F Y', $prepTs) . ')'; // Logo HTML: prefer full HTML from cfg; else build simple if a URL is present $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200); $safeUrl = htmlspecialchars($link, ENT_QUOTES, 'UTF-8'); // Exact same visual structure as your contract.php style, but wording for "ready to sign" $html = build_admin_email_html_template( $logoHtml, $safeJob, $safeFirst, htmlspecialchars($preparedPart, ENT_QUOTES, 'UTF-8'), $safeUrl, $safeCompany, $safeSignature ); $alt = "Hello {$safeFirst},\n\n" . "Your contract{$preparedPart} is ready to view and sign:\n" . "{$link}\n\n" . "Thanks again.\n" . "Kind Regards,\n{$safeCompany}"; // Send with PHPMailer using your $cfg SMTP (same pattern as contract.php) try { $mail->CharSet = 'UTF-8'; $mail->Encoding = 'base64'; // SMTP if host present $smtpHost = $cfg['smtp_host'] ?? ''; if ($smtpHost !== '') { $mail->isSMTP(); $mail->SMTPDebug = SMTP::DEBUG_OFF; $mail->Host = $smtpHost; $mail->SMTPAuth = true; $mail->Username = $cfg['smtp_username'] ?? ''; $mail->Password = $cfg['smtp_password'] ?? ''; $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); // 'ssl' or 'tls' if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); } else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); } } $fromAddress = $cfg['smtp_from'] ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au'); $fromName = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design'); $mail->setFrom($fromAddress, $fromName); if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']); $mail->addAddress($email); // Optional BCC list (comma separated) if (!empty($cfg['smtp_bcc'])) { foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc = trim($bcc); if ($bcc !== '') $mail->addBCC($bcc); } } $mail->isHTML(true); $mail->Subject = "Your Building Design contract"; $mail->Body = $html; $mail->AltBody = $alt; $mail->addBCC('drafting@modulosdesign.com.au'); $mail->send(); return true; } catch (Throwable $e) { error_log("contracts-admin: PHPMailer failed for {$email}: ".$e->getMessage()); // Fallback to mail() $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-type: text/html; charset=UTF-8\r\n"; $headers .= "From: ".$safeCompany." <".$fromAddress.">\r\n"; return mail($email, "Your Building Design contract", $html, $headers); } } // Return absolute paths to every *.md under CONTRACTS_DIR (recursively) function list_all_contract_md_files(): array { $base = rtrim(CONTRACTS_DIR, '/\\'); $files = []; if (!is_dir($base)) return $files; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $path => $info) { if ($info->isFile() && strtolower($info->getExtension()) === 'md') { $files[] = $path; } } return $files; } // Pretty display path (relative to CONTRACTS_DIR) function contract_relpath(string $abs): string { $base = realpath(CONTRACTS_DIR) ?: CONTRACTS_DIR; $absR = realpath($abs) ?: $abs; $rel = ltrim(str_replace('\\','/', substr($absR, strlen($base))), '/'); return $rel ?: basename($absR); } // Find the real path for {clientid}.md anywhere under CONTRACTS_DIR function find_contract_path_by_clientid(string $clientid): ?string { $id = safe_clientid($clientid); $needle = $id . '.md'; // quick root check $direct = rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $needle; if (is_file($direct)) return $direct; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( rtrim(CONTRACTS_DIR, '/\\'), FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS ), RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($it as $path => $info) { if ($info->isFile() && strtolower($info->getExtension()) === 'md' && strcasecmp($info->getFilename(), $needle) === 0) { return $path; } } return null; } /** Build a starter Markdown file with YAML front matter. */ function build_markdown_template(string $clientid, ?string $name, ?string $email, ?string $project): string { $today = date('Y-m-d'); $name = $name ?? ''; $email = $email ?? ''; $project = $project ?? ''; // Generate secure random credentials $adminUser = bin2hex(random_bytes(4)); // 8 hex chars (~4 bytes) $adminPass = bin2hex(random_bytes(8)); // 16 hex chars (~8 bytes) $adminSecret = bin2hex(random_bytes(16)); // 32 hex chars (~16 bytes) $frontMatter = <<'','client_email'=>'','client_address'=>'','property_address'=>'']; $txt = @file_get_contents($file); if (!$txt) return $out; if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out; $fm = $m[1]; $ctx = null; foreach (preg_split('/\R/', $fm) as $line) { // Any new TOP-LEVEL key (no leading spaces) resets context if (preg_match('/^\S[^:]*:\s*$/', $line)) { if (preg_match('/^client\s*:\s*$/', $line)) { $ctx = 'client'; } elseif (preg_match('/^property\s*:\s*$/', $line)) { $ctx = 'property'; } else { $ctx = null; } continue; } if ($ctx === 'client') { if (preg_match('/^\s*name\s*:\s*(.+)$/', $line, $mm)) $out['client_name'] = trim($mm[1], " \t\"'"); if (preg_match('/^\s*email\s*:\s*(.+)$/', $line, $mm)) $out['client_email'] = trim($mm[1], " \t\"'"); if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['client_address'] = trim($mm[1], " \t\"'"); } elseif ($ctx === 'property') { if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['property_address']= trim($mm[1], " \t\"'"); } } // Fallback: if no property address, use client address if ($out['property_address'] === '' && $out['client_address'] !== '') { $out['property_address'] = $out['client_address']; } return $out; } function lookup_job_for_loa(string $job): array { $job = safe_clientid($job); $empty = ['client_name'=>'','client_email'=>'','property_address'=>'','source'=>null]; foreach (list_all_contract_md_files() as $file) { $txt = @file_get_contents($file); if (!$txt) continue; if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) continue; $fm = $m[1]; $fm_job = null; if (preg_match('/^\s*job\s*:\s*["\']?([^"\r\n]+)["\']?/mi', $fm, $mm)) $fm_job = trim($mm[1]); $fname_id = clientid_from_filename($file); if ($fm_job === $job || $fname_id === $job) { $info = extract_front_matter_fields($file); $client_name = $info['client_name'] ?? ''; $client_email = $info['client_email'] ?? ''; $client_addr = null; if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block) && preg_match('/^\s*address\s*:\s*(.+)$/mi', $block[1], $ma)) { $client_addr = trim($ma[1], " \t\"'"); } $prop_addr = null; if (preg_match('/^\s*property\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $pblock) && preg_match('/^\s*address\s*:\s*(.+)$/mi', $pblock[1], $mp)) { $prop_addr = trim($mp[1], " \t\"'"); } return [ 'client_name' => $client_name, 'client_email' => $client_email, 'property_address' => $prop_addr ?: $client_addr ?: '', 'source' => 'contract', 'clientid' => $fname_id, ]; } } return $empty; } /* -------------------------------------------------------------------------- */ /* API MODE */ /* -------------------------------------------------------------------------- */ $action = $_GET['action'] ?? $_POST['action'] ?? null; if ($action) { // For API calls, enforce admin auth except for mark_signed which uses a shared secret. if ($action !== 'mark_signed') { require_admin_auth(); } try { switch ($action) { case 'list': $files = glob(rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . '*.md'); $rows = []; foreach ($files as $file) { $clientid = clientid_from_filename($file); $stat = get_status($clientid); // build signed public URL (with token) $publicUrl = null; try { $publicUrl = contract_public_url($clientid); } catch (Throwable $e) { $publicUrl = null; // missing secret or bad front matter } $rows[] = [ 'clientid' => $clientid, 'filename' => basename($file), 'size' => filesize($file), 'mtime' => filemtime($file), 'sent' => (int)($stat['sent'] ?? 0), 'sent_at' => $stat['sent_at'] ?? null, 'signed' => (int)($stat['signed'] ?? 0), 'signed_at' => $stat['signed_at'] ?? null, 'last_email_to' => $stat['last_email_to'] ?? null, 'public_url' => $publicUrl, // <-- add this ]; } // Sort by most-recent modified first usort($rows, fn($a,$b) => ($b['mtime'] <=> $a['mtime'])); json_response(['ok' => true, 'contracts' => $rows]); break; case 'read': $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? ''); $path = contract_path($clientid); if (!file_exists($path)) { json_response(['ok' => false, 'error' => 'File not found'], 404); } $content = file_get_contents($path); json_response(['ok' => true, 'content' => $content]); break; case 'save': $clientid = safe_clientid($_POST['clientid'] ?? ''); $content = $_POST['content'] ?? ''; $path = contract_path($clientid); if (!is_dir(CONTRACTS_DIR)) { @mkdir(CONTRACTS_DIR, 0775, true); } $ok = file_put_contents($path, $content, LOCK_EX); if ($ok === false) { json_response(['ok' => false, 'error' => 'Write failed'], 500); } json_response(['ok' => true]); break; case 'send_link': $clientid = safe_clientid($_POST['clientid'] ?? ''); $email = trim($_POST['email'] ?? ''); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { json_response(['ok' => false, 'error' => 'Invalid email'], 400); } $ok = send_contract_email($email, $clientid); if ($ok) { set_sent($clientid, $email); json_response(['ok' => true]); } else { json_response(['ok' => false, 'error' => 'Failed to send email'], 500); } break; case 'mark_signed': // This endpoint is meant to be called from the public contracts.php after a successful signature $secret = $_GET['secret'] ?? $_POST['secret'] ?? ''; if (ADMIN_SHARED_SECRET === '' || $secret !== ADMIN_SHARED_SECRET) { json_response(['ok' => false, 'error' => 'Unauthorized'], 401); } $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? ''); $signerName = $_GET['name'] ?? $_POST['name'] ?? null; $pdfPath = $_GET['pdf'] ?? $_POST['pdf'] ?? null; $ip = $_SERVER['REMOTE_ADDR'] ?? null; set_signed($clientid, $signerName, $ip, $pdfPath); json_response(['ok' => true]); break; case 'toggle_signed': // Manual override from the admin UI $clientid = safe_clientid($_POST['clientid'] ?? ''); $flag = (int)($_POST['flag'] ?? 0); if ($flag) { set_signed($clientid, null, null, null); } else { $pdo = ensure_db(); $stmt = $pdo->prepare("UPDATE contract_status SET signed = 0, signed_at = NULL WHERE clientid = :id"); $stmt->execute([':id' => $clientid]); } json_response(['ok' => true]); break; case 'toggle_sent': { $clientid = safe_clientid($_POST['clientid'] ?? ''); $flag = (int)($_POST['flag'] ?? 0); $pdo = ensure_db(); if ($flag) { $stmt = $pdo->prepare("UPDATE contract_status SET sent = 1, sent_at = :now WHERE clientid = :id"); $stmt->execute([':id' => $clientid, ':now' => date('Y-m-d H:i:s')]); } else { $stmt = $pdo->prepare("UPDATE contract_status SET sent = 0, sent_at = NULL WHERE clientid = :id"); $stmt->execute([':id' => $clientid]); } json_response(['ok' => true]); break; } case 'create': // Create a new {clientid}.md using a starter template $clientid = safe_clientid($_POST['clientid'] ?? ''); $name = trim($_POST['name'] ?? ''); $email = trim($_POST['email'] ?? ''); $project = trim($_POST['project'] ?? ''); $overwrite = (int)($_POST['overwrite'] ?? 0); $path = contract_path($clientid); if (file_exists($path) && !$overwrite) { json_response(['ok' => false, 'error' => 'File already exists'], 409); } if (!is_dir(CONTRACTS_DIR)) { @mkdir(CONTRACTS_DIR, 0775, true); } $content = build_markdown_template($clientid, $name, $email, $project); $ok = file_put_contents($path, $content, LOCK_EX); if ($ok === false) { json_response(['ok' => false, 'error' => 'Create failed'], 500); } // Seed DB row if missing $pdo = ensure_db(); try { $stmt = $pdo->prepare("INSERT OR IGNORE INTO contract_status (clientid, sent, signed) VALUES (:id, 0, 0)"); $stmt->execute([':id' => $clientid]); } catch (Throwable $e) {} json_response(['ok' => true]); break; case 'loa_list': { require_admin_auth(); $rows = []; foreach (glob(rtrim(LOA_DIR,'/\\').'/*.md') as $file) { $base = basename($file); if ($base === 'default-authorisation.md') continue; $job = clientid_from_filename($file); // filename without .md $st = @stat($file); $info = extract_loa_fields($file); $signedPdf = rtrim(LOA_DIR,'/\\')."/{$job}_signed_loa.pdf"; $rows[] = [ 'job' => $job, 'filename' => $base, 'mtime' => $st ? ($st['mtime'] ?? time()) : time(), 'client' => $info['client_name'], 'email' => $info['client_email'], 'address' => $info['property_address'], 'signed' => (int)file_exists($signedPdf), 'public_url' => loa_public_url($job), 'pdf_url' => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.pdf"), 'html_url' => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.html"), ]; } usort($rows, fn($a,$b)=>($b['mtime']<=>$a['mtime'])); json_response(['ok'=>true,'loas'=>$rows]); } case 'loa_read': { require_admin_auth(); $job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? ''); $path= loa_path($job); if (!file_exists($path)) json_response(['ok'=>false,'error'=>'File not found'],404); json_response(['ok'=>true,'content'=>file_get_contents($path)]); } case 'loa_save': { require_admin_auth(); $job = safe_clientid($_POST['job'] ?? ''); $content = $_POST['content'] ?? ''; $path= loa_path($job); if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true); $ok = file_put_contents($path,$content,LOCK_EX); if ($ok===false) json_response(['ok'=>false,'error'=>'Write failed'],500); json_response(['ok'=>true]); } case 'loa_create': { require_admin_auth(); $job = safe_clientid($_POST['job'] ?? ''); $name = trim($_POST['client_name'] ?? ''); $email = trim($_POST['client_email'] ?? ''); $addr = trim($_POST['property_address'] ?? ''); $dst = loa_path($job); if (file_exists($dst) && !((int)($_POST['overwrite'] ?? 0))) { json_response(['ok'=>false,'error'=>'File already exists'],409); } if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true); $tpl = @file_get_contents(rtrim(LOA_DIR,'/\\').'/default-authorisation.md') ?: "---\njob: {$job}\n---\n# Authorisation"; // light substitutions $tpl = preg_replace('/^---\R(.+?)\R---/s', function($m) use($job,$name,$email,$addr){ $yaml = $m[1]; $yaml = preg_replace('/\bjob:\s*.*/','job: '.$job,$yaml); if ($name !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*name:.*$/m', '$1 name: '.$name, $yaml, 1); if ($email !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*email:.*$/m','$1 email: '.$email,$yaml,1); if ($addr !== '') $yaml = preg_replace('/(property:\s*\R(?:.*\R)*?)^\s*address:.*$/m','$1 address: '.$addr,$yaml,1); return "---\n".$yaml."\n---"; }, $tpl, 1) ?? $tpl; file_put_contents($dst,$tpl); json_response(['ok'=>true]); } case 'loa_send_link': { require_admin_auth(); $job = safe_clientid($_POST['job'] ?? ''); $to = trim($_POST['email'] ?? ''); if (!filter_var($to, FILTER_VALIDATE_EMAIL)) json_response(['ok'=>false,'error'=>'Invalid email'],400); $url = loa_public_url($job); $addr = extract_loa_fields(loa_path($job))['property_address'] ?: ('Job '.$job); $mail = new PHPMailer(true); try { // (mirror your SMTP bootstrap) $smtpHost = $cfg['smtp_host'] ?? ''; if ($smtpHost) { $mail->isSMTP(); $mail->SMTPDebug = SMTP::DEBUG_OFF; $mail->Host = $smtpHost; $mail->SMTPAuth = true; $mail->Username = $cfg['smtp_username'] ?? ''; $mail->Password = $cfg['smtp_password'] ?? ''; $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); } else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); } } $fromAddress = $cfg['smtp_from'] ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au'); $fromName = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design'); $mail->setFrom($fromAddress, $fromName); if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']); $mail->addAddress($to); if (!empty($cfg['smtp_bcc'])) foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc=trim($bcc); if ($bcc) $mail->addBCC($bcc); } // Always keep your audit trail BCC $mail->addBCC('drafting@modulosdesign.com.au'); $btn = 'Open Authorisation'; $mail->isHTML(true); $mail->Subject = "Please review & sign your Authorisation - {$addr}"; $mail->Body = "

Hi,

Please review and sign the Authorisation for ".htmlspecialchars($addr,ENT_QUOTES).".

{$btn}

If the button doesn't work, use this link:
".htmlspecialchars($url,ENT_QUOTES)."

"; $mail->AltBody = "Please review and sign the Authorisation for {$addr}\n{$url}"; $mail->send(); json_response(['ok'=>true]); } catch (Throwable $e) { error_log('loa_send_link: '.$e->getMessage()); json_response(['ok'=>false,'error'=>'Failed to send email'],500); } } case 'loa_lookup': { require_admin_auth(); $job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? ''); $data = lookup_job_for_loa($job); $found = (bool)($data['client_name'] || $data['client_email'] || $data['property_address']); json_response(['ok' => true, 'found' => $found, 'data' => $data]); } default: json_response(['ok' => false, 'error' => 'Unknown action'], 400); } } catch (Throwable $e) { json_response(['ok' => false, 'error' => $e->getMessage()], 500); } exit; } /* -------------------------------------------------------------------------- */ /* EMAIL TEMPLATE */ /* Build the styled HTML email body identical in structure to contract.php, */ /* adjusted for "ready to sign" wording. */ /* -------------------------------------------------------------------------- */ function build_admin_email_html_template(string $logoHtml, string $safeJob, string $firstNameSafe, string $preparedPartHtml, string $safeUrl, string $safeCompany, string $safeSignature): string { $html = <<
Your contract is ready to view and sign. Lets get started!
$logoHtml Job #$safeJob
Hello {$firstNameSafe},
Your contract{$preparedPartHtml} is ready to view and sign. Use the link below:
View Contract
If the button doesn’t work, copy and paste this link into your browser:
$safeUrl
Thank you once again. We’re excited to be working with you and look forward to getting started. Once the contract is signed, we will issue an invoice for the initial deposit and begin work as soon as we receive confirmation.

Kind Regards,

$safeSignature
Benjamin Harris
$safeCompany
0402 984 082 | drafting@modulosdesign.com.au
This is an automated message. Please reply to this email if you have any questions.
HTML; return $html; } // No action, render the admin UI require_admin_auth(); ?> Contracts Admin

Contracts Admin

Contracts folder: . Signing page base URL:
Client ID Client Modified Sent Signed Email Actions

LOA Admin

LOA folder: . Signing page base URL: /loa.php
Job Client Property Modified Signed Email Actions