@contentgrowth/content-emailing 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -29,11 +29,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  // src/index.js
30
30
  var index_exports = {};
31
31
  __export(index_exports, {
32
+ EmailLogger: () => EmailLogger,
32
33
  EmailService: () => EmailService,
33
34
  EmailingCacheDO: () => EmailingCacheDO,
34
35
  TemplateEditor: () => TemplateEditor,
35
36
  TemplateManager: () => TemplateManager,
36
37
  createDOCacheProvider: () => createDOCacheProvider,
38
+ createEmailLoggerCallback: () => createEmailLoggerCallback,
37
39
  createEmailRoutes: () => createEmailRoutes,
38
40
  createTemplateRoutes: () => createTemplateRoutes,
39
41
  createTrackingRoutes: () => createTrackingRoutes,
@@ -593,6 +595,10 @@ var EmailService = class {
593
595
  // Updater function to save settings to backend
594
596
  // Signature: async (profile, tenantId, settings) => void
595
597
  settingsUpdater: config.settingsUpdater || null,
598
+ // Email Logger callback for tracking all email sends
599
+ // Signature: async (logEntry) => void
600
+ // logEntry: { event: 'pending'|'sent'|'failed', recipientEmail, templateId?, subject?, provider?, messageId?, error?, metadata? }
601
+ emailLogger: config.emailLogger || null,
596
602
  // Branding configuration for email templates
597
603
  branding: {
598
604
  brandName: config.branding?.brandName || "Your App",
@@ -768,11 +774,28 @@ var EmailService = class {
768
774
  }
769
775
  }
770
776
  // --- Rendering ---
777
+ /**
778
+ * Pre-process template data to auto-format URLs
779
+ * Scans for strings starting with http:// or https:// and wraps them in Markdown links
780
+ */
781
+ _preprocessData(data) {
782
+ if (!data || typeof data !== "object") return data;
783
+ const processed = { ...data };
784
+ for (const [key, value] of Object.entries(processed)) {
785
+ if (typeof value === "string" && (value.startsWith("http://") || value.startsWith("https://"))) {
786
+ if (!value.trim().startsWith("[") && !value.includes("](")) {
787
+ processed[key] = `[${value}](${value})`;
788
+ }
789
+ }
790
+ }
791
+ return processed;
792
+ }
771
793
  async renderTemplate(templateId, data) {
772
794
  const template = await this.getTemplate(templateId);
773
795
  if (!template) throw new Error(`Template not found: ${templateId}`);
774
- const subject = import_mustache.default.render(template.subject_template, data);
775
- let markdown = import_mustache.default.render(template.body_markdown, data);
796
+ const processedData = this._preprocessData(data);
797
+ const subject = import_mustache.default.render(template.subject_template, processedData);
798
+ let markdown = import_mustache.default.render(template.body_markdown, processedData);
776
799
  markdown = markdown.replace(/\\n/g, "\n");
777
800
  import_marked.marked.use({
778
801
  mangle: false,
@@ -834,13 +857,30 @@ var EmailService = class {
834
857
  * @param {Object} [params.metadata] - Additional metadata
835
858
  * @returns {Promise<Object>} Delivery result
836
859
  */
837
- async sendEmail({ to, subject, html, htmlBody, text, textBody, provider, profile = "system", tenantId = null, metadata = {} }) {
860
+ async sendEmail({ to, subject, html, htmlBody, text, textBody, provider, profile = "system", tenantId = null, metadata = {}, batchId = null, userId = null }) {
838
861
  const htmlContent = html || htmlBody;
839
862
  const textContent = text || textBody;
863
+ const templateId = metadata?.templateId || "direct";
864
+ if (this.config.emailLogger) {
865
+ try {
866
+ await this.config.emailLogger({
867
+ event: "pending",
868
+ recipientEmail: to,
869
+ recipientUserId: userId,
870
+ templateId,
871
+ subject,
872
+ batchId,
873
+ metadata
874
+ });
875
+ } catch (e) {
876
+ console.warn("[EmailService] emailLogger pending failed:", e);
877
+ }
878
+ }
840
879
  try {
841
880
  const settings = await this.loadSettings(profile, tenantId);
842
881
  const useProvider = provider || settings.provider || "mailchannels";
843
882
  let result;
883
+ let providerMessageId = null;
844
884
  switch (useProvider) {
845
885
  case "mailchannels":
846
886
  result = await this.sendViaMailChannels(to, subject, htmlContent, textContent, settings, metadata);
@@ -850,22 +890,91 @@ var EmailService = class {
850
890
  break;
851
891
  case "resend":
852
892
  result = await this.sendViaResend(to, subject, htmlContent, textContent, settings, metadata);
893
+ if (result && typeof result === "object" && result.id) {
894
+ providerMessageId = result.id;
895
+ result = true;
896
+ }
853
897
  break;
854
898
  case "sendpulse":
855
899
  result = await this.sendViaSendPulse(to, subject, htmlContent, textContent, settings, metadata);
856
900
  break;
857
901
  default:
858
902
  console.error(`[EmailService] Unknown provider: ${useProvider}`);
903
+ if (this.config.emailLogger) {
904
+ try {
905
+ await this.config.emailLogger({
906
+ event: "failed",
907
+ recipientEmail: to,
908
+ recipientUserId: userId,
909
+ templateId,
910
+ subject,
911
+ provider: useProvider,
912
+ batchId,
913
+ error: `Unknown email provider: ${useProvider}`,
914
+ metadata
915
+ });
916
+ } catch (e) {
917
+ }
918
+ }
859
919
  return { success: false, error: `Unknown email provider: ${useProvider}` };
860
920
  }
861
921
  if (result) {
862
- return { success: true, messageId: crypto.randomUUID() };
922
+ const messageId = providerMessageId || crypto.randomUUID();
923
+ if (this.config.emailLogger) {
924
+ try {
925
+ await this.config.emailLogger({
926
+ event: "sent",
927
+ recipientEmail: to,
928
+ recipientUserId: userId,
929
+ templateId,
930
+ subject,
931
+ provider: useProvider,
932
+ messageId,
933
+ batchId,
934
+ metadata
935
+ });
936
+ } catch (e) {
937
+ console.warn("[EmailService] emailLogger sent failed:", e);
938
+ }
939
+ }
940
+ return { success: true, messageId };
863
941
  } else {
864
942
  console.error("[EmailService] Failed to send email to:", to);
943
+ if (this.config.emailLogger) {
944
+ try {
945
+ await this.config.emailLogger({
946
+ event: "failed",
947
+ recipientEmail: to,
948
+ recipientUserId: userId,
949
+ templateId,
950
+ subject,
951
+ provider: useProvider,
952
+ batchId,
953
+ error: "Failed to send email",
954
+ metadata
955
+ });
956
+ } catch (e) {
957
+ }
958
+ }
865
959
  return { success: false, error: "Failed to send email" };
866
960
  }
867
961
  } catch (error) {
868
962
  console.error("[EmailService] Error sending email:", error);
963
+ if (this.config.emailLogger) {
964
+ try {
965
+ await this.config.emailLogger({
966
+ event: "failed",
967
+ recipientEmail: to,
968
+ recipientUserId: userId,
969
+ templateId,
970
+ subject,
971
+ batchId,
972
+ error: error.message,
973
+ metadata
974
+ });
975
+ } catch (e) {
976
+ }
977
+ }
869
978
  return { success: false, error: error.message };
870
979
  }
871
980
  }
@@ -1057,6 +1166,226 @@ var EmailService = class {
1057
1166
  }
1058
1167
  };
1059
1168
 
1169
+ // src/backend/EmailLogger.js
1170
+ var EmailLogger = class {
1171
+ /**
1172
+ * @param {Object} db - D1 database binding
1173
+ * @param {Object} options - Configuration options
1174
+ * @param {string} [options.tableName='system_email_logs'] - Table name for logs
1175
+ */
1176
+ constructor(db, options = {}) {
1177
+ this.db = db;
1178
+ this.tableName = options.tableName || "system_email_logs";
1179
+ }
1180
+ /**
1181
+ * Creates a logger callback function for use with EmailService.
1182
+ * Usage: new EmailService(env, { emailLogger: emailLogger.createCallback() })
1183
+ */
1184
+ createCallback() {
1185
+ return async (entry) => {
1186
+ await this.log(entry);
1187
+ };
1188
+ }
1189
+ /**
1190
+ * Log an email event (pending, sent, or failed)
1191
+ * @param {Object} entry - Log entry
1192
+ */
1193
+ async log(entry) {
1194
+ const {
1195
+ event,
1196
+ recipientEmail,
1197
+ recipientUserId,
1198
+ templateId,
1199
+ subject,
1200
+ provider,
1201
+ messageId,
1202
+ batchId,
1203
+ error,
1204
+ errorCode,
1205
+ metadata
1206
+ } = entry;
1207
+ try {
1208
+ if (event === "pending") {
1209
+ const id = crypto.randomUUID().replace(/-/g, "");
1210
+ await this.db.prepare(`
1211
+ INSERT INTO ${this.tableName}
1212
+ (id, batch_id, recipient_email, recipient_user_id, template_id, subject, status, metadata, created_at)
1213
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, strftime('%s', 'now'))
1214
+ `).bind(
1215
+ id,
1216
+ batchId || null,
1217
+ recipientEmail,
1218
+ recipientUserId || null,
1219
+ templateId || "direct",
1220
+ subject || null,
1221
+ metadata ? JSON.stringify(metadata) : null
1222
+ ).run();
1223
+ } else if (event === "sent") {
1224
+ await this.db.prepare(`
1225
+ UPDATE ${this.tableName}
1226
+ SET status = 'sent',
1227
+ provider = ?,
1228
+ provider_message_id = ?,
1229
+ sent_at = strftime('%s', 'now')
1230
+ WHERE recipient_email = ?
1231
+ AND template_id = ?
1232
+ AND status = 'pending'
1233
+ ORDER BY created_at DESC
1234
+ LIMIT 1
1235
+ `).bind(
1236
+ provider || null,
1237
+ messageId || null,
1238
+ recipientEmail,
1239
+ templateId || "direct"
1240
+ ).run();
1241
+ } else if (event === "failed") {
1242
+ await this.db.prepare(`
1243
+ UPDATE ${this.tableName}
1244
+ SET status = 'failed',
1245
+ provider = ?,
1246
+ error_message = ?,
1247
+ error_code = ?
1248
+ WHERE recipient_email = ?
1249
+ AND template_id = ?
1250
+ AND status = 'pending'
1251
+ ORDER BY created_at DESC
1252
+ LIMIT 1
1253
+ `).bind(
1254
+ provider || null,
1255
+ error || null,
1256
+ errorCode || null,
1257
+ recipientEmail,
1258
+ templateId || "direct"
1259
+ ).run();
1260
+ }
1261
+ } catch (e) {
1262
+ console.error("[EmailLogger] Failed to log:", e);
1263
+ }
1264
+ }
1265
+ /**
1266
+ * Query email logs with filtering
1267
+ * @param {Object} options - Query options
1268
+ */
1269
+ async query(options = {}) {
1270
+ const {
1271
+ recipientEmail,
1272
+ recipientUserId,
1273
+ templateId,
1274
+ status,
1275
+ batchId,
1276
+ limit = 50,
1277
+ offset = 0
1278
+ } = options;
1279
+ const conditions = [];
1280
+ const bindings = [];
1281
+ if (recipientEmail) {
1282
+ conditions.push("recipient_email = ?");
1283
+ bindings.push(recipientEmail);
1284
+ }
1285
+ if (recipientUserId) {
1286
+ conditions.push("recipient_user_id = ?");
1287
+ bindings.push(recipientUserId);
1288
+ }
1289
+ if (templateId) {
1290
+ conditions.push("template_id = ?");
1291
+ bindings.push(templateId);
1292
+ }
1293
+ if (status) {
1294
+ conditions.push("status = ?");
1295
+ bindings.push(status);
1296
+ }
1297
+ if (batchId) {
1298
+ conditions.push("batch_id = ?");
1299
+ bindings.push(batchId);
1300
+ }
1301
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1302
+ const countResult = await this.db.prepare(
1303
+ `SELECT COUNT(*) as count FROM ${this.tableName} ${whereClause}`
1304
+ ).bind(...bindings).first();
1305
+ const { results } = await this.db.prepare(`
1306
+ SELECT id, batch_id, recipient_email, recipient_user_id, template_id, subject,
1307
+ status, provider, provider_message_id, error_message, error_code, metadata,
1308
+ created_at, sent_at
1309
+ FROM ${this.tableName}
1310
+ ${whereClause}
1311
+ ORDER BY created_at DESC
1312
+ LIMIT ? OFFSET ?
1313
+ `).bind(...bindings, limit, offset).all();
1314
+ const logs = (results || []).map((row) => ({
1315
+ id: row.id,
1316
+ batchId: row.batch_id,
1317
+ recipientEmail: row.recipient_email,
1318
+ recipientUserId: row.recipient_user_id,
1319
+ templateId: row.template_id,
1320
+ subject: row.subject,
1321
+ status: row.status,
1322
+ provider: row.provider,
1323
+ providerMessageId: row.provider_message_id,
1324
+ errorMessage: row.error_message,
1325
+ errorCode: row.error_code,
1326
+ metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
1327
+ createdAt: row.created_at,
1328
+ sentAt: row.sent_at
1329
+ }));
1330
+ return { logs, total: countResult?.count || 0 };
1331
+ }
1332
+ /**
1333
+ * Get email sending statistics
1334
+ * @param {number} sinceDays - Number of days to look back
1335
+ */
1336
+ async getStats(sinceDays = 7) {
1337
+ const sinceTimestamp = Math.floor(Date.now() / 1e3) - sinceDays * 24 * 60 * 60;
1338
+ const statusResult = await this.db.prepare(`
1339
+ SELECT status, COUNT(*) as count
1340
+ FROM ${this.tableName}
1341
+ WHERE created_at >= ?
1342
+ GROUP BY status
1343
+ `).bind(sinceTimestamp).all();
1344
+ const templateResult = await this.db.prepare(`
1345
+ SELECT template_id, COUNT(*) as count
1346
+ FROM ${this.tableName}
1347
+ WHERE created_at >= ?
1348
+ GROUP BY template_id
1349
+ `).bind(sinceTimestamp).all();
1350
+ const stats = {
1351
+ total: 0,
1352
+ sent: 0,
1353
+ failed: 0,
1354
+ pending: 0,
1355
+ byTemplate: {}
1356
+ };
1357
+ (statusResult.results || []).forEach((row) => {
1358
+ const count = row.count || 0;
1359
+ stats.total += count;
1360
+ if (row.status === "sent") stats.sent = count;
1361
+ if (row.status === "failed") stats.failed = count;
1362
+ if (row.status === "pending") stats.pending = count;
1363
+ });
1364
+ (templateResult.results || []).forEach((row) => {
1365
+ stats.byTemplate[row.template_id] = row.count;
1366
+ });
1367
+ return stats;
1368
+ }
1369
+ /**
1370
+ * Get recent failed emails for debugging
1371
+ * @param {number} limit - Number of failed emails to retrieve
1372
+ */
1373
+ async getRecentFailures(limit = 20) {
1374
+ const { results } = await this.db.prepare(`
1375
+ SELECT id, recipient_email, template_id, subject, error_message, error_code, created_at
1376
+ FROM ${this.tableName}
1377
+ WHERE status = 'failed'
1378
+ ORDER BY created_at DESC
1379
+ LIMIT ?
1380
+ `).bind(limit).all();
1381
+ return results || [];
1382
+ }
1383
+ };
1384
+ function createEmailLoggerCallback(db, tableName = "system_email_logs") {
1385
+ const logger = new EmailLogger(db, { tableName });
1386
+ return logger.createCallback();
1387
+ }
1388
+
1060
1389
  // src/backend/routes/index.js
1061
1390
  var import_hono3 = require("hono");
1062
1391
 
@@ -1725,7 +2054,7 @@ var TemplateManager = ({
1725
2054
  },
1726
2055
  /* @__PURE__ */ import_react3.default.createElement("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24" }, /* @__PURE__ */ import_react3.default.createElement("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 4v16m8-8H4" })),
1727
2056
  "Create Template"
1728
- )), error && /* @__PURE__ */ import_react3.default.createElement("div", { className: "mb-4 p-3 rounded-lg bg-red-50 text-red-800 border border-red-200" }, error, /* @__PURE__ */ import_react3.default.createElement("button", { onClick: () => setError(null), className: "ml-2 underline" }, "Dismiss")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "grid grid-cols-4 gap-4 mb-6" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-gray-900" }, templates.length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Total")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-green-600" }, templates.filter((t) => t.is_active).length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Active")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-yellow-600" }, templates.filter((t) => !t.is_active).length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Inactive")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-blue-600" }, new Set(templates.map((t) => t.template_type)).size), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Types"))), /* @__PURE__ */ import_react3.default.createElement("div", { className: "flex gap-2 mb-6 border-b border-gray-200 pb-3 flex-wrap" }, uniqueTypes.map((type) => /* @__PURE__ */ import_react3.default.createElement(
2057
+ )), error && /* @__PURE__ */ import_react3.default.createElement("div", { className: "mb-4 p-3 rounded-lg bg-red-50 text-red-800 border border-red-200" }, error, /* @__PURE__ */ import_react3.default.createElement("button", { onClick: () => setError(null), className: "ml-2 underline" }, "Dismiss")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "flex gap-4 mb-6" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4 flex-1" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-gray-900" }, templates.length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Total")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4 flex-1" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-green-600" }, templates.filter((t) => t.is_active).length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Active")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4 flex-1" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-yellow-600" }, templates.filter((t) => !t.is_active).length), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Inactive")), /* @__PURE__ */ import_react3.default.createElement("div", { className: "bg-white rounded-lg border border-gray-200 p-4 flex-1" }, /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-2xl font-bold text-blue-600" }, new Set(templates.map((t) => t.template_type)).size), /* @__PURE__ */ import_react3.default.createElement("div", { className: "text-sm text-gray-500" }, "Types"))), /* @__PURE__ */ import_react3.default.createElement("div", { className: "flex gap-2 mb-6 border-b border-gray-200 pb-3 flex-wrap" }, uniqueTypes.map((type) => /* @__PURE__ */ import_react3.default.createElement(
1729
2058
  "button",
1730
2059
  {
1731
2060
  key: type,
@@ -1850,11 +2179,13 @@ var TemplateManager = ({
1850
2179
  };
1851
2180
  // Annotate the CommonJS export names for ESM import in node:
1852
2181
  0 && (module.exports = {
2182
+ EmailLogger,
1853
2183
  EmailService,
1854
2184
  EmailingCacheDO,
1855
2185
  TemplateEditor,
1856
2186
  TemplateManager,
1857
2187
  createDOCacheProvider,
2188
+ createEmailLoggerCallback,
1858
2189
  createEmailRoutes,
1859
2190
  createTemplateRoutes,
1860
2191
  createTrackingRoutes,