Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

871 Zeilen
45KB

  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. .tabbar { margin: 0 0 20px; border-bottom: 2px solid #cbd7e2; }
  14. .tab-button { margin: 0 4px 0 0; padding: 11px 15px; background: transparent; color: #17324e; border: 1px solid transparent; border-bottom: 0; font-weight: bold; }
  15. .tab-button:hover, .tab-button:focus { background: #e8eff5; }
  16. .tab-button.active { background: white; border-color: #cbd7e2; color: #105b88; }
  17. .tab-pane { display: none; }
  18. .tab-pane.active { display: block; }
  19. .notice { padding: 14px 17px; background: #fff7e3; border-left: 4px solid #ca8f13; margin-bottom: 22px; }
  20. .cards { display: table; width: 100%; border-spacing: 10px; margin-left: -10px; }
  21. .card { display: table-cell; width: 25%; background: white; padding: 18px; border: 1px solid #dbe1e7; }
  22. .number { display: block; margin-top: 8px; font-size: 28px; color: #17324e; }
  23. .details { background: white; padding: 18px; border: 1px solid #dbe1e7; line-height: 1.7; }
  24. .details strong { display: inline-block; width: 135px; }
  25. button { background: #176b9b; color: white; border: 0; padding: 9px 16px; cursor: pointer; margin-top: 17px; }
  26. button:disabled { background: #a7b3bd; cursor: default; }
  27. .panel { background: white; padding: 18px; border: 1px solid #dbe1e7; margin-top: 18px; }
  28. .panel h2 { margin: 0 0 12px; font-size: 19px; }
  29. .field { display: block; margin: 12px 0; }
  30. .field input, .field select { display: block; width: 95%; max-width: 640px; padding: 7px; margin-top: 4px; }
  31. table { border-collapse: collapse; width: 100%; margin-top: 15px; }
  32. th, td { border-bottom: 1px solid #e4e8ec; padding: 7px; text-align: left; }
  33. .bad { color: #9c2929; }
  34. .good { color: #235e37; }
  35. #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; }
  36. #templatePreview, #campaignPreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; }
  37. </style>
  38. <script language="JScript" src="src/contacts.js"></script>
  39. <script language="JScript" type="text/javascript">
  40. var pendingCsvText = null;
  41. var pendingPreview = null;
  42. var pendingSource = "";
  43. var pendingPath = "";
  44. var pendingCampaignReview = null;
  45. var campaignTestAccepted = false;
  46. var dispatchSessionPid = 0;
  47. var dispatchSessionToken = "";
  48. var dispatchSessionPath = "";
  49. var dispatchReady = false;
  50. var dispatchProcess = null;
  51. var dispatchTimerId = null;
  52. function projectRoot() {
  53. var path = decodeURIComponent(window.location.pathname);
  54. if (path.charAt(0) === "/" && path.charAt(2) === ":") path = path.substring(1);
  55. path = path.replace(/\//g, "\\");
  56. return new ActiveXObject("Scripting.FileSystemObject").GetParentFolderName(path);
  57. }
  58. function readSettings(filePath) {
  59. var stream = new ActiveXObject("ADODB.Stream");
  60. stream.Type = 2;
  61. stream.Charset = "utf-8";
  62. stream.Open();
  63. stream.LoadFromFile(filePath);
  64. var data = stream.ReadText();
  65. stream.Close();
  66. var result = {};
  67. var lines = data.replace(/\r\n?/g, "\n").split("\n");
  68. for (var i = 0; i < lines.length; i++) {
  69. var line = lines[i].replace(/^\s+|\s+$/g, "");
  70. if (!line || line.charAt(0) === "#" || line.charAt(0) === ";") continue;
  71. var equalAt = line.indexOf("=");
  72. if (equalAt > 0) result[line.substring(0, equalAt).replace(/\s/g, "").toUpperCase()] =
  73. line.substring(equalAt + 1).replace(/^\s+|\s+$/g, "");
  74. }
  75. return result;
  76. }
  77. function countRows(db, tableName) {
  78. var rows = db.Execute("SELECT COUNT(*) AS N FROM [" + tableName + "]");
  79. var result = rows.Fields("N").Value;
  80. rows.Close();
  81. return result;
  82. }
  83. function openDatabase() {
  84. var fs = new ActiveXObject("Scripting.FileSystemObject");
  85. var root = projectRoot();
  86. var config = readSettings(fs.BuildPath(root, "config.ini"));
  87. var dbPath = config.DB_PATH;
  88. if (fs.GetDriveName(dbPath) === "") dbPath = fs.BuildPath(root, dbPath);
  89. dbPath = fs.GetAbsolutePathName(dbPath);
  90. var db = new ActiveXObject("ADODB.Connection");
  91. db.Open("Provider=" + config.DB_PROVIDER + ";Data Source=" + dbPath + ";");
  92. return { db: db, config: config, path: dbPath };
  93. }
  94. function refreshStatus() {
  95. var db = null;
  96. try {
  97. var opened = openDatabase();
  98. db = opened.db;
  99. document.getElementById("contacts").innerText = countRows(db, "Contact");
  100. document.getElementById("suppressed").innerText = countRows(db, "Suppression");
  101. document.getElementById("templates").innerText = countRows(db, "TemplateVersion");
  102. document.getElementById("campaigns").innerText = countRows(db, "Campaign");
  103. document.getElementById("database").innerText = opened.path;
  104. document.getElementById("provider").innerText = opened.config.DB_PROVIDER;
  105. document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
  106. document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
  107. document.getElementById("error").innerText = "";
  108. } catch (error) {
  109. document.getElementById("error").innerText = "Status refresh failed: " + error.message;
  110. } finally {
  111. if (db !== null && db.State === 1) db.Close();
  112. }
  113. }
  114. function readCsvFile(path) {
  115. var fs = new ActiveXObject("Scripting.FileSystemObject");
  116. if (!fs.FileExists(path)) throw new Error("CSV file not found: " + path);
  117. if (fs.GetFile(path).Size > 2097152) throw new Error("CSV exceeds the 2 MB import limit");
  118. var stream = new ActiveXObject("ADODB.Stream");
  119. stream.Type = 2;
  120. stream.Charset = "utf-8";
  121. stream.Open();
  122. stream.LoadFromFile(path);
  123. var text = stream.ReadText();
  124. stream.Close();
  125. return text;
  126. }
  127. function emailLookup(db, tableName) {
  128. var found = {};
  129. var rows = db.Execute("SELECT [EmailKey] FROM [" + tableName + "]");
  130. while (!rows.EOF) {
  131. found[String(rows.Fields("EmailKey").Value).toLowerCase()] = true;
  132. rows.MoveNext();
  133. }
  134. rows.Close();
  135. return found;
  136. }
  137. function renderPreview(preview) {
  138. document.getElementById("previewCount").innerText = preview.accepted.length +
  139. " ready, " + preview.rejected.length + " rejected";
  140. var body = document.getElementById("previewBody");
  141. while (body.firstChild) body.removeChild(body.firstChild);
  142. var items = preview.accepted.concat(preview.rejected);
  143. items.sort(function (a, b) { return a.line - b.line; });
  144. for (var i = 0; i < items.length; i++) {
  145. var tr = document.createElement("tr");
  146. var values = [items[i].line, items[i].email, items[i].businessName,
  147. items[i].source, items[i].reason || "Ready"];
  148. for (var j = 0; j < values.length; j++) {
  149. var td = document.createElement("td");
  150. td.innerText = values[j];
  151. if (j === 4) td.className = items[i].reason ? "bad" : "good";
  152. tr.appendChild(td);
  153. }
  154. body.appendChild(tr);
  155. }
  156. document.getElementById("commitImport").disabled = preview.accepted.length === 0;
  157. }
  158. function previewContacts() {
  159. var db = null;
  160. document.getElementById("importMessage").innerText = "";
  161. document.getElementById("commitImport").disabled = true;
  162. try {
  163. var path = document.getElementById("csvPath").value;
  164. pendingPath = path;
  165. pendingSource = document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "");
  166. pendingCsvText = readCsvFile(path);
  167. var opened = openDatabase();
  168. db = opened.db;
  169. pendingPreview = MassMailerContacts.preview(pendingCsvText, pendingSource,
  170. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  171. renderPreview(pendingPreview);
  172. } catch (error) {
  173. pendingPreview = null;
  174. document.getElementById("importMessage").innerText = error.message;
  175. } finally {
  176. if (db !== null && db.State === 1) db.Close();
  177. }
  178. }
  179. function utcNowSql() {
  180. return "DateAdd('n'," + new Date().getTimezoneOffset() + ",Now())";
  181. }
  182. function importOne(db, row) {
  183. var dateSql = utcNowSql();
  184. if (row.acquiredAt) {
  185. dateSql = "DateSerial(" + Number(row.acquiredAt.substring(0, 4)) + "," +
  186. Number(row.acquiredAt.substring(5, 7)) + "," +
  187. Number(row.acquiredAt.substring(8, 10)) + ")";
  188. }
  189. var cmd = new ActiveXObject("ADODB.Command");
  190. cmd.ActiveConnection = db;
  191. cmd.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName]," +
  192. "[SourceReference],[AcquiredAtUtc],[Country],[EligibilityBasis],[CreatedAtUtc]) " +
  193. "VALUES (?,?,?,? ," + dateSql + ",'US','public_listing'," + utcNowSql() + ")";
  194. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, row.email));
  195. cmd.Parameters.Append(cmd.CreateParameter("key", 202, 1, 254, row.emailKey));
  196. cmd.Parameters.Append(cmd.CreateParameter("business", 202, 1, 200, row.businessName));
  197. cmd.Parameters.Append(cmd.CreateParameter("source", 203, 1, Math.max(1, row.source.length), row.source));
  198. cmd.Execute();
  199. }
  200. function commitContacts() {
  201. if (!pendingPreview || !pendingPreview.accepted.length) return;
  202. var db = null, inTransaction = false, succeeded = false;
  203. try {
  204. if (document.getElementById("csvPath").value !== pendingPath)
  205. throw new Error("CSV file selection changed. Preview it again.");
  206. if (document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "") !== pendingSource)
  207. throw new Error("Source reference changed. Preview the CSV again.");
  208. var opened = openDatabase();
  209. db = opened.db;
  210. var current = MassMailerContacts.preview(pendingCsvText, pendingSource,
  211. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  212. if (current.accepted.length !== pendingPreview.accepted.length ||
  213. current.rejected.length !== pendingPreview.rejected.length)
  214. throw new Error("Contacts or suppressions changed. Preview the CSV again.");
  215. for (var check = 0; check < current.accepted.length; check++) {
  216. if (current.accepted[check].emailKey !== pendingPreview.accepted[check].emailKey)
  217. throw new Error("Contacts changed. Preview the CSV again.");
  218. }
  219. db.BeginTrans();
  220. inTransaction = true;
  221. for (var i = 0; i < current.accepted.length; i++) importOne(db, current.accepted[i]);
  222. db.CommitTrans();
  223. inTransaction = false;
  224. document.getElementById("importMessage").innerText =
  225. "Imported " + current.accepted.length + " contacts. Rejected rows were not saved.";
  226. pendingPreview = null;
  227. document.getElementById("commitImport").disabled = true;
  228. succeeded = true;
  229. } catch (error) {
  230. if (inTransaction) db.RollbackTrans();
  231. document.getElementById("importMessage").innerText = "Import failed: " + error.message;
  232. } finally {
  233. if (db !== null && db.State === 1) db.Close();
  234. if (succeeded) refreshStatus();
  235. }
  236. }
  237. function addSuppression() {
  238. var db = null, succeeded = false;
  239. try {
  240. var email = document.getElementById("suppressEmail").value.replace(/^\s+|\s+$/g, "");
  241. if (!MassMailerContacts.isEmail(email)) throw new Error("Enter a valid email address");
  242. var key = MassMailerContacts.emailKey(email);
  243. var reason = document.getElementById("suppressReason").value;
  244. var evidence = document.getElementById("suppressEvidence").value.replace(/^\s+|\s+$/g, "");
  245. var opened = openDatabase();
  246. db = opened.db;
  247. if (emailLookup(db, "Suppression")[key]) throw new Error("Address is already suppressed");
  248. var cmd = new ActiveXObject("ADODB.Command");
  249. cmd.ActiveConnection = db;
  250. cmd.CommandText = "INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]," +
  251. "[EvidenceReference]) VALUES (?, ?, " + utcNowSql() + ", ?)";
  252. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, key));
  253. cmd.Parameters.Append(cmd.CreateParameter("reason", 202, 1, 40, reason));
  254. cmd.Parameters.Append(cmd.CreateParameter("evidence", 203, 1, Math.max(1, evidence.length), evidence));
  255. cmd.Execute();
  256. document.getElementById("suppressMessage").innerText = "Suppressed " + key;
  257. document.getElementById("suppressEmail").value = "";
  258. succeeded = true;
  259. } catch (error) {
  260. document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
  261. } finally {
  262. if (db !== null && db.State === 1) db.Close();
  263. if (succeeded) refreshStatus();
  264. }
  265. }
  266. function powerShellHost() {
  267. var fs = new ActiveXObject("Scripting.FileSystemObject");
  268. var shell = new ActiveXObject("WScript.Shell");
  269. var root = shell.ExpandEnvironmentStrings("%SystemRoot%");
  270. var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
  271. var folder = (settings.DB_BITNESS || "").toLowerCase() === "x64" ? "System32" :
  272. (fs.FolderExists(root + "\\SysWOW64") ? "SysWOW64" : "System32");
  273. return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe";
  274. }
  275. function quoted(value) { return '"' + value + '"'; }
  276. function configurePassword() {
  277. try {
  278. var fs = new ActiveXObject("Scripting.FileSystemObject");
  279. var script = fs.BuildPath(projectRoot(), "tools\\set-smtp-password.ps1");
  280. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + quoted(script);
  281. var status = new ActiveXObject("WScript.Shell").Run(command, 1, true);
  282. document.getElementById("transportMessage").innerText = status === 0 ?
  283. "SMTP password setup completed." : "SMTP password setup failed. Review the console error.";
  284. } catch (error) {
  285. document.getElementById("transportMessage").innerText = "Setup failed: " + error.message;
  286. }
  287. }
  288. function sendInternalTest() {
  289. try {
  290. var fs = new ActiveXObject("Scripting.FileSystemObject");
  291. var root = projectRoot();
  292. var settings = readSettings(fs.BuildPath(root, "config.ini"));
  293. if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
  294. if (!window.confirm("Send one internal SMTP test to " + settings.SMTP_TEST_TO + "?")) return;
  295. var script = fs.BuildPath(root, "tools\\test-smtp-message.ps1");
  296. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  297. quoted(script) + " -Send";
  298. var process = new ActiveXObject("WScript.Shell").Exec(command);
  299. var output = process.StdOut.ReadAll();
  300. var errors = process.StdErr.ReadAll();
  301. document.getElementById("transportMessage").innerText =
  302. (process.ExitCode === 0 ? "" : "Test failed. ") + output + errors;
  303. } catch (error) {
  304. document.getElementById("transportMessage").innerText = "Test failed: " + error.message;
  305. }
  306. }
  307. function refreshTemplates() {
  308. var db = null;
  309. try {
  310. db = openDatabase().db;
  311. var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC");
  312. var select = document.getElementById("templateVersion");
  313. var campaignSelect = document.getElementById("campaignTemplateVersion");
  314. while (select.options.length) select.remove(0);
  315. while (campaignSelect.options.length) campaignSelect.remove(0);
  316. while (!rows.EOF) {
  317. var value = String(rows.Fields("Id").Value);
  318. var label = String(rows.Fields("TemplateName").Value) + " — version " +
  319. String(rows.Fields("VersionNumber").Value);
  320. var option = document.createElement("option");
  321. option.value = value;
  322. option.text = label;
  323. select.add(option);
  324. var campaignOption = document.createElement("option");
  325. campaignOption.value = value;
  326. campaignOption.text = label;
  327. campaignSelect.add(campaignOption);
  328. rows.MoveNext();
  329. }
  330. rows.Close();
  331. invalidateCampaignReview();
  332. } catch (error) {
  333. document.getElementById("templateMessage").innerText = "Template list failed: " + error.message;
  334. } finally {
  335. if (db !== null && db.State === 1) db.Close();
  336. }
  337. }
  338. function runTemplateTool(scriptName, args) {
  339. var fs = new ActiveXObject("Scripting.FileSystemObject");
  340. var script = fs.BuildPath(projectRoot(), "tools\\" + scriptName);
  341. var configPath = fs.BuildPath(projectRoot(), "config.ini");
  342. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  343. quoted(script) + " -ConfigPath " + quoted(configPath) + " " + args;
  344. var process = new ActiveXObject("WScript.Shell").Exec(command);
  345. var output = process.StdOut.ReadAll();
  346. var errors = process.StdErr.ReadAll();
  347. if (process.ExitCode !== 0) throw new Error((output + errors).replace(/^\s+|\s+$/g, ""));
  348. return output.replace(/^\s+|\s+$/g, "");
  349. }
  350. function importTemplate() {
  351. try {
  352. var path = document.getElementById("templatePath").value;
  353. if (!path || path.indexOf('"') !== -1) throw new Error("Select a template file");
  354. var result = runTemplateTool("import-template.ps1", "-Path " + quoted(path));
  355. document.getElementById("templateMessage").innerText = result;
  356. refreshTemplates();
  357. refreshStatus();
  358. } catch (error) {
  359. document.getElementById("templateMessage").innerText = "Import failed: " + error.message;
  360. }
  361. }
  362. function previewTemplate() {
  363. try {
  364. var id = document.getElementById("templateVersion").value;
  365. if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an imported template");
  366. var file = runTemplateTool("preview-template.ps1", "-TemplateId " + id);
  367. document.getElementById("templatePreview").src = file;
  368. document.getElementById("templateMessage").innerText =
  369. "Showing the imported snapshot. This preview does not include the campaign footer yet.";
  370. } catch (error) {
  371. document.getElementById("templateMessage").innerText = "Preview failed: " + error.message;
  372. }
  373. }
  374. function refreshCampaigns() {
  375. var db = null;
  376. try {
  377. db = openDatabase().db;
  378. var rows = db.Execute("SELECT [Id],[Name],[Status] FROM [Campaign] ORDER BY [Id] DESC");
  379. var body = document.getElementById("campaignBody");
  380. while (body.firstChild) body.removeChild(body.firstChild);
  381. var select = document.getElementById("dispatchCampaign");
  382. var reportSelect = document.getElementById("reportCampaign");
  383. var selectedCampaign = select.value;
  384. var selectedReport = reportSelect.value;
  385. while (select.options.length) select.remove(0);
  386. while (reportSelect.options.length) reportSelect.remove(0);
  387. while (!rows.EOF) {
  388. var tr = document.createElement("tr");
  389. var values = [rows.Fields("Id").Value, rows.Fields("Name").Value,
  390. rows.Fields("Status").Value];
  391. for (var i = 0; i < values.length; i++) {
  392. var td = document.createElement("td");
  393. td.innerText = String(values[i]);
  394. tr.appendChild(td);
  395. }
  396. body.appendChild(tr);
  397. var reportOption = document.createElement("option");
  398. reportOption.value = String(values[0]);
  399. reportOption.text = "#" + values[0] + " " + values[1];
  400. reportSelect.add(reportOption);
  401. var status = String(values[2]);
  402. if (status === "approved_paused" || status === "running") {
  403. var option = document.createElement("option");
  404. option.value = String(values[0]);
  405. option.text = "#" + values[0] + " " + values[1] + " (" + status + ")";
  406. select.add(option);
  407. }
  408. rows.MoveNext();
  409. }
  410. rows.Close();
  411. for (var d = 0; d < select.options.length; d++) {
  412. if (select.options[d].value === selectedCampaign) select.selectedIndex = d;
  413. }
  414. for (var r = 0; r < reportSelect.options.length; r++) {
  415. if (reportSelect.options[r].value === selectedReport) reportSelect.selectedIndex = r;
  416. }
  417. refreshUncertain();
  418. } catch (error) {
  419. document.getElementById("campaignMessage").innerText = "Campaign list failed: " + error.message;
  420. } finally {
  421. if (db !== null && db.State === 1) db.Close();
  422. }
  423. }
  424. function invalidateCampaignReview() {
  425. pendingCampaignReview = null;
  426. campaignTestAccepted = false;
  427. document.getElementById("sendCampaignTest").disabled = true;
  428. document.getElementById("approveCampaign").disabled = true;
  429. document.getElementById("confirmReceived").checked = false;
  430. document.getElementById("confirmReceived").disabled = true;
  431. document.getElementById("campaignReview").innerText = "";
  432. document.getElementById("campaignPreview").src = "about:blank";
  433. }
  434. function campaignRequest() {
  435. return {
  436. Name: document.getElementById("campaignName").value,
  437. Subject: document.getElementById("campaignSubject").value,
  438. TemplateId: document.getElementById("campaignTemplateVersion").value,
  439. ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "",
  440. TestReceived: document.getElementById("confirmReceived").checked
  441. };
  442. }
  443. function withCampaignRequest(scriptName, extraArgs) {
  444. var fs = new ActiveXObject("Scripting.FileSystemObject");
  445. var folder = fs.BuildPath(projectRoot(), "temp");
  446. if (!fs.FolderExists(folder)) fs.CreateFolder(folder);
  447. var path = fs.BuildPath(folder, fs.GetTempName());
  448. var stream = new ActiveXObject("ADODB.Stream");
  449. try {
  450. stream.Type = 2;
  451. stream.Charset = "utf-8";
  452. stream.Open();
  453. stream.WriteText(JSON.stringify(campaignRequest()));
  454. stream.SaveToFile(path, 2);
  455. stream.Close();
  456. return runTemplateTool(scriptName, "-RequestPath " + quoted(path) + (extraArgs || ""));
  457. } finally {
  458. if (stream.State === 1) stream.Close();
  459. if (fs.FileExists(path)) fs.DeleteFile(path);
  460. }
  461. }
  462. function reviewCampaign() {
  463. invalidateCampaignReview();
  464. try {
  465. var data = JSON.parse(withCampaignRequest("review-campaign.ps1", ""));
  466. pendingCampaignReview = data;
  467. document.getElementById("campaignReview").innerText =
  468. data.Count + " eligible recipients. From: " + data.From + ". Reply-to: " +
  469. data.ReplyTo + ". Postal address: " + data.PostalAddress +
  470. ". Template: " + data.TemplateName + " v" + data.TemplateVersion +
  471. ". Subject: " + data.Subject + ".";
  472. document.getElementById("campaignPreview").src = data.PreviewPath;
  473. document.getElementById("sendCampaignTest").disabled = false;
  474. document.getElementById("campaignMessage").innerText =
  475. "Review the full message, then send it to the internal test inbox.";
  476. } catch (error) {
  477. document.getElementById("campaignMessage").innerText = "Review failed: " + error.message;
  478. }
  479. }
  480. function sendReviewedCampaignTest() {
  481. if (!pendingCampaignReview) return;
  482. if (!window.confirm("Send this campaign test to " + pendingCampaignReview.TestTo + "?")) return;
  483. try {
  484. var result = withCampaignRequest("send-campaign-test.ps1", " -Send");
  485. campaignTestAccepted = true;
  486. document.getElementById("confirmReceived").disabled = false;
  487. document.getElementById("campaignMessage").innerText = result;
  488. } catch (error) {
  489. document.getElementById("campaignMessage").innerText = "Campaign test failed: " + error.message;
  490. }
  491. }
  492. function updateApprovalButton() {
  493. document.getElementById("approveCampaign").disabled =
  494. !pendingCampaignReview || !campaignTestAccepted ||
  495. !document.getElementById("confirmReceived").checked;
  496. }
  497. function approveReviewedCampaign() {
  498. if (!pendingCampaignReview || !campaignTestAccepted ||
  499. !document.getElementById("confirmReceived").checked) return;
  500. if (!window.confirm("Approve " + pendingCampaignReview.Count +
  501. " recipients? The campaign will remain paused.")) return;
  502. try {
  503. var result = withCampaignRequest("approve-campaign.ps1", "");
  504. document.getElementById("campaignMessage").innerText = result;
  505. invalidateCampaignReview();
  506. refreshStatus();
  507. refreshCampaigns();
  508. } catch (error) {
  509. document.getElementById("campaignMessage").innerText = "Approval failed: " + error.message;
  510. }
  511. }
  512. function htaProcessId() {
  513. var service = new ActiveXObject("WbemScripting.SWbemLocator")
  514. .ConnectServer(".", "root\\cimv2");
  515. var rows = new Enumerator(service.ExecQuery(
  516. "SELECT ProcessId,CommandLine FROM Win32_Process WHERE Name='mshta.exe'"));
  517. var path = projectRoot().toLowerCase();
  518. for (; !rows.atEnd(); rows.moveNext()) {
  519. var row = rows.item();
  520. var command = String(row.CommandLine).toLowerCase();
  521. if (command.indexOf("massmailer.hta") !== -1 && command.indexOf(path) !== -1)
  522. return Number(row.ProcessId);
  523. }
  524. throw new Error("Could not identify this HTA process for the open-window send guard");
  525. }
  526. function writeHeartbeat() {
  527. if (!dispatchReady) return;
  528. var fs = new ActiveXObject("Scripting.FileSystemObject");
  529. var file = fs.CreateTextFile(dispatchSessionPath, true);
  530. file.Write(dispatchSessionToken);
  531. file.Close();
  532. }
  533. function startup() {
  534. try {
  535. showTab("overview");
  536. refreshStatus();
  537. refreshTemplates();
  538. refreshCampaigns();
  539. initializeDispatch();
  540. } catch (error) {
  541. document.getElementById("dispatchMessage").innerText =
  542. "Startup failed: " + error.message;
  543. }
  544. }
  545. function showTab(name) {
  546. var panes = document.querySelectorAll(".tab-pane");
  547. var buttons = document.querySelectorAll(".tab-button");
  548. for (var i = 0; i < panes.length; i++) {
  549. var paneActive = panes[i].getAttribute("data-tab") === name;
  550. var paneBase = panes[i].getAttribute("data-tab") === "overview" ? "" : "panel ";
  551. panes[i].className = paneBase + (paneActive ? "tab-pane active" : "tab-pane");
  552. panes[i].setAttribute("aria-hidden", paneActive ? "false" : "true");
  553. }
  554. for (var j = 0; j < buttons.length; j++) {
  555. var buttonActive = buttons[j].getAttribute("data-tab") === name;
  556. buttons[j].className = buttonActive ? "tab-button active" : "tab-button";
  557. buttons[j].setAttribute("aria-selected", buttonActive ? "true" : "false");
  558. }
  559. window.scrollTo(0, 0);
  560. }
  561. function initializeDispatch() {
  562. if (dispatchReady) return;
  563. try {
  564. var fs = new ActiveXObject("Scripting.FileSystemObject");
  565. var folder = fs.BuildPath(projectRoot(), "temp");
  566. if (!fs.FolderExists(folder)) fs.CreateFolder(folder);
  567. document.getElementById("dispatchMessage").innerText =
  568. runTemplateTool("dispatch-control.ps1", "-Action Recover");
  569. dispatchSessionPid = htaProcessId();
  570. dispatchSessionToken = "";
  571. for (var i = 0; i < 32; i++)
  572. dispatchSessionToken += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
  573. dispatchSessionPath = fs.BuildPath(folder, "session-" + dispatchSessionPid + ".txt");
  574. dispatchReady = true;
  575. writeHeartbeat();
  576. if (dispatchTimerId === null)
  577. dispatchTimerId = window.setInterval(dispatchTick, 5000);
  578. document.getElementById("retryDispatch").disabled = true;
  579. document.getElementById("reviewOptOut").disabled = false;
  580. document.getElementById("resumeCampaign").disabled = false;
  581. document.getElementById("pauseAll").disabled = false;
  582. document.getElementById("cancelCampaign").disabled = false;
  583. refreshCampaigns();
  584. refreshDispatchStatus();
  585. } catch (error) {
  586. dispatchReady = false;
  587. document.getElementById("dispatchMessage").innerText =
  588. "Dispatcher did not initialize: " + error.message;
  589. }
  590. }
  591. function shutdownDispatch() {
  592. dispatchReady = false;
  593. try {
  594. if (dispatchTimerId !== null) window.clearInterval(dispatchTimerId);
  595. var fs = new ActiveXObject("Scripting.FileSystemObject");
  596. if (dispatchSessionPath && fs.FileExists(dispatchSessionPath))
  597. fs.DeleteFile(dispatchSessionPath);
  598. runTemplateTool("dispatch-control.ps1", "-Action PauseAll");
  599. } catch (ignored) { }
  600. }
  601. function refreshDispatchStatus() {
  602. if (!dispatchReady || (dispatchProcess && dispatchProcess.Status === 0)) return;
  603. try {
  604. var status = JSON.parse(runTemplateTool("dispatch-control.ps1", "-Action Status"));
  605. document.getElementById("sendState").innerText = status.Paused ?
  606. "Paused" : "Running " + status.RunningCampaigns + " campaign(s)";
  607. document.getElementById("dispatchSummary").innerText =
  608. "Pending " + status.Counts.pending + ", accepted " + status.Counts.accepted +
  609. ", uncertain " + status.Counts.uncertain + ", suppressed " +
  610. status.Counts.suppressed + ". Next allowed attempt (UTC): " +
  611. (status.NextAllowedAttemptUtc || "Now") + ".";
  612. } catch (error) {
  613. document.getElementById("dispatchMessage").innerText =
  614. "Dispatch status failed: " + error.message;
  615. }
  616. }
  617. function dispatchTick() {
  618. if (!dispatchReady) return;
  619. try {
  620. writeHeartbeat();
  621. if (dispatchProcess) {
  622. if (dispatchProcess.Status === 0) return;
  623. var output = dispatchProcess.StdOut.ReadAll();
  624. var errors = dispatchProcess.StdErr.ReadAll();
  625. var exitCode = dispatchProcess.ExitCode;
  626. dispatchProcess = null;
  627. document.getElementById("dispatchMessage").innerText =
  628. (exitCode === 0 ? "" : "Dispatch error: ") + output + errors;
  629. refreshStatus();
  630. refreshCampaigns();
  631. refreshDispatchStatus();
  632. return;
  633. }
  634. var db = openDatabase().db;
  635. var running = 0;
  636. try {
  637. var rows = db.Execute("SELECT COUNT(*) AS N FROM [Campaign] WHERE [Status]='running'");
  638. running = Number(rows.Fields("N").Value);
  639. rows.Close();
  640. } finally { db.Close(); }
  641. if (!running) return;
  642. var fs = new ActiveXObject("Scripting.FileSystemObject");
  643. var script = fs.BuildPath(projectRoot(), "tools\\dispatch-once.ps1");
  644. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  645. quoted(script) + " -ConfigPath " + quoted(fs.BuildPath(projectRoot(), "config.ini")) +
  646. " -Send -SessionPid " + dispatchSessionPid +
  647. " -SessionToken " + dispatchSessionToken;
  648. dispatchProcess = new ActiveXObject("WScript.Shell").Exec(command);
  649. } catch (error) {
  650. document.getElementById("dispatchMessage").innerText =
  651. "Dispatch tick failed: " + error.message;
  652. }
  653. }
  654. function dispatchControl(action, campaignId, deliveryId) {
  655. try {
  656. var args = "-Action " + action;
  657. if (campaignId) args += " -CampaignId " + campaignId;
  658. if (deliveryId) args += " -DeliveryId " + deliveryId;
  659. var result = runTemplateTool("dispatch-control.ps1", args);
  660. document.getElementById("dispatchMessage").innerText = result;
  661. refreshCampaigns();
  662. refreshUncertain();
  663. refreshDispatchStatus();
  664. } catch (error) {
  665. document.getElementById("dispatchMessage").innerText =
  666. "Control failed: " + error.message;
  667. }
  668. }
  669. function reviewOptOutMailbox() {
  670. if (window.confirm("Have you checked the monitored reply mailbox and recorded all opt-outs?"))
  671. dispatchControl("ReviewOptOut", 0);
  672. }
  673. function selectedDispatchCampaign() {
  674. var id = document.getElementById("dispatchCampaign").value;
  675. if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an approved campaign");
  676. return id;
  677. }
  678. function resumeSelectedCampaign() {
  679. try {
  680. var id = selectedDispatchCampaign();
  681. if (window.confirm("Start sending campaign #" + id +
  682. " to approved business contacts while this HTA stays open?"))
  683. dispatchControl("Resume", id);
  684. } catch (error) { document.getElementById("dispatchMessage").innerText = error.message; }
  685. }
  686. function testApprovedCampaign() {
  687. try {
  688. var id = selectedDispatchCampaign();
  689. var fs = new ActiveXObject("Scripting.FileSystemObject");
  690. var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
  691. if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
  692. if (!window.confirm("Send a test of approved campaign #" + id +
  693. " to " + settings.SMTP_TEST_TO + " only?")) return;
  694. document.getElementById("dispatchMessage").innerText =
  695. runTemplateTool("send-approved-campaign-test.ps1", "-CampaignId " + id + " -Send");
  696. } catch (error) {
  697. document.getElementById("dispatchMessage").innerText =
  698. "Approved campaign test failed: " + error.message;
  699. }
  700. }
  701. function cancelSelectedCampaign() {
  702. try {
  703. var id = selectedDispatchCampaign();
  704. if (window.confirm("Cancel all pending deliveries in campaign #" + id + "?"))
  705. dispatchControl("Cancel", id);
  706. } catch (error) { document.getElementById("dispatchMessage").innerText = error.message; }
  707. }
  708. function refreshUncertain() {
  709. var db = null;
  710. try {
  711. db = openDatabase().db;
  712. var rows = db.Execute("SELECT [Id],[CampaignId],[EmailKey],[AttemptCount] FROM [Delivery] WHERE [Status]='uncertain' ORDER BY [Id]");
  713. var select = document.getElementById("uncertainDelivery");
  714. var selectedDelivery = select.value;
  715. while (select.options.length) select.remove(0);
  716. while (!rows.EOF) {
  717. var option = document.createElement("option");
  718. option.value = String(rows.Fields("Id").Value);
  719. option.text = "Delivery #" + option.value + " / campaign #" +
  720. rows.Fields("CampaignId").Value + " / " + rows.Fields("EmailKey").Value +
  721. " / attempts " + rows.Fields("AttemptCount").Value;
  722. select.add(option);
  723. rows.MoveNext();
  724. }
  725. rows.Close();
  726. for (var i = 0; i < select.options.length; i++) {
  727. if (select.options[i].value === selectedDelivery) select.selectedIndex = i;
  728. }
  729. } catch (error) {
  730. document.getElementById("dispatchMessage").innerText =
  731. "Uncertain list failed: " + error.message;
  732. } finally {
  733. if (db !== null && db.State === 1) db.Close();
  734. }
  735. }
  736. function reconcileDelivery(action) {
  737. var id = document.getElementById("uncertainDelivery").value;
  738. if (!/^[1-9][0-9]*$/.test(id)) {
  739. document.getElementById("dispatchMessage").innerText = "Select an uncertain delivery.";
  740. return;
  741. }
  742. var prompt = action === "ResolveAccepted" ?
  743. "Have you verified delivery #" + id + " was accepted by the provider?" :
  744. "Have you checked that delivery #" + id + " was not sent and authorize one retry?";
  745. if (window.confirm(prompt)) dispatchControl(action, 0, id);
  746. }
  747. function exportSelectedReport() {
  748. try {
  749. var id = document.getElementById("reportCampaign").value;
  750. if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select a campaign to report");
  751. document.getElementById("reportMessage").innerText =
  752. runTemplateTool("export-report.ps1", "-CampaignId " + id);
  753. } catch (error) {
  754. document.getElementById("reportMessage").innerText =
  755. "Report export failed: " + error.message;
  756. }
  757. }
  758. </script>
  759. </head>
  760. <body onload="startup()" onunload="shutdownDispatch()">
  761. <header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
  762. <main>
  763. <div class="tabbar" role="tablist" aria-label="Application sections">
  764. <button type="button" class="tab-button" data-tab="overview" role="tab" onclick="showTab('overview')">Overview</button>
  765. <button type="button" class="tab-button" data-tab="smtp" role="tab" onclick="showTab('smtp')">SMTP</button>
  766. <button type="button" class="tab-button" data-tab="contacts" role="tab" onclick="showTab('contacts')">Contacts</button>
  767. <button type="button" class="tab-button" data-tab="templates" role="tab" onclick="showTab('templates')">Templates</button>
  768. <button type="button" class="tab-button" data-tab="campaigns" role="tab" onclick="showTab('campaigns')">Campaigns</button>
  769. <button type="button" class="tab-button" data-tab="sending" role="tab" onclick="showTab('sending')">Sending</button>
  770. <button type="button" class="tab-button" data-tab="reports" role="tab" onclick="showTab('reports')">Reports</button>
  771. </div>
  772. <div class="tab-pane" data-tab="overview" role="tabpanel">
  773. <div class="notice"><strong>Campaigns start only when you explicitly resume them.</strong> Sending stops when this window closes. Review replies and record opt-outs before resuming.</div>
  774. <div class="cards">
  775. <div class="card">Contacts<span class="number" id="contacts">—</span></div>
  776. <div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
  777. <div class="card">Templates<span class="number" id="templates">—</span></div>
  778. <div class="card">Campaigns<span class="number" id="campaigns">—</span></div>
  779. </div>
  780. <div class="details">
  781. <div><strong>Database</strong><span id="database">Checking…</span></div>
  782. <div><strong>Provider</strong><span id="provider">Checking…</span></div>
  783. <div><strong>Send interval</strong><span id="interval">Checking…</span></div>
  784. <div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
  785. <div><strong>Send state</strong><span id="sendState">Initializing…</span></div>
  786. <button onclick="refreshStatus()">Refresh status</button>
  787. <div id="error"></div>
  788. </div>
  789. </div>
  790. <div class="panel tab-pane" data-tab="smtp" role="tabpanel">
  791. <h2>SMTP setup and internal test</h2>
  792. <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>
  793. <button onclick="configurePassword()">Set or rotate SMTP password</button>
  794. <button onclick="sendInternalTest()">Send one internal test</button>
  795. <div id="transportMessage"></div>
  796. </div>
  797. <div class="panel tab-pane" data-tab="contacts" role="tabpanel">
  798. <h2>Import business contacts</h2>
  799. <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>
  800. <label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
  801. <label class="field">Source URL or reference for rows without one
  802. <input id="sourceFallback" type="text" maxlength="2048" /></label>
  803. <button onclick="previewContacts()">Preview import</button>
  804. <button id="commitImport" onclick="commitContacts()" disabled="disabled">Import ready contacts</button>
  805. <div id="importMessage"></div>
  806. <p id="previewCount"></p>
  807. <table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
  808. <tbody id="previewBody"></tbody></table>
  809. </div>
  810. <div class="panel tab-pane" data-tab="templates" role="tabpanel">
  811. <h2>HTML templates</h2>
  812. <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>
  813. <label class="field">Template file <input id="templatePath" type="file" accept=".html,.htm" /></label>
  814. <button onclick="importTemplate()">Import template version</button>
  815. <label class="field">Imported version <select id="templateVersion"></select></label>
  816. <button onclick="previewTemplate()">Preview selected version</button>
  817. <div id="templateMessage"></div>
  818. <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe>
  819. </div>
  820. <div class="panel tab-pane" data-tab="campaigns" role="tabpanel">
  821. <h2>Review and approve a campaign</h2>
  822. <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 you resume them.</p>
  823. <label class="field">Campaign name <input id="campaignName" type="text" maxlength="200" oninput="invalidateCampaignReview()" /></label>
  824. <label class="field">Email subject <input id="campaignSubject" type="text" maxlength="240" oninput="invalidateCampaignReview()" /></label>
  825. <label class="field">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label>
  826. <button onclick="reviewCampaign()">Review message and recipients</button>
  827. <div id="campaignReview"></div>
  828. <iframe id="campaignPreview" APPLICATION="no" title="Full campaign preview"></iframe>
  829. <button id="sendCampaignTest" onclick="sendReviewedCampaignTest()" disabled="disabled">Send reviewed message to internal test inbox</button>
  830. <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>
  831. <button id="approveCampaign" onclick="approveReviewedCampaign()" disabled="disabled">Approve campaign (paused)</button>
  832. <div id="campaignMessage"></div>
  833. <table><thead><tr><th>ID</th><th>Campaign</th><th>Status</th></tr></thead><tbody id="campaignBody"></tbody></table>
  834. </div>
  835. <div class="panel tab-pane" data-tab="sending" role="tabpanel">
  836. <h2>Open-window sending</h2>
  837. <p>Check the reply mailbox daily. A send starts only while this HTA is open, after you explicitly resume an approved campaign. The configured interval applies across campaigns. Uncertain results pause sending for review.</p>
  838. <button id="reviewOptOut" onclick="reviewOptOutMailbox()" disabled="disabled">I reviewed replies and recorded opt-outs</button>
  839. <label class="field">Approved campaign <select id="dispatchCampaign"></select></label>
  840. <button onclick="testApprovedCampaign()">Send selected campaign test to internal inbox</button>
  841. <button id="resumeCampaign" onclick="resumeSelectedCampaign()" disabled="disabled">Resume selected campaign</button>
  842. <button id="pauseAll" onclick="dispatchControl('PauseAll',0)" disabled="disabled">Pause all</button>
  843. <button id="cancelCampaign" onclick="cancelSelectedCampaign()" disabled="disabled">Cancel selected campaign</button>
  844. <div id="dispatchSummary"></div>
  845. <div id="dispatchMessage"></div>
  846. <button id="retryDispatch" onclick="initializeDispatch()">Retry dispatcher initialization</button>
  847. <label class="field">Uncertain delivery <select id="uncertainDelivery"></select></label>
  848. <button onclick="reconcileDelivery('ResolveAccepted')">Mark verified accepted</button>
  849. <button onclick="reconcileDelivery('AuthorizeRetry')">Authorize one retry</button>
  850. </div>
  851. <div class="panel tab-pane" data-tab="contacts" role="tabpanel">
  852. <h2>Suppress an address</h2>
  853. <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
  854. <label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
  855. <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>
  856. <label class="field">Evidence or note <input id="suppressEvidence" type="text" maxlength="2048" /></label>
  857. <button onclick="addSuppression()">Suppress address</button>
  858. <div id="suppressMessage"></div>
  859. </div>
  860. <div class="panel tab-pane" data-tab="reports" role="tabpanel">
  861. <h2>Campaign reports</h2>
  862. <p>Export delivery statuses and attempt history to CSV files in the configured report folder. SMTP acceptance means the server accepted a message; it does not confirm inbox delivery.</p>
  863. <label class="field">Campaign <select id="reportCampaign"></select></label>
  864. <button onclick="exportSelectedReport()">Export campaign CSV reports</button>
  865. <div id="reportMessage"></div>
  866. </div>
  867. </main>
  868. </body>
  869. </html>

Powered by TurnKey Linux.