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.

415 linhas
21KB

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta http-equiv="X-UA-Compatible" content="IE=9" />
  5. <title>MassMailer</title>
  6. <HTA:APPLICATION ID="MassMailer" APPLICATIONNAME="MassMailer" BORDER="thick" SCROLL="yes" SINGLEINSTANCE="yes" />
  7. <style type="text/css">
  8. body { margin: 0; font: 15px Segoe UI, Arial, sans-serif; color: #243145; background: #f4f6f8; }
  9. header { background: #17324e; color: #fff; padding: 22px 28px; }
  10. h1 { margin: 0; font-size: 24px; }
  11. header p { margin: 7px 0 0; color: #d2dfec; }
  12. main { padding: 24px 28px; }
  13. .notice { padding: 14px 17px; background: #fff7e3; border-left: 4px solid #ca8f13; margin-bottom: 22px; }
  14. .cards { display: table; width: 100%; border-spacing: 10px; margin-left: -10px; }
  15. .card { display: table-cell; width: 25%; background: white; padding: 18px; border: 1px solid #dbe1e7; }
  16. .number { display: block; margin-top: 8px; font-size: 28px; color: #17324e; }
  17. .details { background: white; padding: 18px; border: 1px solid #dbe1e7; line-height: 1.7; }
  18. .details strong { display: inline-block; width: 135px; }
  19. button { background: #176b9b; color: white; border: 0; padding: 9px 16px; cursor: pointer; margin-top: 17px; }
  20. button:disabled { background: #a7b3bd; cursor: default; }
  21. .panel { background: white; padding: 18px; border: 1px solid #dbe1e7; margin-top: 18px; }
  22. .panel h2 { margin: 0 0 12px; font-size: 19px; }
  23. .field { display: block; margin: 12px 0; }
  24. .field input, .field select { display: block; width: 95%; max-width: 640px; padding: 7px; margin-top: 4px; }
  25. table { border-collapse: collapse; width: 100%; margin-top: 15px; }
  26. th, td { border-bottom: 1px solid #e4e8ec; padding: 7px; text-align: left; }
  27. .bad { color: #9c2929; }
  28. .good { color: #235e37; }
  29. #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; }
  30. #templatePreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; }
  31. </style>
  32. <script language="JScript" src="src/contacts.js"></script>
  33. <script language="JScript" type="text/javascript">
  34. var pendingCsvText = null;
  35. var pendingPreview = null;
  36. var pendingSource = "";
  37. var pendingPath = "";
  38. function projectRoot() {
  39. var path = decodeURIComponent(window.location.pathname);
  40. if (path.charAt(0) === "/" && path.charAt(2) === ":") path = path.substring(1);
  41. path = path.replace(/\//g, "\\");
  42. return new ActiveXObject("Scripting.FileSystemObject").GetParentFolderName(path);
  43. }
  44. function readSettings(filePath) {
  45. var stream = new ActiveXObject("ADODB.Stream");
  46. stream.Type = 2;
  47. stream.Charset = "utf-8";
  48. stream.Open();
  49. stream.LoadFromFile(filePath);
  50. var data = stream.ReadText();
  51. stream.Close();
  52. var result = {};
  53. var lines = data.replace(/\r\n?/g, "\n").split("\n");
  54. for (var i = 0; i < lines.length; i++) {
  55. var line = lines[i].replace(/^\s+|\s+$/g, "");
  56. if (!line || line.charAt(0) === "#" || line.charAt(0) === ";") continue;
  57. var equalAt = line.indexOf("=");
  58. if (equalAt > 0) result[line.substring(0, equalAt).replace(/\s/g, "").toUpperCase()] =
  59. line.substring(equalAt + 1).replace(/^\s+|\s+$/g, "");
  60. }
  61. return result;
  62. }
  63. function countRows(db, tableName) {
  64. var rows = db.Execute("SELECT COUNT(*) AS N FROM [" + tableName + "]");
  65. var result = rows.Fields("N").Value;
  66. rows.Close();
  67. return result;
  68. }
  69. function openDatabase() {
  70. var fs = new ActiveXObject("Scripting.FileSystemObject");
  71. var root = projectRoot();
  72. var config = readSettings(fs.BuildPath(root, "config.ini"));
  73. var dbPath = config.DB_PATH;
  74. if (fs.GetDriveName(dbPath) === "") dbPath = fs.BuildPath(root, dbPath);
  75. dbPath = fs.GetAbsolutePathName(dbPath);
  76. var db = new ActiveXObject("ADODB.Connection");
  77. db.Open("Provider=" + config.DB_PROVIDER + ";Data Source=" + dbPath + ";");
  78. return { db: db, config: config, path: dbPath };
  79. }
  80. function refreshStatus() {
  81. var db = null;
  82. try {
  83. var opened = openDatabase();
  84. db = opened.db;
  85. document.getElementById("contacts").innerText = countRows(db, "Contact");
  86. document.getElementById("suppressed").innerText = countRows(db, "Suppression");
  87. document.getElementById("templates").innerText = countRows(db, "TemplateVersion");
  88. document.getElementById("campaigns").innerText = countRows(db, "Campaign");
  89. document.getElementById("database").innerText = opened.path;
  90. document.getElementById("provider").innerText = opened.config.DB_PROVIDER;
  91. document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
  92. document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
  93. document.getElementById("error").innerText = "";
  94. refreshTemplates();
  95. } catch (error) {
  96. document.getElementById("error").innerText = "Status refresh failed: " + error.message;
  97. } finally {
  98. if (db !== null && db.State === 1) db.Close();
  99. }
  100. }
  101. function readCsvFile(path) {
  102. var fs = new ActiveXObject("Scripting.FileSystemObject");
  103. if (!fs.FileExists(path)) throw new Error("CSV file not found: " + path);
  104. if (fs.GetFile(path).Size > 2097152) throw new Error("CSV exceeds the 2 MB import limit");
  105. var stream = new ActiveXObject("ADODB.Stream");
  106. stream.Type = 2;
  107. stream.Charset = "utf-8";
  108. stream.Open();
  109. stream.LoadFromFile(path);
  110. var text = stream.ReadText();
  111. stream.Close();
  112. return text;
  113. }
  114. function emailLookup(db, tableName) {
  115. var found = {};
  116. var rows = db.Execute("SELECT [EmailKey] FROM [" + tableName + "]");
  117. while (!rows.EOF) {
  118. found[String(rows.Fields("EmailKey").Value).toLowerCase()] = true;
  119. rows.MoveNext();
  120. }
  121. rows.Close();
  122. return found;
  123. }
  124. function renderPreview(preview) {
  125. document.getElementById("previewCount").innerText = preview.accepted.length +
  126. " ready, " + preview.rejected.length + " rejected";
  127. var body = document.getElementById("previewBody");
  128. while (body.firstChild) body.removeChild(body.firstChild);
  129. var items = preview.accepted.concat(preview.rejected);
  130. items.sort(function (a, b) { return a.line - b.line; });
  131. for (var i = 0; i < items.length; i++) {
  132. var tr = document.createElement("tr");
  133. var values = [items[i].line, items[i].email, items[i].businessName,
  134. items[i].source, items[i].reason || "Ready"];
  135. for (var j = 0; j < values.length; j++) {
  136. var td = document.createElement("td");
  137. td.innerText = values[j];
  138. if (j === 4) td.className = items[i].reason ? "bad" : "good";
  139. tr.appendChild(td);
  140. }
  141. body.appendChild(tr);
  142. }
  143. document.getElementById("commitImport").disabled = preview.accepted.length === 0;
  144. }
  145. function previewContacts() {
  146. var db = null;
  147. document.getElementById("importMessage").innerText = "";
  148. document.getElementById("commitImport").disabled = true;
  149. try {
  150. var path = document.getElementById("csvPath").value;
  151. pendingPath = path;
  152. pendingSource = document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "");
  153. pendingCsvText = readCsvFile(path);
  154. var opened = openDatabase();
  155. db = opened.db;
  156. pendingPreview = MassMailerContacts.preview(pendingCsvText, pendingSource,
  157. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  158. renderPreview(pendingPreview);
  159. } catch (error) {
  160. pendingPreview = null;
  161. document.getElementById("importMessage").innerText = error.message;
  162. } finally {
  163. if (db !== null && db.State === 1) db.Close();
  164. }
  165. }
  166. function utcNowSql() {
  167. return "DateAdd('n'," + new Date().getTimezoneOffset() + ",Now())";
  168. }
  169. function importOne(db, row) {
  170. var dateSql = utcNowSql();
  171. if (row.acquiredAt) {
  172. dateSql = "DateSerial(" + Number(row.acquiredAt.substring(0, 4)) + "," +
  173. Number(row.acquiredAt.substring(5, 7)) + "," +
  174. Number(row.acquiredAt.substring(8, 10)) + ")";
  175. }
  176. var cmd = new ActiveXObject("ADODB.Command");
  177. cmd.ActiveConnection = db;
  178. cmd.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName]," +
  179. "[SourceReference],[AcquiredAtUtc],[Country],[EligibilityBasis],[CreatedAtUtc]) " +
  180. "VALUES (?,?,?,? ," + dateSql + ",'US','public_listing'," + utcNowSql() + ")";
  181. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, row.email));
  182. cmd.Parameters.Append(cmd.CreateParameter("key", 202, 1, 254, row.emailKey));
  183. cmd.Parameters.Append(cmd.CreateParameter("business", 202, 1, 200, row.businessName));
  184. cmd.Parameters.Append(cmd.CreateParameter("source", 203, 1, Math.max(1, row.source.length), row.source));
  185. cmd.Execute();
  186. }
  187. function commitContacts() {
  188. if (!pendingPreview || !pendingPreview.accepted.length) return;
  189. var db = null, inTransaction = false, succeeded = false;
  190. try {
  191. if (document.getElementById("csvPath").value !== pendingPath)
  192. throw new Error("CSV file selection changed. Preview it again.");
  193. if (document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "") !== pendingSource)
  194. throw new Error("Source reference changed. Preview the CSV again.");
  195. var opened = openDatabase();
  196. db = opened.db;
  197. var current = MassMailerContacts.preview(pendingCsvText, pendingSource,
  198. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  199. if (current.accepted.length !== pendingPreview.accepted.length ||
  200. current.rejected.length !== pendingPreview.rejected.length)
  201. throw new Error("Contacts or suppressions changed. Preview the CSV again.");
  202. for (var check = 0; check < current.accepted.length; check++) {
  203. if (current.accepted[check].emailKey !== pendingPreview.accepted[check].emailKey)
  204. throw new Error("Contacts changed. Preview the CSV again.");
  205. }
  206. db.BeginTrans();
  207. inTransaction = true;
  208. for (var i = 0; i < current.accepted.length; i++) importOne(db, current.accepted[i]);
  209. db.CommitTrans();
  210. inTransaction = false;
  211. document.getElementById("importMessage").innerText =
  212. "Imported " + current.accepted.length + " contacts. Rejected rows were not saved.";
  213. pendingPreview = null;
  214. document.getElementById("commitImport").disabled = true;
  215. succeeded = true;
  216. } catch (error) {
  217. if (inTransaction) db.RollbackTrans();
  218. document.getElementById("importMessage").innerText = "Import failed: " + error.message;
  219. } finally {
  220. if (db !== null && db.State === 1) db.Close();
  221. if (succeeded) refreshStatus();
  222. }
  223. }
  224. function addSuppression() {
  225. var db = null, succeeded = false;
  226. try {
  227. var email = document.getElementById("suppressEmail").value.replace(/^\s+|\s+$/g, "");
  228. if (!MassMailerContacts.isEmail(email)) throw new Error("Enter a valid email address");
  229. var key = MassMailerContacts.emailKey(email);
  230. var reason = document.getElementById("suppressReason").value;
  231. var evidence = document.getElementById("suppressEvidence").value.replace(/^\s+|\s+$/g, "");
  232. var opened = openDatabase();
  233. db = opened.db;
  234. if (emailLookup(db, "Suppression")[key]) throw new Error("Address is already suppressed");
  235. var cmd = new ActiveXObject("ADODB.Command");
  236. cmd.ActiveConnection = db;
  237. cmd.CommandText = "INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]," +
  238. "[EvidenceReference]) VALUES (?, ?, " + utcNowSql() + ", ?)";
  239. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, key));
  240. cmd.Parameters.Append(cmd.CreateParameter("reason", 202, 1, 40, reason));
  241. cmd.Parameters.Append(cmd.CreateParameter("evidence", 203, 1, Math.max(1, evidence.length), evidence));
  242. cmd.Execute();
  243. document.getElementById("suppressMessage").innerText = "Suppressed " + key;
  244. document.getElementById("suppressEmail").value = "";
  245. succeeded = true;
  246. } catch (error) {
  247. document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
  248. } finally {
  249. if (db !== null && db.State === 1) db.Close();
  250. if (succeeded) refreshStatus();
  251. }
  252. }
  253. function powerShellHost() {
  254. var fs = new ActiveXObject("Scripting.FileSystemObject");
  255. var shell = new ActiveXObject("WScript.Shell");
  256. var root = shell.ExpandEnvironmentStrings("%SystemRoot%");
  257. var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
  258. var folder = (settings.DB_BITNESS || "").toLowerCase() === "x64" ? "System32" :
  259. (fs.FolderExists(root + "\\SysWOW64") ? "SysWOW64" : "System32");
  260. return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe";
  261. }
  262. function quoted(value) { return '"' + value + '"'; }
  263. function configurePassword() {
  264. try {
  265. var fs = new ActiveXObject("Scripting.FileSystemObject");
  266. var script = fs.BuildPath(projectRoot(), "tools\\set-smtp-password.ps1");
  267. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + quoted(script);
  268. var status = new ActiveXObject("WScript.Shell").Run(command, 1, true);
  269. document.getElementById("transportMessage").innerText = status === 0 ?
  270. "SMTP password setup completed." : "SMTP password setup failed. Review the console error.";
  271. } catch (error) {
  272. document.getElementById("transportMessage").innerText = "Setup failed: " + error.message;
  273. }
  274. }
  275. function sendInternalTest() {
  276. try {
  277. var fs = new ActiveXObject("Scripting.FileSystemObject");
  278. var root = projectRoot();
  279. var settings = readSettings(fs.BuildPath(root, "config.ini"));
  280. if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
  281. if (!window.confirm("Send one internal SMTP test to " + settings.SMTP_TEST_TO + "?")) return;
  282. var script = fs.BuildPath(root, "tools\\test-smtp-message.ps1");
  283. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  284. quoted(script) + " -Send";
  285. var process = new ActiveXObject("WScript.Shell").Exec(command);
  286. var output = process.StdOut.ReadAll();
  287. var errors = process.StdErr.ReadAll();
  288. document.getElementById("transportMessage").innerText =
  289. (process.ExitCode === 0 ? "" : "Test failed. ") + output + errors;
  290. } catch (error) {
  291. document.getElementById("transportMessage").innerText = "Test failed: " + error.message;
  292. }
  293. }
  294. function refreshTemplates() {
  295. var db = null;
  296. try {
  297. db = openDatabase().db;
  298. var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC");
  299. var select = document.getElementById("templateVersion");
  300. while (select.options.length) select.remove(0);
  301. while (!rows.EOF) {
  302. var option = document.createElement("option");
  303. option.value = String(rows.Fields("Id").Value);
  304. option.text = String(rows.Fields("TemplateName").Value) + " — version " +
  305. String(rows.Fields("VersionNumber").Value);
  306. select.add(option);
  307. rows.MoveNext();
  308. }
  309. rows.Close();
  310. } catch (error) {
  311. document.getElementById("templateMessage").innerText = "Template list failed: " + error.message;
  312. } finally {
  313. if (db !== null && db.State === 1) db.Close();
  314. }
  315. }
  316. function runTemplateTool(scriptName, args) {
  317. var fs = new ActiveXObject("Scripting.FileSystemObject");
  318. var script = fs.BuildPath(projectRoot(), "tools\\" + scriptName);
  319. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  320. quoted(script) + " " + args;
  321. var process = new ActiveXObject("WScript.Shell").Exec(command);
  322. var output = process.StdOut.ReadAll();
  323. var errors = process.StdErr.ReadAll();
  324. if (process.ExitCode !== 0) throw new Error((output + errors).replace(/^\s+|\s+$/g, ""));
  325. return output.replace(/^\s+|\s+$/g, "");
  326. }
  327. function importTemplate() {
  328. try {
  329. var path = document.getElementById("templatePath").value;
  330. if (!path || path.indexOf('"') !== -1) throw new Error("Select a template file");
  331. var result = runTemplateTool("import-template.ps1", "-Path " + quoted(path));
  332. document.getElementById("templateMessage").innerText = result;
  333. refreshTemplates();
  334. refreshStatus();
  335. } catch (error) {
  336. document.getElementById("templateMessage").innerText = "Import failed: " + error.message;
  337. }
  338. }
  339. function previewTemplate() {
  340. try {
  341. var id = document.getElementById("templateVersion").value;
  342. if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an imported template");
  343. var file = runTemplateTool("preview-template.ps1", "-TemplateId " + id);
  344. document.getElementById("templatePreview").src = file;
  345. document.getElementById("templateMessage").innerText =
  346. "Showing the imported snapshot. This preview does not include the campaign footer yet.";
  347. } catch (error) {
  348. document.getElementById("templateMessage").innerText = "Preview failed: " + error.message;
  349. }
  350. }
  351. </script>
  352. </head>
  353. <body onload="refreshStatus()">
  354. <header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
  355. <main>
  356. <div class="notice"><strong>Campaign sending is disabled in this build.</strong> The SMTP test sends one message only when you press its button.</div>
  357. <div class="cards">
  358. <div class="card">Contacts<span class="number" id="contacts">—</span></div>
  359. <div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
  360. <div class="card">Templates<span class="number" id="templates">—</span></div>
  361. <div class="card">Campaigns<span class="number" id="campaigns">—</span></div>
  362. </div>
  363. <div class="details">
  364. <div><strong>Database</strong><span id="database">Checking…</span></div>
  365. <div><strong>Provider</strong><span id="provider">Checking…</span></div>
  366. <div><strong>Send interval</strong><span id="interval">Checking…</span></div>
  367. <div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
  368. <div><strong>Send state</strong>Disabled</div>
  369. <button onclick="refreshStatus()">Refresh status</button>
  370. <div id="error"></div>
  371. </div>
  372. <div class="panel">
  373. <h2>SMTP setup and internal test</h2>
  374. <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>
  375. <button onclick="configurePassword()">Set or rotate SMTP password</button>
  376. <button onclick="sendInternalTest()">Send one internal test</button>
  377. <div id="transportMessage"></div>
  378. </div>
  379. <div class="panel">
  380. <h2>Import business contacts</h2>
  381. <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>
  382. <label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
  383. <label class="field">Source URL or reference for rows without one
  384. <input id="sourceFallback" type="text" maxlength="2048" /></label>
  385. <button onclick="previewContacts()">Preview import</button>
  386. <button id="commitImport" onclick="commitContacts()" disabled="disabled">Import ready contacts</button>
  387. <div id="importMessage"></div>
  388. <p id="previewCount"></p>
  389. <table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
  390. <tbody id="previewBody"></tbody></table>
  391. </div>
  392. <div class="panel">
  393. <h2>HTML templates</h2>
  394. <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>
  395. <label class="field">Template file <input id="templatePath" type="file" accept=".html,.htm" /></label>
  396. <button onclick="importTemplate()">Import template version</button>
  397. <label class="field">Imported version <select id="templateVersion"></select></label>
  398. <button onclick="previewTemplate()">Preview selected version</button>
  399. <div id="templateMessage"></div>
  400. <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe>
  401. </div>
  402. <div class="panel">
  403. <h2>Suppress an address</h2>
  404. <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
  405. <label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
  406. <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>
  407. <label class="field">Evidence or note <input id="suppressEvidence" type="text" maxlength="2048" /></label>
  408. <button onclick="addSuppression()">Suppress address</button>
  409. <div id="suppressMessage"></div>
  410. </div>
  411. </main>
  412. </body>
  413. </html>

Powered by TurnKey Linux.