loa.php 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * LOA signing page
  5. * Mirrors contracts.php features but reads/writes under /loa and uses "Authorisation" wording.
  6. */
  7. error_reporting(E_ALL);
  8. ini_set("display_errors", 1);
  9. date_default_timezone_set("Australia/Hobart");
  10. ini_set("default_charset", "UTF-8");
  11. mb_internal_encoding("UTF-8");
  12. require_once __DIR__ . "/Parsedown.php";
  13. require_once __DIR__ . '/ParsedownExtra.php';
  14. require_once __DIR__ . "/dompdf/autoload.inc.php";
  15. require_once __DIR__ . '/../vendor/autoload.php'; // for setasign/fpdf & setasign/fpdi
  16. use PHPMailer\PHPMailer\PHPMailer;
  17. use PHPMailer\PHPMailer\SMTP;
  18. use PHPMailer\PHPMailer\Exception as MailerException;
  19. require_once __DIR__ . "/../internal/phpmailer/src/Exception.php";
  20. require_once __DIR__ . "/../internal/phpmailer/src/PHPMailer.php";
  21. require_once __DIR__ . "/../internal/phpmailer/src/SMTP.php";
  22. // at the top, before any output
  23. if (session_status() !== PHP_SESSION_ACTIVE) session_start();
  24. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  25. $ok = isset($_POST["csrf"], $_SESSION["csrf"]) && hash_equals($_SESSION["csrf"], $_POST["csrf"]);
  26. if (!$ok) {
  27. http_response_code(403);
  28. exit("Invalid CSRF token");
  29. }
  30. }
  31. if (empty($_SESSION["csrf"])) {
  32. $_SESSION["csrf"] = bin2hex(random_bytes(32));
  33. }
  34. $csrf = $_SESSION["csrf"];
  35. // Load optional config
  36. $cfg = @include __DIR__ . "/config.php";
  37. $cfg = is_array($cfg) ? $cfg : [];
  38. // Core settings (can be overridden by config.php)
  39. $CFG = [
  40. "brand_name" => $cfg["dev_name"] ?? "Modulos Design",
  41. "from_email" => $cfg["from_address"] ?? "drafting@modulosdesign.com.au",
  42. "bcc_email" => $cfg["bcc_email"] ?? "drafting@modulosdesign.com.au",
  43. "secret" => $cfg["loa_secret"] ?? ($cfg["admin_secret"] ?? "change-me"),
  44. ];
  45. // Compute base URL
  46. $https = !empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off";
  47. $scheme = $https ? "https" : "https";
  48. $host = $_SERVER["HTTP_HOST"] ?? "localhost";
  49. $base = rtrim(dirname($_SERVER["SCRIPT_NAME"] ?? ""), "/\\");
  50. $CFG["base_url"] = $scheme . "://" . $host . ($base ? $base . "/" : "/");
  51. /* ----------------------------- helpers ----------------------------- */
  52. function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, "UTF-8"); }
  53. function getClientIp(): string {
  54. if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
  55. $parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
  56. foreach ($parts as $ip) {
  57. if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return $ip;
  58. }
  59. foreach ($parts as $ip) if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
  60. }
  61. if (!empty($_SERVER['HTTP_X_REAL_IP']) && filter_var($_SERVER['HTTP_X_REAL_IP'], FILTER_VALIDATE_IP)) return $_SERVER['HTTP_X_REAL_IP'];
  62. if (!empty($_SERVER['REMOTE_ADDR']) && filter_var($_SERVER['REMOTE_ADDR'], FILTER_VALIDATE_IP)) return $_SERVER['REMOTE_ADDR'];
  63. return "UNKNOWN";
  64. }
  65. function tokenForJob(string $job, string $secret): string { return hash_hmac("sha256", "loa|" . $job, $secret); }
  66. function verifyToken(string $job, string $token, string $secret): bool {
  67. return $token !== "" && hash_equals(tokenForJob($job, $secret), $token);
  68. }
  69. /* --------------------------- Front matter --------------------------- */
  70. function _fm_trim_quotes(string $v): string {
  71. $v = trim($v);
  72. if ($v !== "" && $v[0] === "'" && substr($v, -1) === "'") return stripslashes(substr($v, 1, -1));
  73. if ($v !== "" && $v[0] === '"' && substr($v, -1) === '"') return stripslashes(substr($v, 1, -1));
  74. return $v;
  75. }
  76. function parseFrontMatter(string $text): array {
  77. if (function_exists("yaml_parse")) {
  78. $arr = @yaml_parse($text);
  79. return is_array($arr) ? $arr : [];
  80. }
  81. $lines = preg_split('/\R/', rtrim($text));
  82. $root = [];
  83. $stack = [ ["indent" => -1, "ref" => &$root] ];
  84. foreach ($lines as $raw) {
  85. if ($raw === "") continue;
  86. $trimmed = ltrim($raw, " ");
  87. if ($trimmed === "" || $trimmed[0] === "#") continue;
  88. $indent = strlen($raw) - strlen($trimmed);
  89. while (count($stack) > 1 && $indent <= $stack[array_key_last($stack)]["indent"]) {
  90. array_pop($stack);
  91. }
  92. $parent =& $stack[array_key_last($stack)]["ref"];
  93. // List item
  94. if (preg_match('/^-\s*(.*)$/', $trimmed, $m)) {
  95. $val = $m[1];
  96. if (!is_array($parent)) $parent = [];
  97. if ($val === "") {
  98. $parent[] = [];
  99. $stack[] = ["indent" => $indent, "ref" => &$parent[array_key_last($parent)]];
  100. } else {
  101. $parent[] = _fm_trim_quotes($val);
  102. }
  103. continue;
  104. }
  105. // Key: value or Key:
  106. if (preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $trimmed, $m)) {
  107. $key = $m[1];
  108. $val = $m[2];
  109. if ($val === "") {
  110. if (!isset($parent[$key]) || !is_array($parent[$key])) $parent[$key] = [];
  111. $stack[] = ["indent" => $indent, "ref" => &$parent[$key]];
  112. } else {
  113. $parent[$key] = _fm_trim_quotes($val);
  114. }
  115. }
  116. }
  117. return $root;
  118. }
  119. function getByPath($arr, string $path, $default = "") {
  120. $keys = explode(".", $path);
  121. foreach ($keys as $k) {
  122. if ($k === "") continue;
  123. if (is_array($arr) && array_key_exists($k, $arr)) {
  124. $arr = $arr[$k];
  125. } else {
  126. return $default;
  127. }
  128. }
  129. return $arr;
  130. }
  131. function setByPath(array &$arr, string $path, $value): void {
  132. $keys = explode(".", $path);
  133. $ref =& $arr;
  134. foreach ($keys as $k) {
  135. if ($k === "") continue;
  136. if (!isset($ref[$k]) || !is_array($ref[$k])) $ref[$k] = [];
  137. $ref =& $ref[$k];
  138. }
  139. $ref = $value;
  140. }
  141. function parseFrontMatterForJob(string $job): array {
  142. $safe = preg_match('/^\d{1,10}$/', $job) ? $job : "default";
  143. $path = __DIR__ . "/loa/$safe/" . $safe . ".md";
  144. if (!is_file($path)) $path = __DIR__ . "/loa/default-authorisation.md";
  145. $md = @file_get_contents($path);
  146. if ($md && preg_match('/^\s*---\s*\n(.*?)\n---\s*\n/s', $md, $m)) {
  147. return parseFrontMatter($m[1]);
  148. }
  149. return [];
  150. }
  151. function abs_url(string $rel): string {
  152. $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
  153. $scheme = $https ? 'https' : 'https';
  154. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  155. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
  156. $root = $scheme . '://' . $host . ($dir ? $dir . '/' : '/');
  157. return $root . ltrim($rel, '/');
  158. }
  159. function council_recipients(array $vars, array $cfg): array {
  160. $to = [];
  161. if (!empty($cfg['council_email'])) $to[] = $cfg['council_email'];
  162. $councilName = (string)getByPath($vars, 'property.council', '');
  163. if ($councilName && !empty($cfg['council_map'][$councilName])) {
  164. $to[] = $cfg['council_map'][$councilName];
  165. }
  166. // TODO: if you later store locality/postcode, you can look up by locality too.
  167. return array_values(array_unique(array_filter($to)));
  168. }
  169. /* --------------------- Render LOA Markdown to HTML --------------------- */
  170. function md_to_html(string $md): string {
  171. $pd = class_exists('ParsedownExtra') ? new ParsedownExtra() : new Parsedown();
  172. $pd->setSafeMode(true);
  173. // Optional: $pd->setBreaksEnabled(true); // if you want single newlines as <br>
  174. return $pd->text($md);
  175. }
  176. function loadLoaHtml(string $job, array $overrides = []): string {
  177. $safe = preg_match('/^\d{1,10}$/', $job) ? $job : "default";
  178. $path = __DIR__ . "/loa/$safe/" . $safe . ".md";
  179. if (!is_file($path)) $path = __DIR__ . "/loa/default-authorisation.md";
  180. $md = file_get_contents($path);
  181. // Split front matter and body
  182. $vars = [];
  183. $body = $md;
  184. if (preg_match('/^\s*---\s*\n(.*?)\n---\s*\n(.*)$/s', $md, $m)) {
  185. $front = $m[1];
  186. $body = $m[2];
  187. $vars = parseFrontMatter($front);
  188. }
  189. // Defaults available
  190. $base = [
  191. "dev" => [
  192. "name" => $GLOBALS["cfg"]["dev_name"] ?? "Modulos Design",
  193. "email" => $GLOBALS["cfg"]["dev_email"] ?? "drafting@modulosdesign.com.au",
  194. "phone" => $GLOBALS["cfg"]["dev_phone"] ?? "0402 984 082",
  195. "address" => $GLOBALS["cfg"]["dev_address"] ?? "34 Coplestone St, Scottsdale, Tas 7260",
  196. ],
  197. "client" => [
  198. "name" => "",
  199. "email" => "",
  200. "phone" => "",
  201. "address" => "",
  202. ],
  203. "job" => $safe,
  204. "today" => date("F j, Y"),
  205. ];
  206. // Merge: overrides > front matter > base
  207. $merged = array_replace_recursive($base, $vars, $overrides);
  208. // Flat GET overrides like client_name=...
  209. foreach (["client_name" => "client.name", "client_email" => "client.email", "client_phone" => "client.phone"] as $q => $pathKey) {
  210. if (isset($_GET[$q]) && $_GET[$q] !== "") {
  211. setByPath($merged, $pathKey, (string)$_GET[$q]);
  212. }
  213. }
  214. // Replace [path.to.value] placeholders
  215. $body = preg_replace_callback('/\[([a-zA-Z0-9_.-]+)\]/', function ($m) use ($merged) {
  216. $val = getByPath($merged, $m[1]);
  217. return is_scalar($val) ? (string)$val : "";
  218. }, $body);
  219. // Convert Markdown to HTML (ParsedownExtra supports tables)
  220. return md_to_html($body);
  221. }
  222. /* --------------------------- HTML wrappers --------------------------- */
  223. function headerWithTitle(string $title, ?string $job = null, ?string $preparedDate = null, string $context = "web"): string {
  224. $safeTitle = h($title);
  225. $safeJob = h((string)$job);
  226. $safePreparedDate = h((string)$preparedDate);
  227. $https = !empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off";
  228. $scheme = $https ? "https" : "https";
  229. $host = $_SERVER["HTTP_HOST"] ?? "localhost";
  230. $dir = rtrim(dirname($_SERVER["SCRIPT_NAME"] ?? ""), "/\\") . "/";
  231. $cssLinks = $context === "web"
  232. ? <<<HTML
  233. <link rel="preconnect" href="https://cdn.jsdelivr.net">
  234. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
  235. <link href="../internal/css/blueprint.css" rel="stylesheet">
  236. <link href="../internal/css/print.css" rel="stylesheet" media="print">
  237. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
  238. <link href="style.css" rel="stylesheet">
  239. HTML
  240. : <<<HTML
  241. <link href="../internal/css/blueprint.css" rel="stylesheet">
  242. <link href="style.css" rel="stylesheet">
  243. <style>
  244. @page { margin: 5mm 10mm 10mm 10mm; }
  245. body { background:#fff;}
  246. .container { max-width: 780px; margin: 0 auto; font-size:0.7rem; }
  247. .shadow-sm { box-shadow: none !important; }
  248. .rounded-3 { border-radius: 0 !important; }
  249. .bg-white { background: #fff !important; }
  250. .d-print-none, .noprint { display: none !important; }
  251. .img-logo { max-height: 40px; }
  252. .page-header { display: table; width: 100%; table-layout: fixed; }
  253. .page-header > .col-4 { display: table-cell; width: 33.333%; vertical-align: middle; padding: 0 8px; }
  254. .page-header .text-start { text-align: left; }
  255. .page-header .text-center { text-align: center; }
  256. .page-header .text-end { text-align: right; }
  257. .compiled-signatures { display: table; width: 100%; table-layout: fixed; margin-top: 1rem; }
  258. .compiled-signatures .compiled-signature { display: table-cell; width: 35%; vertical-align: bottom; padding: 0 8px; }
  259. .compiled-signatures img { max-width: 100%; height: auto; }
  260. table { border-collapse: collapse; width: 100%; margin: 12px 0; }
  261. th, td { border: 1px solid #ddd; padding: 2px 4px; vertical-align: top; }
  262. th { background: #f6f6f6; text-align: left; }
  263. </style>
  264. HTML;
  265. $jsLinks = $context === "web"
  266. ? <<<HTML
  267. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"></script>
  268. HTML
  269. : "";
  270. $nav = $context === "web"
  271. ? <<<HTML
  272. <nav class="navbar bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
  273. <div class="container-fluid">
  274. <a class="navbar-brand brown-light" href="#">
  275. <img src="../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top" >
  276. Modulos Design
  277. </a>
  278. </div>
  279. </nav>
  280. HTML
  281. : "";
  282. return <<<HTML
  283. <!doctype html>
  284. <html lang="en">
  285. <head>
  286. <meta charset="utf-8">
  287. <title>{$safeJob} - {$safeTitle}</title>
  288. <meta name="viewport" content="width=device-width, initial-scale=1">
  289. <meta name="robots" content="noindex">
  290. <link rel="shortcut icon" href="../internal/images/blueprint.ico" type="image/x-icon">
  291. <base href="{$scheme}://{$host}{$dir}">
  292. {$cssLinks}
  293. {$jsLinks}
  294. </head>
  295. <body>
  296. {$nav}
  297. <main class="container my-4">
  298. <div class="bg-white p-4 p-md-5 rounded-0 shadow-sm">
  299. <div class="row align-items-center page-header">
  300. <div class="col-12 col-md-4 text-start">
  301. <img class="img-fluid pt-2 img-logo" src="../internal/images/blueprint-full-logo-medium.png" height="100" alt="Modulos Design">
  302. </div>
  303. <div class="col-12 col-md-8 text-end pt-3">
  304. <h3 class="fw-bold mb-1" style="color:#373a3c;">Job: {$safeJob}</h3>
  305. <h4 class="mb-1"><span class="fw-bold text-secondary">{$safePreparedDate}</span></h4>
  306. </div>
  307. </div>
  308. HTML;
  309. }
  310. function footerFor(string $context = "web"): string {
  311. $extra = $context === "web"
  312. ? <<<HTML
  313. <script>
  314. function printDoc(){ window.print(); }
  315. </script>
  316. HTML
  317. : "";
  318. return <<<HTML
  319. </div>
  320. </main>
  321. {$extra}
  322. </body>
  323. </html>
  324. HTML;
  325. }
  326. /* --------------------------- Email helpers --------------------------- */
  327. function salutationFromName(string $fullName): string {
  328. $name = str_replace("\xC2\xA0", " ", $fullName);
  329. $name = trim(preg_replace('/\s+/u', ' ', $name));
  330. if ($name === "") return "there";
  331. $name = preg_replace('/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu', '', $name);
  332. $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)';
  333. $name = preg_replace('/^(?:' . $honorifics . ')\.?[\s\x{00A0}]+/iu', '', $name);
  334. while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) {
  335. $name = preg_replace('/^' . $honorifics . '(\.?)[\s\x{00A0}]+/iu', '', $name, 1);
  336. }
  337. $tokens = preg_split('/[\s\x{00A0}]+/u', $name);
  338. if (!$tokens) return "there";
  339. foreach ($tokens as $tok) {
  340. $t = rtrim($tok, ".");
  341. if (!preg_match('/^[A-Za-z]\.?$/u', $t)) return $t;
  342. }
  343. return $tokens[0] ?: "there";
  344. }
  345. function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = "Modulos Design", int $width = 140): string {
  346. if ($dataUrl === "") return "";
  347. $dataUrl = trim($dataUrl);
  348. $prefix = "data:image/png;base64,";
  349. if (stripos($dataUrl, $prefix) !== 0) return "";
  350. $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
  351. if ($bin === false || $bin === "") return "";
  352. $cid = "logo_" . substr(sha1($bin), 0, 12) . "@modulos";
  353. $mail->addStringEmbeddedImage($bin, $cid, "logo.png", "base64", "image/png");
  354. return '<img src="cid:' . $cid . '" alt="' . h($alt) . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
  355. }
  356. function email_cta(string $href, string $label): string {
  357. $safeUrl = h($href);
  358. $safeLbl = h($label);
  359. return <<<HTML
  360. <!--[if mso]>
  361. <v:rect xmlns:v="urn:schemas-microsoft-com:vml" href="{$safeUrl}"
  362. style="height:42px;v-text-anchor:middle;width:240px;" stroked="f" fillcolor="#635A4A">
  363. <w:anchorlock/>
  364. <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;">{$safeLbl}</center>
  365. </v:rect>
  366. <![endif]-->
  367. <!--[if !mso]><!-- -->
  368. <a href="{$safeUrl}"
  369. style="background:#635A4A;border-radius:0;display:inline-block;padding:12px 24px;color:#ffffff;
  370. text-decoration:none;font-weight:700;font-size:14px;line-height:1.6;mso-hide:all"
  371. target="_blank" rel="noopener">{$safeLbl}</a>
  372. <!--<![endif]-->
  373. HTML;
  374. }
  375. function email_signature_block(string $safeSignatureHtml, string $company = "Modulos Design"): string {
  376. $safeCo = h($company);
  377. return <<<HTML
  378. <div style="font-size:14px;line-height:1.6;margin-top:18px;">
  379. <b>Kind Regards,</b><br><br>{$safeSignatureHtml}<br>
  380. Benjamin Harris<br>{$safeCo}<br>
  381. 0402 984 082 | drafting@modulosdesign.com.au
  382. </div>
  383. HTML;
  384. }
  385. /**
  386. * Reusable frame: preheader + header band + {contentHtml} + footer band
  387. */
  388. function email_frame(string $preheader, string $logoHtml, string $job, string $contentHtml, string $footerNote = "This is an automated message. Please reply to this email if you have any questions."): string {
  389. $safePre = h($preheader);
  390. $safeJob = h($job);
  391. $safeFoot = h($footerNote);
  392. return <<<HTML
  393. <!-- Preheader -->
  394. <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">{$safePre}</div>
  395. <div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
  396. <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600"
  397. style="width:600px;max-width:100%;background:#ffffff;border-radius:8px;overflow:hidden;
  398. font-size:14px;line-height:1.6;font-family:Arial,Helvetica,sans-serif;">
  399. <tr>
  400. <td style="font-size:14px;line-height:1.6;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
  401. <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;line-height:1.6;">
  402. <tr>
  403. <td style="font-size:14px;line-height:1.6;">{$logoHtml}</td>
  404. <td align="right" style="font-weight:700;font-size:14px;line-height:1.6;">Job #{$safeJob}</td>
  405. </tr>
  406. </table>
  407. </td>
  408. </tr>
  409. {$contentHtml}
  410. <tr>
  411. <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:14px;line-height:1.6;">
  412. {$safeFoot}
  413. </td>
  414. </tr>
  415. </table>
  416. </div>
  417. HTML;
  418. }
  419. function buildSignedLoaEmail(
  420. string $logoHtml,
  421. string $viewUrl,
  422. string $job,
  423. string $clientName = "",
  424. string $preparedDate = "",
  425. string $company = "Modulos Design",
  426. string $safeSignature = ""
  427. ): array {
  428. $firstName = salutationFromName($clientName);
  429. $firstNameSafe = h($firstName);
  430. $safeUrl = h($viewUrl);
  431. $safeJob = h($job);
  432. $safePrepared = h($preparedDate);
  433. $preparedPart = $preparedDate ? " (prepared {$safePrepared})" : "";
  434. $subject = "{$safeJob} – Copy of Signed Authorisation";
  435. $cta = email_cta($safeUrl, "View Authorisation");
  436. $sig = email_signature_block($safeSignature, $company);
  437. $content = <<<HTML
  438. <tr>
  439. <td style="padding:28px 24px 8px;line-height:1.6;color:#635A4A;font-size:14px;">
  440. <div style="font-size:14px;margin-bottom:8px;line-height:1.6;">Hello {$firstNameSafe},</div>
  441. <div>Thank you for signing the authorisation{$preparedPart}. A copy is attached for your records,
  442. and you can view or download it anytime using the link below:</div>
  443. </td>
  444. </tr>
  445. <tr>
  446. <td align="center" style="padding:20px 24px 8px;font-size:14px;line-height:1.6;">{$cta}</td>
  447. </tr>
  448. <tr>
  449. <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
  450. <div>If the button doesn’t work, copy and paste this link into your browser:<br>
  451. <span style="word-break:break-all;color:#635A4A;">{$safeUrl}</span>
  452. </div>
  453. {$sig}
  454. </td>
  455. </tr>
  456. HTML;
  457. $html = email_frame(
  458. "Thank you for signing your authorisation — here’s your copy and access link.",
  459. $logoHtml,
  460. $job,
  461. $content
  462. );
  463. $alt = "Hello {$firstName},\n\nThe authorisation has been signed{$preparedPart}.\n\nView/download: {$viewUrl}\n\nThanks,\n{$company}";
  464. return [$subject, $html, $alt];
  465. }
  466. function buildCouncilRequestEmail(
  467. string $logoHtml,
  468. string $job,
  469. array $vars,
  470. string $loaPublicUrl,
  471. string $company = "Modulos Design",
  472. string $safeSignature = ""
  473. ): array {
  474. $addr = (string)getByPath($vars, 'property.address', '');
  475. $pid = (string)getByPath($vars, 'property.pid', '');
  476. $title = (string)getByPath($vars, 'property.title', '');
  477. $vol = (string)getByPath($vars, 'property.vol', '');
  478. $folio = (string)getByPath($vars, 'property.folio', '');
  479. $owners = (string)getByPath($vars, 'client.name', '');
  480. $prepared = (string)getByPath($vars, 'dates.prepared', date('F j, Y'));
  481. $titleRef = $title ?: trim($vol . '/' . $folio, '/');
  482. $safeAddr = h($addr);
  483. $safePid = h($pid);
  484. $safeTitle = h($titleRef);
  485. $safeOwners= h($owners);
  486. $safeJob = h($job);
  487. $safeDate = h($prepared);
  488. $safeUrl = h($loaPublicUrl);
  489. $safeCo = h($company);
  490. $subject = "Request for Planning & Plumbing Information – {$safeAddr} (PID {$safePid}, Title {$safeTitle}) – Job #{$safeJob}";
  491. $sig = email_signature_block($safeSignature, $company);
  492. $cta = email_cta($safeUrl, "View Signed LOA");
  493. $content = <<<HTML
  494. <tr><td style="padding:22px 24px;color:#222;">
  495. <p>Good day,</p>
  496. <p>We’re requesting planning and plumbing information for:</p>
  497. <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;">
  498. <tr><td style="border:1px solid #ddd;padding:8px;font-weight:600;width:160px;">Property Address</td><td style="border:1px solid #ddd;padding:8px;">{$safeAddr}</td></tr>
  499. <tr><td style="border:1px solid #ddd;padding:8px;font-weight:600;">Registered Owner(s)</td><td style="border:1px solid #ddd;padding:8px;">{$safeOwners}</td></tr>
  500. <tr><td style="border:1px solid #ddd;padding:8px;font-weight:600;">PID</td><td style="border:1px solid #ddd;padding:8px;">{$safePid}</td></tr>
  501. <tr><td style="border:1px solid #ddd;padding:8px;font-weight:600;">Title / Volume–Folio</td><td style="border:1px solid #ddd;padding:8px;">{$safeTitle}</td></tr>
  502. <tr><td style="border:1px solid #ddd;padding:8px;font-weight:600;">LOA prepared</td><td style="border:1px solid #ddd;padding:8px;">{$safeDate}</td></tr>
  503. </table>
  504. <p style="margin-top:14px;">Specifically, could you please supply or confirm:</p>
  505. <ul style="margin:0 0 0 18px;padding:0;">
  506. <li>Record of any recent or active Development/Building/Plumbing Applications and associated permit numbers and decisions;</li>
  507. <li>Any additional planning advice or pre-application notes Council considers relevant.</li>
  508. </ul>
  509. <p style="margin-top:14px;">A signed Letter of Authority is attached. You can also view it here:</p>
  510. <div style="margin:10px 0 18px;">{$cta}</div>
  511. <div style="font-size:12px;color:#555;">If the button doesn’t work, copy and paste this link:<br>
  512. <span style="word-break:break-all;color:#635A4A;">{$safeUrl}</span>
  513. </div>
  514. {$sig}
  515. </td></tr>
  516. HTML;
  517. $html = email_frame(
  518. "Request for planning & plumbing information for {$addr}.",
  519. $logoHtml,
  520. $job,
  521. $content,
  522. "Please reply to this email with the requested information or next steps."
  523. );
  524. $alt = "Request for planning & plumbing info\n\n"
  525. . "Address: {$addr}\n"
  526. . "Owners: {$owners}\n"
  527. . "PID: {$pid}\n"
  528. . "Title: {$titleRef}\n"
  529. . "LOA prepared: {$prepared}\n\n"
  530. . "Please provide scheme & zoning, overlays, constraints/easements, stormwater/sewer/water service info, "
  531. . "any recent DA/BA/Plumbing records, and any other relevant advice.\n\n"
  532. . "LOA (view): {$loaPublicUrl}\n\n"
  533. . "Thanks,\n{$company}";
  534. return [$subject, $html, $alt];
  535. }
  536. function getHtmlUrl(string $htmlName): string {
  537. $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
  538. $scheme = $https ? 'https' : 'https';
  539. $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
  540. $dir = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/\\');
  541. return $scheme . '://' . $host . ($dir ? $dir : '') . '/' . $htmlName;
  542. }
  543. /* ------------------------------- Routing ------------------------------- */
  544. $job = isset($_REQUEST["job"]) ? preg_replace('/\D+/', '', (string)$_REQUEST["job"]) : "";
  545. $token = $_REQUEST["token"] ?? "";
  546. // Resolve some prepared/derived values
  547. $preparedDate = getByPath(parseFrontMatterForJob($job), "dates.prepared", date("F j, Y"));
  548. if ($_SERVER["REQUEST_METHOD"] === "GET") {
  549. if (!$job || !verifyToken($job, $token, $CFG["secret"])) {
  550. http_response_code(403);
  551. echo "Auth required";
  552. exit;
  553. }
  554. // If a signed file already exists for this job, redirect there
  555. $pattern = __DIR__ . "/loa/{$job}/{$job}_signed_loa*.pdf";
  556. $matches = glob($pattern);
  557. if ($matches) {
  558. usort($matches, fn($a, $b) => filemtime($b) <=> filemtime($a));
  559. $latest = basename($matches[0]);
  560. header("Location: loa/{$job}/" . $latest, true, 302);
  561. exit;
  562. }
  563. // Render unsigned page
  564. $HEADER = headerWithTitle("Unsigned Authorisation", $job, $preparedDate, "web");
  565. $LOA_HTML = loadLoaHtml($job);
  566. echo $HEADER;
  567. echo $LOA_HTML;
  568. // Signature UI (mirrors contracts.php)
  569. ?>
  570. <div id="ui-unsigned">
  571. <form method="post" class="noprint" id="signature_form">
  572. <div id="signature-container">
  573. <div id="canvas-container">
  574. <canvas id="signature-pad" class="signature-pad" width="188" height="58.66"></canvas>
  575. </div>
  576. </div>
  577. <div class="animate slide">
  578. <div id="signature-controls" class="d-flex gap-2 justify-content-center mt-3">
  579. <button id="reset" type="button" class="btn btn-warning rounded-0">Clear</button>
  580. <button data-bs-toggle="modal" data-bs-target="#modal-qr" type="button" class="btn btn-secondary rounded-0">Sign on mobile</button>
  581. <button id="confirm" type="submit" class="btn btn-success rounded-0" disabled>Sign</button>
  582. </div>
  583. </div>
  584. <div class="flow" style="max-width: 330px; margin-inline-start: auto;">
  585. <h3 class="margin-top loading-signed hidden | animate slide" style="color: var(--clr-green-500); font-weight: 700;">Saving authorisation…</h3>
  586. <small class="loading-signed hidden | animate slide delay-16"
  587. style="font-weight: 600; color: var(--clr-blue-700);">
  588. This shouldnt take more than a minute.
  589. </small>
  590. </div>
  591. <input type="hidden" name="csrf" value="<?php echo $csrf; ?>">
  592. <input type="hidden" name="job" value="<?php echo h($job); ?>">
  593. <input type="hidden" name="token" value="<?php echo h($token); ?>">
  594. <input type="hidden" id="client_signature" name="client_signature" />
  595. <input type="hidden" name="client_tz" value="">
  596. </form>
  597. <div class="modal fade" tabindex="-1" id="modal-qr" aria-labelledby="modal-qrLabel" aria-hidden="true">
  598. <div class="modal-dialog modal-dialog-centered">
  599. <div class="modal-content">
  600. <div class="modal-body qr-code-container">
  601. <button id="close-modal-qr" type="button" class="btn-close" data-bs-dismiss="modal-qr" aria-label="Close"></button>
  602. <canvas id="qr-code"></canvas>
  603. </div>
  604. </div>
  605. </div>
  606. </div>
  607. </div>
  608. </div>
  609. </main>
  610. <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
  611. <script src="https://cdn.jsdelivr.net/npm/qrious@4.0.2/dist/qrious.min.js"></script>
  612. <script id="loa_script_unsigned" type="module">
  613. signature("#signature-pad");
  614. function signature(selector) {
  615. if (!document.querySelector(selector)) return;
  616. const canvas = document.querySelector(selector);
  617. const sigPad = new SignaturePad(canvas, {
  618. penColor: "hsl(200, 100%, 30%)",
  619. minDistance: 2,
  620. });
  621. resizeCanvas();
  622. if (localStorage.getItem("client_signature_loa_<?php echo h($job); ?>")) {
  623. document.querySelector("#confirm").disabled = false;
  624. }
  625. sigPad.addEventListener("afterUpdateStroke", () => {
  626. let data = sigPad.toDataURL("image/png");
  627. document.querySelector("#client_signature").value = data;
  628. localStorage.setItem("client_signature_loa_<?php echo h($job); ?>", data);
  629. document.querySelector("#confirm").disabled = false;
  630. });
  631. document.querySelector("#reset")?.addEventListener("click", (e) => {
  632. sigPad.clear();
  633. localStorage.removeItem("client_signature_loa_<?php echo h($job); ?>");
  634. document.querySelector("#client_signature").value = null;
  635. document.querySelector("#confirm").disabled = true;
  636. });
  637. document.querySelector("#signature_form").addEventListener("submit", (e) => {
  638. e.target.querySelectorAll(".loading-signed").forEach((el) => el.classList.remove("hidden"));
  639. e.target.querySelector("#canvas-container").classList.add("just-signed");
  640. // allow submit to continue
  641. });
  642. window.onresize = resizeCanvas;
  643. function resizeCanvas() {
  644. const ratio = Math.max(window.devicePixelRatio || 1, 1);
  645. canvas.width = canvas.offsetWidth * ratio;
  646. canvas.height = canvas.offsetHeight * ratio;
  647. canvas.getContext("2d").scale(ratio, ratio);
  648. let data = localStorage.getItem("client_signature_loa_<?php echo h($job); ?>");
  649. if (data) {
  650. sigPad.fromDataURL(data);
  651. document.querySelector("#client_signature").value = data;
  652. }
  653. }
  654. }
  655. </script>
  656. <script>
  657. (function () {
  658. const modal = document.getElementById('modal-qr');
  659. const btnClose = document.getElementById('close-modal-qr');
  660. const canvas = document.getElementById('qr-code');
  661. if (canvas && window.QRious) {
  662. new QRious({
  663. element: canvas,
  664. value: window.location.href,
  665. foreground: 'hsl(200, 30%, 20%)',
  666. padding: 0,
  667. size: 500
  668. });
  669. }
  670. btnClose?.addEventListener('click', function () {
  671. try { if (modal.open) modal.close(); else modal.removeAttribute('open'); } catch (e) { modal.removeAttribute('open'); }
  672. });
  673. modal?.addEventListener('click', function (e) {
  674. const r = modal.getBoundingClientRect();
  675. const inside = e.clientY >= r.top && e.clientY <= r.bottom && e.clientX >= r.left && e.clientX <= r.right;
  676. if (!inside) {
  677. try { modal.close(); } catch (err) { modal.removeAttribute('open'); }
  678. }
  679. });
  680. try {
  681. var tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
  682. var tzField = document.querySelector('input[name="client_tz"]');
  683. if (tzField) tzField.value = tz;
  684. } catch (e) {}
  685. })();
  686. </script>
  687. </body>
  688. </html>
  689. <?php
  690. exit;
  691. }
  692. /* ---------------------------------- POST ------------------------------------- */
  693. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  694. if (!hash_equals($_SESSION["csrf"] ?? "", $_POST["csrf"] ?? "")) {
  695. http_response_code(403);
  696. exit("Invalid CSRF");
  697. }
  698. $job = preg_replace('/\D+/', '', (string)($_POST["job"] ?? ""));
  699. $token = (string)($_POST["token"] ?? "");
  700. if (!$job || !verifyToken($job, $token, $CFG["secret"])) { http_response_code(403); exit("Auth required"); }
  701. $jobDir = __DIR__ . "/loa/{$job}";
  702. if (!is_dir($jobDir)) {
  703. mkdir($jobDir, 0775, true);
  704. }
  705. $clientSignature = $_POST["client_signature"] ?? null;
  706. if (!is_string($clientSignature) || strpos($clientSignature, "data:image/png;base64,") !== 0) {
  707. http_response_code(400);
  708. exit("No signature");
  709. }
  710. // Load variables/body again
  711. $LOA_HTML = loadLoaHtml($job);
  712. $vars = parseFrontMatterForJob($job);
  713. // Save signature PNG
  714. $sigData = base64_decode(substr($clientSignature, strlen("data:image/png;base64,")));
  715. if ($sigData === false) { http_response_code(400); exit("Bad signature data"); }
  716. $sigPathRel = "loa/{$job}/{$job}_signature.png";
  717. $sigPathAbs = $jobDir . "/{$job}_signature.png";
  718. file_put_contents($sigPathAbs, $sigData);
  719. // Build compiled signatures block
  720. $clientTz = $_POST["client_tz"] ?? "";
  721. if ($clientTz && in_array($clientTz, timezone_identifiers_list(), true)) {
  722. $tz = new DateTimeZone($clientTz);
  723. $clientDate = (new DateTime("now", $tz))->format("F j, Y \a\t g:i:s A T");
  724. } else {
  725. $clientDate = gmdate("F j, Y \a\t g:i:s A \G\M\T");
  726. }
  727. $clientIp = getClientIp();
  728. $CLIENT_SIGNATURE = '<strong>' . h(getByPath($vars, "client.name", "")) . '</strong>';
  729. $CLIENT_SIGNATURE .= '<div id="date-ip" class="date-ip">'
  730. . '<strong>Signed on:</strong> ' . h($clientDate) . '<br>'
  731. . '<strong>Client IP:</strong> ' . h($clientIp) . '</div>'
  732. . '<img id="sig" src="' . h($sigPathRel) . '" style="max-height: 117px;padding:10px;" alt="Client Signature">';
  733. $compiled = <<<HTML
  734. <div class="row compiled-signatures align-items-start">
  735. <div class="col compiled-signature">{$CLIENT_SIGNATURE}</div>
  736. </div>
  737. <br>
  738. <div class="row download-pdf d-print-none">
  739. <a href="loa/{$job}/{$job}_signed_loa.pdf" download class="btn btn-light rounded-0" id="downloadpdf">Download PDF</a>
  740. </div>
  741. HTML;
  742. // Build final HTML (web and pdf versions)
  743. $headerWeb = headerWithTitle("{$job} - Signed Authorisation", $job, getByPath($vars, "dates.prepared", date("F j, Y")), "web");
  744. $footerWeb = footerFor("web");
  745. $outputWeb = $headerWeb . $LOA_HTML . $compiled . $footerWeb;
  746. $headerPdf = headerWithTitle("{$job} - Signed Authorisation", $job, getByPath($vars, "dates.prepared", date("F j, Y")), "pdf");
  747. $footerPdf = footerFor("pdf");
  748. $outputPdf = $headerPdf . $LOA_HTML . $compiled . $footerPdf;
  749. // Render and save PDF
  750. $options = new \Dompdf\Options();
  751. $options->set('defaultFont', 'Helvetica');
  752. $options->set('isRemoteEnabled', true);
  753. $dompdf = new \Dompdf\Dompdf($options);
  754. $dompdf->loadHtml($outputPdf, "UTF-8");
  755. $dompdf->setPaper("A4", "portrait");
  756. $https = !empty($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off";
  757. $scheme = $https ? "https" : "http";
  758. $host = $_SERVER["HTTP_HOST"] ?? "localhost";
  759. $dir = rtrim(dirname($_SERVER["SCRIPT_NAME"] ?? ""), "/\\") . "/";
  760. $dompdf->setBasePath($scheme . "://" . $host . $dir);
  761. $dompdf->render();
  762. $pdfPathRel = "{$job}_signed_loa.pdf";
  763. $pdfPathAbs = __DIR__ . "/loa/{$job}/" . $pdfPathRel;
  764. $pdfPublicRel = "loa/{$job}/{$pdfPathRel}";
  765. file_put_contents($pdfPathAbs, $dompdf->output());
  766. // Email client + dev
  767. $clientEmail = (string)(getByPath($vars, "client.email", "") ?: "");
  768. $devEmail = (string)($cfg["dev_email"] ?? "drafting@modulosdesign.com.au");
  769. $fromAddress = (string)($cfg["from_address"] ?? "drafting@modulosdesign.com.au");
  770. // --- Dorset council PDF + email (only if Dorset is the council) ---
  771. try {
  772. // Guard so we only do this for Dorset; set in your LOA front matter:
  773. // council:
  774. // name: "Dorset Council"
  775. // email: "development@dorset.tas.gov.au"
  776. $councilName = (string) getByPath($vars, 'council.name', '');
  777. $councilEmail = (string) getByPath($vars, 'council.email', '');
  778. if ($councilEmail && stripos($councilEmail, 'tazz.com.au') !== false) { //dorset.tas.gov.au
  779. require_once __DIR__ . '/dorset_fill.php';
  780. $templatePath = __DIR__ . '/loa/dorset_consent_form.pdf';
  781. $dorsetOutAbs = __DIR__ . "/loa/{$job}/{$job}_dorset_consent_form.pdf";
  782. $dorsetPdf = generate_dorset_application($job, $vars, $cfg, $templatePath, $dorsetOutAbs);
  783. if ($dorsetPdf) {
  784. // Email Dorset + BCC dev
  785. $mailCouncil = new PHPMailer(true);
  786. // SMTP (optional but recommended if set in config)
  787. if (!empty($cfg['smtp_host'])) {
  788. $mailCouncil->isSMTP();
  789. $mailCouncil->Host = $cfg['smtp_host'] ?? '';
  790. $mailCouncil->SMTPAuth = true;
  791. $mailCouncil->Username = $cfg['smtp_username'] ?? '';
  792. $mailCouncil->Password = $cfg['smtp_password'] ?? '';
  793. $mailCouncil->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
  794. $mailCouncil->Port = (int)($cfg['smtp_port'] ?? 465);
  795. }
  796. // From/Reply-To
  797. $fromAddress = (string)($cfg['from_address'] ?? 'drafting@modulosdesign.com.au');
  798. $mailCouncil->CharSet = 'UTF-8';
  799. $mailCouncil->Encoding = 'base64';
  800. $mailCouncil->setFrom($fromAddress, $CFG['brand_name'] ?? 'Modulos Design');
  801. if (!empty($cfg['dev_email'])) $mailCouncil->addReplyTo($cfg['dev_email']);
  802. // ✅ REQUIRED: recipient(s)
  803. $mailCouncil->addAddress($councilEmail);
  804. if (!empty($CFG['bcc_email'])) $mailCouncil->addBCC($CFG['bcc_email']);
  805. // Build body using shared template
  806. $logoCouncil = email_logo_png_cid($mailCouncil, $cfg['dark_logo'] ?? "", $CFG['brand_name'] ?? 'Modulos Design', 200);
  807. $sigCouncil = email_logo_png_cid($mailCouncil, $cfg['dev_signature'] ?? "", "Signature", 100);
  808. $loaUrl = abs_url($pdfPublicRel);
  809. [$subject, $html, $alt] = buildCouncilRequestEmail(
  810. $logoCouncil,
  811. $job,
  812. $vars,
  813. $loaUrl,
  814. (string)($CFG['brand_name'] ?? 'Modulos Design'),
  815. $sigCouncil
  816. );
  817. $mailCouncil->isHTML(true);
  818. $mailCouncil->Subject = $subject;
  819. $mailCouncil->Body = $html;
  820. $mailCouncil->AltBody = $alt;
  821. // Attach Dorset form + (optionally) the signed LOA
  822. if (is_file($dorsetPdf)) $mailCouncil->addAttachment($dorsetPdf, basename($dorsetPdf));
  823. if (is_file($pdfPathAbs)) $mailCouncil->addAttachment($pdfPathAbs, basename($pdfPathAbs));
  824. try {
  825. $mailCouncil->send();
  826. } catch (Throwable $e) {
  827. error_log("Council email failed for job {$job} to {$councilEmail}: ".$e->getMessage());
  828. }
  829. }
  830. }
  831. } catch (Throwable $e) {
  832. error_log("Dorset generation error for job {$job}: ".$e->getMessage());
  833. }
  834. // Build the mailer once per recipient (same as contracts.php)
  835. $targets = [];
  836. if ($clientEmail) $targets[] = ["to" => $clientEmail, "kind" => "client"];
  837. if ($devEmail) $targets[] = ["to" => $devEmail, "kind" => "dev"];
  838. foreach ($targets as $t) {
  839. $mail = new PHPMailer(true);
  840. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  841. if (!empty($cfg["smtp_host"])) {
  842. $mail->isSMTP();
  843. $mail->Host = $cfg["smtp_host"] ?? "";
  844. $mail->SMTPAuth = true;
  845. $mail->Username = $cfg["smtp_username"] ?? "";
  846. $mail->Password = $cfg["smtp_password"] ?? "";
  847. $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; // 465/SSL
  848. $mail->Port = $cfg["smtp_port"] ?? 465;
  849. }
  850. $mail->CharSet = "UTF-8";
  851. $mail->Encoding = "base64";
  852. $mail->setFrom($fromAddress, $CFG["brand_name"] ?? "Modulos Design");
  853. if ($t["kind"] === "client" && $devEmail) $mail->addReplyTo($devEmail);
  854. if ($t["kind"] === "dev" && $clientEmail)$mail->addReplyTo($clientEmail);
  855. $mail->addAddress($t["to"]);
  856. $mail->isHTML(true);
  857. // Embed assets per message
  858. $logoHtml = email_logo_png_cid($mail, $cfg["dark_logo"] ?? "", $CFG["brand_name"] ?? "Modulos Design", 200);
  859. $safeSignature = email_logo_png_cid($mail, $cfg["dev_signature"] ?? "", "Signature", 100);
  860. [$subject, $html, $alt] = buildSignedLoaEmail(
  861. $logoHtml,
  862. abs_url($pdfPublicRel), // <= FIX: public URL, not the filesystem path
  863. $job,
  864. (string)getByPath($vars, "client.name", ""),
  865. (string)getByPath($vars, "dates.prepared", date("F j, Y")),
  866. (string)($CFG["brand_name"] ?? "Modulos Design"),
  867. $safeSignature
  868. );
  869. // Developer copy extra info
  870. if ($t["kind"] === "dev") {
  871. $subject = $job . " – Authorisation has been signed";
  872. $signedBy = h($clientEmail ?: "unknown");
  873. $inject = '<tr><td style="padding:4px 24px 0;font-size:14px;color:#444;">Signed by: ' . $signedBy . '</td></tr>';
  874. $html = preg_replace('/(<tr>\s*<td[^>]*>.*?<\/td>\s*<\/tr>)/s', '$1' . $inject, $html, 1)
  875. ?: str_replace('</tr><tr>', '</tr>' . $inject . '<tr>', $html);
  876. $alt .= "\n\nSigned by: " . ($clientEmail ?: "unknown");
  877. }
  878. $mail->Subject = $subject;
  879. $mail->Body = $html;
  880. if (!empty($alt)) $mail->AltBody = $alt;
  881. if (!empty($CFG["bcc_email"])) $mail->addBCC($CFG["bcc_email"]);
  882. if (is_file($pdfPathAbs)) $mail->addAttachment($pdfPathAbs, basename($pdfPathAbs));
  883. try {
  884. $mail->send();
  885. } catch (MailerException $e) {
  886. error_log("LOA mailer error to {$t['to']}: {$mail->ErrorInfo}\n");
  887. }
  888. }
  889. // Public URL to the signed LOA (for the link in the email body)
  890. $loaUrl = abs_url($pdfPublicRel);
  891. // Council recipients
  892. $councilTo = council_recipients($vars, $cfg);
  893. foreach ($councilTo as $addr) {
  894. try {
  895. $mail = new PHPMailer(true);
  896. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  897. if (!empty($cfg["smtp_host"])) {
  898. $mail->isSMTP();
  899. $mail->Host = $cfg["smtp_host"] ?? "";
  900. $mail->SMTPAuth = true;
  901. $mail->Username = $cfg["smtp_username"] ?? "";
  902. $mail->Password = $cfg["smtp_password"] ?? "";
  903. $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
  904. $mail->Port = $cfg["smtp_port"] ?? 465;
  905. }
  906. $mail->CharSet = "UTF-8";
  907. $mail->Encoding = "base64";
  908. $mail->setFrom($CFG["from_email"] ?? "drafting@modulosdesign.com.au", $CFG["brand_name"] ?? "Modulos Design");
  909. // Replies come back to you
  910. if (!empty($cfg["dev_email"])) $mail->addReplyTo($cfg["dev_email"]);
  911. $mail->addAddress($addr);
  912. $mail->isHTML(true);
  913. // Embed assets
  914. $logoHtml = email_logo_png_cid($mail, $cfg["dark_logo"] ?? "", $CFG["brand_name"] ?? "Modulos Design", 200);
  915. $safeSignature = email_logo_png_cid($mail, $cfg["dev_signature"] ?? "", "Signature", 100);
  916. // Build council email
  917. [$subject, $html, $alt] = buildCouncilRequestEmail(
  918. $logoHtml,
  919. $job,
  920. $vars,
  921. $loaUrl,
  922. (string)($CFG["brand_name"] ?? "Modulos Design"),
  923. $safeSignature
  924. );
  925. $mail->Subject = $subject;
  926. $mail->Body = $html;
  927. $mail->AltBody = $alt;
  928. if (!empty($CFG["bcc_email"])) $mail->addBCC($CFG["bcc_email"]);
  929. if (is_file($pdfPathAbs)) $mail->addAttachment($pdfPathAbs, basename($pdfPathAbs)); // attach signed LOA
  930. $mail->send();
  931. } catch (MailerException $e) {
  932. error_log("Council mailer error to {$addr}: {$mail->ErrorInfo}\n");
  933. }
  934. }
  935. // Redirect to signed HTML
  936. header("Location: " . $pdfPublicRel, true, 303);
  937. exit;
  938. }