Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

346 рядки
18KB

  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. </style>
  31. <script language="JScript" src="src/contacts.js"></script>
  32. <script language="JScript" type="text/javascript">
  33. var pendingCsvText = null;
  34. var pendingPreview = null;
  35. var pendingSource = "";
  36. var pendingPath = "";
  37. function projectRoot() {
  38. var path = decodeURIComponent(window.location.pathname);
  39. if (path.charAt(0) === "/" && path.charAt(2) === ":") path = path.substring(1);
  40. path = path.replace(/\//g, "\\");
  41. return new ActiveXObject("Scripting.FileSystemObject").GetParentFolderName(path);
  42. }
  43. function readSettings(filePath) {
  44. var stream = new ActiveXObject("ADODB.Stream");
  45. stream.Type = 2;
  46. stream.Charset = "utf-8";
  47. stream.Open();
  48. stream.LoadFromFile(filePath);
  49. var data = stream.ReadText();
  50. stream.Close();
  51. var result = {};
  52. var lines = data.replace(/\r\n?/g, "\n").split("\n");
  53. for (var i = 0; i < lines.length; i++) {
  54. var line = lines[i].replace(/^\s+|\s+$/g, "");
  55. if (!line || line.charAt(0) === "#" || line.charAt(0) === ";") continue;
  56. var equalAt = line.indexOf("=");
  57. if (equalAt > 0) result[line.substring(0, equalAt).replace(/\s/g, "").toUpperCase()] =
  58. line.substring(equalAt + 1).replace(/^\s+|\s+$/g, "");
  59. }
  60. return result;
  61. }
  62. function countRows(db, tableName) {
  63. var rows = db.Execute("SELECT COUNT(*) AS N FROM [" + tableName + "]");
  64. var result = rows.Fields("N").Value;
  65. rows.Close();
  66. return result;
  67. }
  68. function openDatabase() {
  69. var fs = new ActiveXObject("Scripting.FileSystemObject");
  70. var root = projectRoot();
  71. var config = readSettings(fs.BuildPath(root, "config.ini"));
  72. var dbPath = config.DB_PATH;
  73. if (fs.GetDriveName(dbPath) === "") dbPath = fs.BuildPath(root, dbPath);
  74. dbPath = fs.GetAbsolutePathName(dbPath);
  75. var db = new ActiveXObject("ADODB.Connection");
  76. db.Open("Provider=" + config.DB_PROVIDER + ";Data Source=" + dbPath + ";");
  77. return { db: db, config: config, path: dbPath };
  78. }
  79. function refreshStatus() {
  80. var db = null;
  81. try {
  82. var opened = openDatabase();
  83. db = opened.db;
  84. document.getElementById("contacts").innerText = countRows(db, "Contact");
  85. document.getElementById("suppressed").innerText = countRows(db, "Suppression");
  86. document.getElementById("templates").innerText = countRows(db, "TemplateVersion");
  87. document.getElementById("campaigns").innerText = countRows(db, "Campaign");
  88. document.getElementById("database").innerText = opened.path;
  89. document.getElementById("provider").innerText = opened.config.DB_PROVIDER;
  90. document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
  91. document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
  92. document.getElementById("error").innerText = "";
  93. } catch (error) {
  94. document.getElementById("error").innerText = "Status refresh failed: " + error.message;
  95. } finally {
  96. if (db !== null && db.State === 1) db.Close();
  97. }
  98. }
  99. function readCsvFile(path) {
  100. var fs = new ActiveXObject("Scripting.FileSystemObject");
  101. if (!fs.FileExists(path)) throw new Error("CSV file not found: " + path);
  102. if (fs.GetFile(path).Size > 2097152) throw new Error("CSV exceeds the 2 MB import limit");
  103. var stream = new ActiveXObject("ADODB.Stream");
  104. stream.Type = 2;
  105. stream.Charset = "utf-8";
  106. stream.Open();
  107. stream.LoadFromFile(path);
  108. var text = stream.ReadText();
  109. stream.Close();
  110. return text;
  111. }
  112. function emailLookup(db, tableName) {
  113. var found = {};
  114. var rows = db.Execute("SELECT [EmailKey] FROM [" + tableName + "]");
  115. while (!rows.EOF) {
  116. found[String(rows.Fields("EmailKey").Value).toLowerCase()] = true;
  117. rows.MoveNext();
  118. }
  119. rows.Close();
  120. return found;
  121. }
  122. function renderPreview(preview) {
  123. document.getElementById("previewCount").innerText = preview.accepted.length +
  124. " ready, " + preview.rejected.length + " rejected";
  125. var body = document.getElementById("previewBody");
  126. while (body.firstChild) body.removeChild(body.firstChild);
  127. var items = preview.accepted.concat(preview.rejected);
  128. items.sort(function (a, b) { return a.line - b.line; });
  129. for (var i = 0; i < items.length; i++) {
  130. var tr = document.createElement("tr");
  131. var values = [items[i].line, items[i].email, items[i].businessName,
  132. items[i].source, items[i].reason || "Ready"];
  133. for (var j = 0; j < values.length; j++) {
  134. var td = document.createElement("td");
  135. td.innerText = values[j];
  136. if (j === 4) td.className = items[i].reason ? "bad" : "good";
  137. tr.appendChild(td);
  138. }
  139. body.appendChild(tr);
  140. }
  141. document.getElementById("commitImport").disabled = preview.accepted.length === 0;
  142. }
  143. function previewContacts() {
  144. var db = null;
  145. document.getElementById("importMessage").innerText = "";
  146. document.getElementById("commitImport").disabled = true;
  147. try {
  148. var path = document.getElementById("csvPath").value;
  149. pendingPath = path;
  150. pendingSource = document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "");
  151. pendingCsvText = readCsvFile(path);
  152. var opened = openDatabase();
  153. db = opened.db;
  154. pendingPreview = MassMailerContacts.preview(pendingCsvText, pendingSource,
  155. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  156. renderPreview(pendingPreview);
  157. } catch (error) {
  158. pendingPreview = null;
  159. document.getElementById("importMessage").innerText = error.message;
  160. } finally {
  161. if (db !== null && db.State === 1) db.Close();
  162. }
  163. }
  164. function utcNowSql() {
  165. return "DateAdd('n'," + new Date().getTimezoneOffset() + ",Now())";
  166. }
  167. function importOne(db, row) {
  168. var dateSql = utcNowSql();
  169. if (row.acquiredAt) {
  170. dateSql = "DateSerial(" + Number(row.acquiredAt.substring(0, 4)) + "," +
  171. Number(row.acquiredAt.substring(5, 7)) + "," +
  172. Number(row.acquiredAt.substring(8, 10)) + ")";
  173. }
  174. var cmd = new ActiveXObject("ADODB.Command");
  175. cmd.ActiveConnection = db;
  176. cmd.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName]," +
  177. "[SourceReference],[AcquiredAtUtc],[Country],[EligibilityBasis],[CreatedAtUtc]) " +
  178. "VALUES (?,?,?,? ," + dateSql + ",'US','public_listing'," + utcNowSql() + ")";
  179. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, row.email));
  180. cmd.Parameters.Append(cmd.CreateParameter("key", 202, 1, 254, row.emailKey));
  181. cmd.Parameters.Append(cmd.CreateParameter("business", 202, 1, 200, row.businessName));
  182. cmd.Parameters.Append(cmd.CreateParameter("source", 203, 1, Math.max(1, row.source.length), row.source));
  183. cmd.Execute();
  184. }
  185. function commitContacts() {
  186. if (!pendingPreview || !pendingPreview.accepted.length) return;
  187. var db = null, inTransaction = false, succeeded = false;
  188. try {
  189. if (document.getElementById("csvPath").value !== pendingPath)
  190. throw new Error("CSV file selection changed. Preview it again.");
  191. if (document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "") !== pendingSource)
  192. throw new Error("Source reference changed. Preview the CSV again.");
  193. var opened = openDatabase();
  194. db = opened.db;
  195. var current = MassMailerContacts.preview(pendingCsvText, pendingSource,
  196. emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
  197. if (current.accepted.length !== pendingPreview.accepted.length ||
  198. current.rejected.length !== pendingPreview.rejected.length)
  199. throw new Error("Contacts or suppressions changed. Preview the CSV again.");
  200. for (var check = 0; check < current.accepted.length; check++) {
  201. if (current.accepted[check].emailKey !== pendingPreview.accepted[check].emailKey)
  202. throw new Error("Contacts changed. Preview the CSV again.");
  203. }
  204. db.BeginTrans();
  205. inTransaction = true;
  206. for (var i = 0; i < current.accepted.length; i++) importOne(db, current.accepted[i]);
  207. db.CommitTrans();
  208. inTransaction = false;
  209. document.getElementById("importMessage").innerText =
  210. "Imported " + current.accepted.length + " contacts. Rejected rows were not saved.";
  211. pendingPreview = null;
  212. document.getElementById("commitImport").disabled = true;
  213. succeeded = true;
  214. } catch (error) {
  215. if (inTransaction) db.RollbackTrans();
  216. document.getElementById("importMessage").innerText = "Import failed: " + error.message;
  217. } finally {
  218. if (db !== null && db.State === 1) db.Close();
  219. if (succeeded) refreshStatus();
  220. }
  221. }
  222. function addSuppression() {
  223. var db = null, succeeded = false;
  224. try {
  225. var email = document.getElementById("suppressEmail").value.replace(/^\s+|\s+$/g, "");
  226. if (!MassMailerContacts.isEmail(email)) throw new Error("Enter a valid email address");
  227. var key = MassMailerContacts.emailKey(email);
  228. var reason = document.getElementById("suppressReason").value;
  229. var evidence = document.getElementById("suppressEvidence").value.replace(/^\s+|\s+$/g, "");
  230. var opened = openDatabase();
  231. db = opened.db;
  232. if (emailLookup(db, "Suppression")[key]) throw new Error("Address is already suppressed");
  233. var cmd = new ActiveXObject("ADODB.Command");
  234. cmd.ActiveConnection = db;
  235. cmd.CommandText = "INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]," +
  236. "[EvidenceReference]) VALUES (?, ?, " + utcNowSql() + ", ?)";
  237. cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, key));
  238. cmd.Parameters.Append(cmd.CreateParameter("reason", 202, 1, 40, reason));
  239. cmd.Parameters.Append(cmd.CreateParameter("evidence", 203, 1, Math.max(1, evidence.length), evidence));
  240. cmd.Execute();
  241. document.getElementById("suppressMessage").innerText = "Suppressed " + key;
  242. document.getElementById("suppressEmail").value = "";
  243. succeeded = true;
  244. } catch (error) {
  245. document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
  246. } finally {
  247. if (db !== null && db.State === 1) db.Close();
  248. if (succeeded) refreshStatus();
  249. }
  250. }
  251. function powerShellHost() {
  252. var fs = new ActiveXObject("Scripting.FileSystemObject");
  253. var shell = new ActiveXObject("WScript.Shell");
  254. var root = shell.ExpandEnvironmentStrings("%SystemRoot%");
  255. var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
  256. var folder = (settings.DB_BITNESS || "").toLowerCase() === "x64" ? "System32" :
  257. (fs.FolderExists(root + "\\SysWOW64") ? "SysWOW64" : "System32");
  258. return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe";
  259. }
  260. function quoted(value) { return '"' + value + '"'; }
  261. function configurePassword() {
  262. try {
  263. var fs = new ActiveXObject("Scripting.FileSystemObject");
  264. var script = fs.BuildPath(projectRoot(), "tools\\set-smtp-password.ps1");
  265. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + quoted(script);
  266. var status = new ActiveXObject("WScript.Shell").Run(command, 1, true);
  267. document.getElementById("transportMessage").innerText = status === 0 ?
  268. "SMTP password setup completed." : "SMTP password setup failed. Review the console error.";
  269. } catch (error) {
  270. document.getElementById("transportMessage").innerText = "Setup failed: " + error.message;
  271. }
  272. }
  273. function sendInternalTest() {
  274. try {
  275. var fs = new ActiveXObject("Scripting.FileSystemObject");
  276. var root = projectRoot();
  277. var settings = readSettings(fs.BuildPath(root, "config.ini"));
  278. if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
  279. if (!window.confirm("Send one internal SMTP test to " + settings.SMTP_TEST_TO + "?")) return;
  280. var script = fs.BuildPath(root, "tools\\test-smtp-message.ps1");
  281. var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
  282. quoted(script) + " -Send";
  283. var process = new ActiveXObject("WScript.Shell").Exec(command);
  284. var output = process.StdOut.ReadAll();
  285. var errors = process.StdErr.ReadAll();
  286. document.getElementById("transportMessage").innerText =
  287. (process.ExitCode === 0 ? "" : "Test failed. ") + output + errors;
  288. } catch (error) {
  289. document.getElementById("transportMessage").innerText = "Test failed: " + error.message;
  290. }
  291. }
  292. </script>
  293. </head>
  294. <body onload="refreshStatus()">
  295. <header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
  296. <main>
  297. <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>
  298. <div class="cards">
  299. <div class="card">Contacts<span class="number" id="contacts">—</span></div>
  300. <div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
  301. <div class="card">Templates<span class="number" id="templates">—</span></div>
  302. <div class="card">Campaigns<span class="number" id="campaigns">—</span></div>
  303. </div>
  304. <div class="details">
  305. <div><strong>Database</strong><span id="database">Checking…</span></div>
  306. <div><strong>Provider</strong><span id="provider">Checking…</span></div>
  307. <div><strong>Send interval</strong><span id="interval">Checking…</span></div>
  308. <div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
  309. <div><strong>Send state</strong>Disabled</div>
  310. <button onclick="refreshStatus()">Refresh status</button>
  311. <div id="error"></div>
  312. </div>
  313. <div class="panel">
  314. <h2>SMTP setup and internal test</h2>
  315. <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>
  316. <button onclick="configurePassword()">Set or rotate SMTP password</button>
  317. <button onclick="sendInternalTest()">Send one internal test</button>
  318. <div id="transportMessage"></div>
  319. </div>
  320. <div class="panel">
  321. <h2>Import business contacts</h2>
  322. <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>
  323. <label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
  324. <label class="field">Source URL or reference for rows without one
  325. <input id="sourceFallback" type="text" maxlength="2048" /></label>
  326. <button onclick="previewContacts()">Preview import</button>
  327. <button id="commitImport" onclick="commitContacts()" disabled="disabled">Import ready contacts</button>
  328. <div id="importMessage"></div>
  329. <p id="previewCount"></p>
  330. <table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
  331. <tbody id="previewBody"></tbody></table>
  332. </div>
  333. <div class="panel">
  334. <h2>Suppress an address</h2>
  335. <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
  336. <label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
  337. <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>
  338. <label class="field">Evidence or note <input id="suppressEvidence" type="text" maxlength="2048" /></label>
  339. <button onclick="addSuppression()">Suppress address</button>
  340. <div id="suppressMessage"></div>
  341. </div>
  342. </main>
  343. </body>
  344. </html>

Powered by TurnKey Linux.