Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

1010 linhas
51KB

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

Powered by TurnKey Linux.