You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

555 line
29KB

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta http-equiv="X-UA-Compatible" content="IE=9" />
  5. <title>MassMailer</title>
  6. <HTA:APPLICATION ID="MassMailer" APPLICATIONNAME="MassMailer" BORDER="thick" SCROLL="yes" SINGLEINSTANCE="yes" />
  7. <style type="text/css">
  8. body { margin: 0; font: 15px Segoe UI, Arial, sans-serif; color: #243145; background: #f4f6f8; }
  9. header { background: #17324e; color: #fff; padding: 22px 28px; }
  10. h1 { margin: 0; font-size: 24px; }
  11. header p { margin: 7px 0 0; color: #d2dfec; }
  12. main { padding: 24px 28px; }
  13. .notice { padding: 14px 17px; background: #fff7e3; border-left: 4px solid #ca8f13; margin-bottom: 22px; }
  14. .cards { display: table; width: 100%; border-spacing: 10px; margin-left: -10px; }
  15. .card { display: table-cell; width: 25%; background: white; padding: 18px; border: 1px solid #dbe1e7; }
  16. .number { display: block; margin-top: 8px; font-size: 28px; color: #17324e; }
  17. .details { background: white; padding: 18px; border: 1px solid #dbe1e7; line-height: 1.7; }
  18. .details strong { display: inline-block; width: 135px; }
  19. button { background: #176b9b; color: white; border: 0; padding: 9px 16px; cursor: pointer; margin-top: 17px; }
  20. button:disabled { background: #a7b3bd; cursor: default; }
  21. .panel { background: white; padding: 18px; border: 1px solid #dbe1e7; margin-top: 18px; }
  22. .panel h2 { margin: 0 0 12px; font-size: 19px; }
  23. .field { display: block; margin: 12px 0; }
  24. .field input, .field select { display: block; width: 95%; max-width: 640px; padding: 7px; margin-top: 4px; }
  25. table { border-collapse: collapse; width: 100%; margin-top: 15px; }
  26. th, td { border-bottom: 1px solid #e4e8ec; padding: 7px; text-align: left; }
  27. .bad { color: #9c2929; }
  28. .good { color: #235e37; }
  29. #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; }
  30. #templatePreview, #campaignPreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; }
  31. </style>
  32. <script language="JScript" src="src/contacts.js"></script>
  33. <script language="JScript" type="text/javascript">
  34. var pendingCsvText = null;
  35. var pendingPreview = null;
  36. var pendingSource = "";
  37. var pendingPath = "";
  38. var pendingCampaignReview = null;
  39. var campaignTestAccepted = false;
  40. function projectRoot() {
  41. var path = decodeURIComponent(window.location.pathname);
  42. if (path.charAt(0) === "/" && path.charAt(2) === ":") path = path.substring(1);
  43. path = path.replace(/\//g, "\\");
  44. return new ActiveXObject("Scripting.FileSystemObject").GetParentFolderName(path);
  45. }
  46. function readSettings(filePath) {
  47. var stream = new ActiveXObject("ADODB.Stream");
  48. stream.Type = 2;
  49. stream.Charset = "utf-8";
  50. stream.Open();
  51. stream.LoadFromFile(filePath);
  52. var data = stream.ReadText();
  53. stream.Close();
  54. var result = {};
  55. var lines = data.replace(/\r\n?/g, "\n").split("\n");
  56. for (var i = 0; i < lines.length; i++) {
  57. var line = lines[i].replace(/^\s+|\s+$/g, "");
  58. if (!line || line.charAt(0) === "#" || line.charAt(0) === ";") continue;
  59. var equalAt = line.indexOf("=");
  60. if (equalAt > 0) result[line.substring(0, equalAt).replace(/\s/g, "").toUpperCase()] =
  61. line.substring(equalAt + 1).replace(/^\s+|\s+$/g, "");
  62. }
  63. return result;
  64. }
  65. function countRows(db, tableName) {
  66. var rows = db.Execute("SELECT COUNT(*) AS N FROM [" + tableName + "]");
  67. var result = rows.Fields("N").Value;
  68. rows.Close();
  69. return result;
  70. }
  71. function openDatabase() {
  72. var fs = new ActiveXObject("Scripting.FileSystemObject");
  73. var root = projectRoot();
  74. var config = readSettings(fs.BuildPath(root, "config.ini"));
  75. var dbPath = config.DB_PATH;
  76. if (fs.GetDriveName(dbPath) === "") dbPath = fs.BuildPath(root, dbPath);
  77. dbPath = fs.GetAbsolutePathName(dbPath);
  78. var db = new ActiveXObject("ADODB.Connection");
  79. db.Open("Provider=" + config.DB_PROVIDER + ";Data Source=" + dbPath + ";");
  80. return { db: db, config: config, path: dbPath };
  81. }
  82. function refreshStatus() {
  83. var db = null;
  84. try {
  85. var opened = openDatabase();
  86. db = opened.db;
  87. document.getElementById("contacts").innerText = countRows(db, "Contact");
  88. document.getElementById("suppressed").innerText = countRows(db, "Suppression");
  89. document.getElementById("templates").innerText = countRows(db, "TemplateVersion");
  90. document.getElementById("campaigns").innerText = countRows(db, "Campaign");
  91. document.getElementById("database").innerText = opened.path;
  92. document.getElementById("provider").innerText = opened.config.DB_PROVIDER;
  93. document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
  94. document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
  95. document.getElementById("error").innerText = "";
  96. refreshTemplates();
  97. refreshCampaigns();
  98. } catch (error) {
  99. document.getElementById("error").innerText = "Status refresh failed: " + error.message;
  100. } finally {
  101. if (db !== null && db.State === 1) db.Close();
  102. }
  103. }
  104. function readCsvFile(path) {
  105. var fs = new ActiveXObject("Scripting.FileSystemObject");
  106. if (!fs.FileExists(path)) throw new Error("CSV file not found: " + path);
  107. if (fs.GetFile(path).Size > 2097152) throw new Error("CSV exceeds the 2 MB import limit");
  108. var stream = new ActiveXObject("ADODB.Stream");
  109. stream.Type = 2;
  110. stream.Charset = "utf-8";
  111. stream.Open();
  112. stream.LoadFromFile(path);
  113. var text = stream.ReadText();
  114. stream.Close();
  115. return text;
  116. }
  117. function emailLookup(db, tableName) {
  118. var found = {};
  119. var rows = db.Execute("SELECT [EmailKey] FROM [" + tableName + "]");
  120. while (!rows.EOF) {
  121. found[String(rows.Fields("EmailKey").Value).toLowerCase()] = true;
  122. rows.MoveNext();
  123. }
  124. rows.Close();
  125. return found;
  126. }
  127. function renderPreview(preview) {
  128. document.getElementById("previewCount").innerText = preview.accepted.length +
  129. " ready, " + preview.rejected.length + " rejected";
  130. var body = document.getElementById("previewBody");
  131. while (body.firstChild) body.removeChild(body.firstChild);
  132. var items = preview.accepted.concat(preview.rejected);
  133. items.sort(function (a, b) { return a.line - b.line; });
  134. for (var i = 0; i < items.length; i++) {
  135. var tr = document.createElement("tr");
  136. var values = [items[i].line, items[i].email, items[i].businessName,
  137. items[i].source, items[i].reason || "Ready"];
  138. for (var j = 0; j < values.length; j++) {
  139. var td = document.createElement("td");
  140. td.innerText = values[j];
  141. if (j === 4) td.className = items[i].reason ? "bad" : "good";
  142. tr.appendChild(td);
  143. }
  144. body.appendChild(tr);
  145. }
  146. document.getElementById("commitImport").disabled = preview.accepted.length === 0;
  147. }
  148. function previewContacts() {
  149. var db = null;
  150. document.getElementById("importMessage").innerText = "";
  151. document.getElementById("commitImport").disabled = true;
  152. try {
  153. var path = document.getElementById("csvPath").value;
  154. pendingPath = path;
  155. pendingSource = document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "");
  156. pendingCsvText = readCsvFile(path);
  157. var opened = openDatabase();
  158. db = opened.db;
  159. pendingPreview = MassMailerContacts.preview(pendingCsvText, pendingSource,
  160. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  161. renderPreview(pendingPreview);
  162. } catch (error) {
  163. pendingPreview = null;
  164. document.getElementById("importMessage").innerText = error.message;
  165. } finally {
  166. if (db !== null && db.State === 1) db.Close();
  167. }
  168. }
  169. function utcNowSql() {
  170. return "DateAdd('n'," + new Date().getTimezoneOffset() + ",Now())";
  171. }
  172. function importOne(db, row) {
  173. var dateSql = utcNowSql();
  174. if (row.acquiredAt) {
  175. dateSql = "DateSerial(" + Number(row.acquiredAt.substring(0, 4)) + "," +
  176. Number(row.acquiredAt.substring(5, 7)) + "," +
  177. Number(row.acquiredAt.substring(8, 10)) + ")";
  178. }
  179. var cmd = new ActiveXObject("ADODB.Command");
  180. cmd.ActiveConnection = db;
  181. cmd.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName]," +
  182. "[SourceReference],[AcquiredAtUtc],[Country],[EligibilityBasis],[CreatedAtUtc]) " +
  183. "VALUES (?,?,?,? ," + dateSql + ",'US','public_listing'," + utcNowSql() + ")";
  184. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, row.email));
  185. cmd.Parameters.Append(cmd.CreateParameter("key", 202, 1, 254, row.emailKey));
  186. cmd.Parameters.Append(cmd.CreateParameter("business", 202, 1, 200, row.businessName));
  187. cmd.Parameters.Append(cmd.CreateParameter("source", 203, 1, Math.max(1, row.source.length), row.source));
  188. cmd.Execute();
  189. }
  190. function commitContacts() {
  191. if (!pendingPreview || !pendingPreview.accepted.length) return;
  192. var db = null, inTransaction = false, succeeded = false;
  193. try {
  194. if (document.getElementById("csvPath").value !== pendingPath)
  195. throw new Error("CSV file selection changed. Preview it again.");
  196. if (document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "") !== pendingSource)
  197. throw new Error("Source reference changed. Preview the CSV again.");
  198. var opened = openDatabase();
  199. db = opened.db;
  200. var current = MassMailerContacts.preview(pendingCsvText, pendingSource,
  201. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  202. if (current.accepted.length !== pendingPreview.accepted.length ||
  203. current.rejected.length !== pendingPreview.rejected.length)
  204. throw new Error("Contacts or suppressions changed. Preview the CSV again.");
  205. for (var check = 0; check < current.accepted.length; check++) {
  206. if (current.accepted[check].emailKey !== pendingPreview.accepted[check].emailKey)
  207. throw new Error("Contacts changed. Preview the CSV again.");
  208. }
  209. db.BeginTrans();
  210. inTransaction = true;
  211. for (var i = 0; i < current.accepted.length; i++) importOne(db, current.accepted[i]);
  212. db.CommitTrans();
  213. inTransaction = false;
  214. document.getElementById("importMessage").innerText =
  215. "Imported " + current.accepted.length + " contacts. Rejected rows were not saved.";
  216. pendingPreview = null;
  217. document.getElementById("commitImport").disabled = true;
  218. succeeded = true;
  219. } catch (error) {
  220. if (inTransaction) db.RollbackTrans();
  221. document.getElementById("importMessage").innerText = "Import failed: " + error.message;
  222. } finally {
  223. if (db !== null && db.State === 1) db.Close();
  224. if (succeeded) refreshStatus();
  225. }
  226. }
  227. function addSuppression() {
  228. var db = null, succeeded = false;
  229. try {
  230. var email = document.getElementById("suppressEmail").value.replace(/^\s+|\s+$/g, "");
  231. if (!MassMailerContacts.isEmail(email)) throw new Error("Enter a valid email address");
  232. var key = MassMailerContacts.emailKey(email);
  233. var reason = document.getElementById("suppressReason").value;
  234. var evidence = document.getElementById("suppressEvidence").value.replace(/^\s+|\s+$/g, "");
  235. var opened = openDatabase();
  236. db = opened.db;
  237. if (emailLookup(db, "Suppression")[key]) throw new Error("Address is already suppressed");
  238. var cmd = new ActiveXObject("ADODB.Command");
  239. cmd.ActiveConnection = db;
  240. cmd.CommandText = "INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]," +
  241. "[EvidenceReference]) VALUES (?, ?, " + utcNowSql() + ", ?)";
  242. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, key));
  243. cmd.Parameters.Append(cmd.CreateParameter("reason", 202, 1, 40, reason));
  244. cmd.Parameters.Append(cmd.CreateParameter("evidence", 203, 1, Math.max(1, evidence.length), evidence));
  245. cmd.Execute();
  246. document.getElementById("suppressMessage").innerText = "Suppressed " + key;
  247. document.getElementById("suppressEmail").value = "";
  248. succeeded = true;
  249. } catch (error) {
  250. document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
  251. } finally {
  252. if (db !== null && db.State === 1) db.Close();
  253. if (succeeded) refreshStatus();
  254. }
  255. }
  256. function powerShellHost() {
  257. var fs = new ActiveXObject("Scripting.FileSystemObject");
  258. var shell = new ActiveXObject("WScript.Shell");
  259. var root = shell.ExpandEnvironmentStrings("%SystemRoot%");
  260. var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
  261. var folder = (settings.DB_BITNESS || "").toLowerCase() === "x64" ? "System32" :
  262. (fs.FolderExists(root + "\\SysWOW64") ? "SysWOW64" : "System32");
  263. return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe";
  264. }
  265. function quoted(value) { return '"' + value + '"'; }
  266. function configurePassword() {
  267. try {
  268. var fs = new ActiveXObject("Scripting.FileSystemObject");
  269. var script = fs.BuildPath(projectRoot(), "tools\\set-smtp-password.ps1");
  270. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + quoted(script);
  271. var status = new ActiveXObject("WScript.Shell").Run(command, 1, true);
  272. document.getElementById("transportMessage").innerText = status === 0 ?
  273. "SMTP password setup completed." : "SMTP password setup failed. Review the console error.";
  274. } catch (error) {
  275. document.getElementById("transportMessage").innerText = "Setup failed: " + error.message;
  276. }
  277. }
  278. function sendInternalTest() {
  279. try {
  280. var fs = new ActiveXObject("Scripting.FileSystemObject");
  281. var root = projectRoot();
  282. var settings = readSettings(fs.BuildPath(root, "config.ini"));
  283. if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
  284. if (!window.confirm("Send one internal SMTP test to " + settings.SMTP_TEST_TO + "?")) return;
  285. var script = fs.BuildPath(root, "tools\\test-smtp-message.ps1");
  286. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  287. quoted(script) + " -Send";
  288. var process = new ActiveXObject("WScript.Shell").Exec(command);
  289. var output = process.StdOut.ReadAll();
  290. var errors = process.StdErr.ReadAll();
  291. document.getElementById("transportMessage").innerText =
  292. (process.ExitCode === 0 ? "" : "Test failed. ") + output + errors;
  293. } catch (error) {
  294. document.getElementById("transportMessage").innerText = "Test failed: " + error.message;
  295. }
  296. }
  297. function refreshTemplates() {
  298. var db = null;
  299. try {
  300. db = openDatabase().db;
  301. var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC");
  302. var select = document.getElementById("templateVersion");
  303. var campaignSelect = document.getElementById("campaignTemplateVersion");
  304. while (select.options.length) select.remove(0);
  305. while (campaignSelect.options.length) campaignSelect.remove(0);
  306. while (!rows.EOF) {
  307. var value = String(rows.Fields("Id").Value);
  308. var label = String(rows.Fields("TemplateName").Value) + " — version " +
  309. String(rows.Fields("VersionNumber").Value);
  310. var option = document.createElement("option");
  311. option.value = value;
  312. option.text = label;
  313. select.add(option);
  314. var campaignOption = document.createElement("option");
  315. campaignOption.value = value;
  316. campaignOption.text = label;
  317. campaignSelect.add(campaignOption);
  318. rows.MoveNext();
  319. }
  320. rows.Close();
  321. invalidateCampaignReview();
  322. } catch (error) {
  323. document.getElementById("templateMessage").innerText = "Template list failed: " + error.message;
  324. } finally {
  325. if (db !== null && db.State === 1) db.Close();
  326. }
  327. }
  328. function runTemplateTool(scriptName, args) {
  329. var fs = new ActiveXObject("Scripting.FileSystemObject");
  330. var script = fs.BuildPath(projectRoot(), "tools\\" + scriptName);
  331. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  332. quoted(script) + " " + args;
  333. var process = new ActiveXObject("WScript.Shell").Exec(command);
  334. var output = process.StdOut.ReadAll();
  335. var errors = process.StdErr.ReadAll();
  336. if (process.ExitCode !== 0) throw new Error((output + errors).replace(/^\s+|\s+$/g, ""));
  337. return output.replace(/^\s+|\s+$/g, "");
  338. }
  339. function importTemplate() {
  340. try {
  341. var path = document.getElementById("templatePath").value;
  342. if (!path || path.indexOf('"') !== -1) throw new Error("Select a template file");
  343. var result = runTemplateTool("import-template.ps1", "-Path " + quoted(path));
  344. document.getElementById("templateMessage").innerText = result;
  345. refreshTemplates();
  346. refreshStatus();
  347. } catch (error) {
  348. document.getElementById("templateMessage").innerText = "Import failed: " + error.message;
  349. }
  350. }
  351. function previewTemplate() {
  352. try {
  353. var id = document.getElementById("templateVersion").value;
  354. if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an imported template");
  355. var file = runTemplateTool("preview-template.ps1", "-TemplateId " + id);
  356. document.getElementById("templatePreview").src = file;
  357. document.getElementById("templateMessage").innerText =
  358. "Showing the imported snapshot. This preview does not include the campaign footer yet.";
  359. } catch (error) {
  360. document.getElementById("templateMessage").innerText = "Preview failed: " + error.message;
  361. }
  362. }
  363. function refreshCampaigns() {
  364. var db = null;
  365. try {
  366. db = openDatabase().db;
  367. var rows = db.Execute("SELECT [Id],[Name],[Status] FROM [Campaign] ORDER BY [Id] DESC");
  368. var body = document.getElementById("campaignBody");
  369. while (body.firstChild) body.removeChild(body.firstChild);
  370. while (!rows.EOF) {
  371. var tr = document.createElement("tr");
  372. var values = [rows.Fields("Id").Value, rows.Fields("Name").Value,
  373. rows.Fields("Status").Value];
  374. for (var i = 0; i < values.length; i++) {
  375. var td = document.createElement("td");
  376. td.innerText = String(values[i]);
  377. tr.appendChild(td);
  378. }
  379. body.appendChild(tr);
  380. rows.MoveNext();
  381. }
  382. rows.Close();
  383. } catch (error) {
  384. document.getElementById("campaignMessage").innerText = "Campaign list failed: " + error.message;
  385. } finally {
  386. if (db !== null && db.State === 1) db.Close();
  387. }
  388. }
  389. function invalidateCampaignReview() {
  390. pendingCampaignReview = null;
  391. campaignTestAccepted = false;
  392. document.getElementById("sendCampaignTest").disabled = true;
  393. document.getElementById("approveCampaign").disabled = true;
  394. document.getElementById("confirmReceived").checked = false;
  395. document.getElementById("confirmReceived").disabled = true;
  396. document.getElementById("campaignReview").innerText = "";
  397. document.getElementById("campaignPreview").src = "about:blank";
  398. }
  399. function campaignRequest() {
  400. return {
  401. Name: document.getElementById("campaignName").value,
  402. Subject: document.getElementById("campaignSubject").value,
  403. TemplateId: document.getElementById("campaignTemplateVersion").value,
  404. ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "",
  405. TestReceived: document.getElementById("confirmReceived").checked
  406. };
  407. }
  408. function withCampaignRequest(scriptName, extraArgs) {
  409. var fs = new ActiveXObject("Scripting.FileSystemObject");
  410. var folder = fs.BuildPath(projectRoot(), "temp");
  411. if (!fs.FolderExists(folder)) fs.CreateFolder(folder);
  412. var path = fs.BuildPath(folder, fs.GetTempName());
  413. var stream = new ActiveXObject("ADODB.Stream");
  414. try {
  415. stream.Type = 2;
  416. stream.Charset = "utf-8";
  417. stream.Open();
  418. stream.WriteText(JSON.stringify(campaignRequest()));
  419. stream.SaveToFile(path, 2);
  420. stream.Close();
  421. return runTemplateTool(scriptName, "-RequestPath " + quoted(path) + (extraArgs || ""));
  422. } finally {
  423. if (stream.State === 1) stream.Close();
  424. if (fs.FileExists(path)) fs.DeleteFile(path);
  425. }
  426. }
  427. function reviewCampaign() {
  428. invalidateCampaignReview();
  429. try {
  430. var data = JSON.parse(withCampaignRequest("review-campaign.ps1", ""));
  431. pendingCampaignReview = data;
  432. document.getElementById("campaignReview").innerText =
  433. data.Count + " eligible recipients. From: " + data.From + ". Reply-to: " +
  434. data.ReplyTo + ". Postal address: " + data.PostalAddress +
  435. ". Template: " + data.TemplateName + " v" + data.TemplateVersion +
  436. ". Subject: " + data.Subject + ".";
  437. document.getElementById("campaignPreview").src = data.PreviewPath;
  438. document.getElementById("sendCampaignTest").disabled = false;
  439. document.getElementById("campaignMessage").innerText =
  440. "Review the full message, then send it to the internal test inbox.";
  441. } catch (error) {
  442. document.getElementById("campaignMessage").innerText = "Review failed: " + error.message;
  443. }
  444. }
  445. function sendReviewedCampaignTest() {
  446. if (!pendingCampaignReview) return;
  447. if (!window.confirm("Send this campaign test to " + pendingCampaignReview.TestTo + "?")) return;
  448. try {
  449. var result = withCampaignRequest("send-campaign-test.ps1", " -Send");
  450. campaignTestAccepted = true;
  451. document.getElementById("confirmReceived").disabled = false;
  452. document.getElementById("campaignMessage").innerText = result;
  453. } catch (error) {
  454. document.getElementById("campaignMessage").innerText = "Campaign test failed: " + error.message;
  455. }
  456. }
  457. function updateApprovalButton() {
  458. document.getElementById("approveCampaign").disabled =
  459. !pendingCampaignReview || !campaignTestAccepted ||
  460. !document.getElementById("confirmReceived").checked;
  461. }
  462. function approveReviewedCampaign() {
  463. if (!pendingCampaignReview || !campaignTestAccepted ||
  464. !document.getElementById("confirmReceived").checked) return;
  465. if (!window.confirm("Approve " + pendingCampaignReview.Count +
  466. " recipients? The campaign will remain paused.")) return;
  467. try {
  468. var result = withCampaignRequest("approve-campaign.ps1", "");
  469. document.getElementById("campaignMessage").innerText = result;
  470. invalidateCampaignReview();
  471. refreshStatus();
  472. } catch (error) {
  473. document.getElementById("campaignMessage").innerText = "Approval failed: " + error.message;
  474. }
  475. }
  476. </script>
  477. </head>
  478. <body onload="refreshStatus()">
  479. <header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
  480. <main>
  481. <div class="notice"><strong>Campaign sending is disabled in this build.</strong> The SMTP test sends one message only when you press its button.</div>
  482. <div class="cards">
  483. <div class="card">Contacts<span class="number" id="contacts">—</span></div>
  484. <div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
  485. <div class="card">Templates<span class="number" id="templates">—</span></div>
  486. <div class="card">Campaigns<span class="number" id="campaigns">—</span></div>
  487. </div>
  488. <div class="details">
  489. <div><strong>Database</strong><span id="database">Checking…</span></div>
  490. <div><strong>Provider</strong><span id="provider">Checking…</span></div>
  491. <div><strong>Send interval</strong><span id="interval">Checking…</span></div>
  492. <div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
  493. <div><strong>Send state</strong>Disabled</div>
  494. <button onclick="refreshStatus()">Refresh status</button>
  495. <div id="error"></div>
  496. </div>
  497. <div class="panel">
  498. <h2>SMTP setup and internal test</h2>
  499. <p>The password entry opens a local console and stores a protected value in the MDB. The test sends one HTML message with an inline image to the configured test inbox.</p>
  500. <button onclick="configurePassword()">Set or rotate SMTP password</button>
  501. <button onclick="sendInternalTest()">Send one internal test</button>
  502. <div id="transportMessage"></div>
  503. </div>
  504. <div class="panel">
  505. <h2>Import business contacts</h2>
  506. <p>UTF-8 CSV needs an <code>email</code> header. Optional headers: <code>business_name</code>, <code>source_reference</code>, and <code>acquired_at</code> (YYYY-MM-DD). Every accepted row needs a source reference.</p>
  507. <label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
  508. <label class="field">Source URL or reference for rows without one
  509. <input id="sourceFallback" type="text" maxlength="2048" /></label>
  510. <button onclick="previewContacts()">Preview import</button>
  511. <button id="commitImport" onclick="commitContacts()" disabled="disabled">Import ready contacts</button>
  512. <div id="importMessage"></div>
  513. <p id="previewCount"></p>
  514. <table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
  515. <tbody id="previewBody"></tbody></table>
  516. </div>
  517. <div class="panel">
  518. <h2>HTML templates</h2>
  519. <p>Choose a well-formed HTML file inside the configured template folder. Images must use relative PNG, JPEG, or GIF paths. Imports create a new version and copy image bytes into the database.</p>
  520. <label class="field">Template file <input id="templatePath" type="file" accept=".html,.htm" /></label>
  521. <button onclick="importTemplate()">Import template version</button>
  522. <label class="field">Imported version <select id="templateVersion"></select></label>
  523. <button onclick="previewTemplate()">Preview selected version</button>
  524. <div id="templateMessage"></div>
  525. <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe>
  526. </div>
  527. <div class="panel">
  528. <h2>Review and approve a campaign</h2>
  529. <p>Approval captures currently eligible contacts and the selected template version. It requires a complete postal address in config.ini, a campaign-specific internal test, and your confirmation that you received it. Approved campaigns stay paused until dispatch controls are built.</p>
  530. <label class="field">Campaign name <input id="campaignName" type="text" maxlength="200" oninput="invalidateCampaignReview()" /></label>
  531. <label class="field">Email subject <input id="campaignSubject" type="text" maxlength="240" oninput="invalidateCampaignReview()" /></label>
  532. <label class="field">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label>
  533. <button onclick="reviewCampaign()">Review message and recipients</button>
  534. <div id="campaignReview"></div>
  535. <iframe id="campaignPreview" APPLICATION="no" title="Full campaign preview"></iframe>
  536. <button id="sendCampaignTest" onclick="sendReviewedCampaignTest()" disabled="disabled">Send reviewed message to internal test inbox</button>
  537. <label class="field"><input id="confirmReceived" type="checkbox" disabled="disabled" onchange="updateApprovalButton()" style="display:inline;width:auto" /> I received and checked the campaign test message.</label>
  538. <button id="approveCampaign" onclick="approveReviewedCampaign()" disabled="disabled">Approve campaign (paused)</button>
  539. <div id="campaignMessage"></div>
  540. <table><thead><tr><th>ID</th><th>Campaign</th><th>Status</th></tr></thead><tbody id="campaignBody"></tbody></table>
  541. </div>
  542. <div class="panel">
  543. <h2>Suppress an address</h2>
  544. <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
  545. <label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
  546. <label class="field">Reason <select id="suppressReason"><option value="opt-out">Opt-out reply</option><option value="manual">Manual</option><option value="bounce">Hard bounce</option><option value="complaint">Complaint</option></select></label>
  547. <label class="field">Evidence or note <input id="suppressEvidence" type="text" maxlength="2048" /></label>
  548. <button onclick="addSuppression()">Suppress address</button>
  549. <div id="suppressMessage"></div>
  550. </div>
  551. </main>
  552. </body>
  553. </html>

Powered by TurnKey Linux.