kempo 4.6.2 → 4.8.0

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.
Files changed (246) hide show
  1. package/README.md +8 -1
  2. package/app-public/account/index.page.html +2 -0
  3. package/app-public/nav.fragment.html +2 -1
  4. package/app-templates/emails/email.template.html +14 -0
  5. package/app-templates/emails/reset-password.page.html +19 -0
  6. package/app-templates/emails/test.page.html +21 -0
  7. package/app-templates/emails/verify-email.page.html +18 -0
  8. package/bin/cli.js +18 -33
  9. package/dist/admin/components/Dashboard.js +2 -2
  10. package/dist/admin/components/Dashboard.js.map +1 -1
  11. package/dist/admin/components/EmailTemplates.js +25 -0
  12. package/dist/admin/components/EmailTemplates.js.map +1 -0
  13. package/dist/admin/components/Emails.js +49 -0
  14. package/dist/admin/components/Emails.js.map +1 -0
  15. package/dist/admin/components/Menus.js +103 -0
  16. package/dist/admin/components/Menus.js.map +1 -0
  17. package/dist/admin/components/settings/AdminSettings.js +7 -0
  18. package/dist/admin/components/settings/AdminSettings.js.map +1 -0
  19. package/dist/admin/components/settings/BooleanSetting.js +2 -0
  20. package/dist/admin/components/settings/BooleanSetting.js.map +1 -0
  21. package/dist/admin/components/settings/JsonSetting.js +2 -0
  22. package/dist/admin/components/settings/JsonSetting.js.map +1 -0
  23. package/dist/admin/components/settings/NumberSetting.js +2 -0
  24. package/dist/admin/components/settings/NumberSetting.js.map +1 -0
  25. package/dist/admin/components/settings/SecretSetting.js +2 -0
  26. package/dist/admin/components/settings/SecretSetting.js.map +1 -0
  27. package/dist/admin/components/settings/Setting.js +2 -0
  28. package/dist/admin/components/settings/Setting.js.map +1 -0
  29. package/dist/admin/components/settings/SettingEditor.js +2 -0
  30. package/dist/admin/components/settings/SettingEditor.js.map +1 -0
  31. package/dist/admin/components/settings/SettingsPanel.js +24 -0
  32. package/dist/admin/components/settings/SettingsPanel.js.map +1 -0
  33. package/dist/admin/components/settings/StringSetting.js +2 -0
  34. package/dist/admin/components/settings/StringSetting.js.map +1 -0
  35. package/dist/admin/components/settings/humanize.js +2 -0
  36. package/dist/admin/components/settings/humanize.js.map +1 -0
  37. package/dist/admin/content/menus/index.page.html +11 -0
  38. package/dist/admin/emails/index.page.html +14 -0
  39. package/dist/admin/icons/mail.svg +1 -0
  40. package/dist/admin/nav.fragment.html +12 -0
  41. package/dist/admin/settings/[owner]/[name]/index.page.html +1 -1
  42. package/dist/admin/settings/index.page.html +6 -289
  43. package/dist/kempo/api/email-templates/GET.js +2 -0
  44. package/dist/kempo/api/email-templates/GET.js.map +1 -0
  45. package/dist/kempo/api/email-templates/file/DELETE.js +2 -0
  46. package/dist/kempo/api/email-templates/file/DELETE.js.map +1 -0
  47. package/dist/kempo/api/email-templates/file/GET.js +2 -0
  48. package/dist/kempo/api/email-templates/file/GET.js.map +1 -0
  49. package/dist/kempo/api/email-templates/file/PUT.js +2 -0
  50. package/dist/kempo/api/email-templates/file/PUT.js.map +1 -0
  51. package/dist/kempo/api/email-templates/preview/GET.js +2 -0
  52. package/dist/kempo/api/email-templates/preview/GET.js.map +1 -0
  53. package/dist/kempo/api/emails/GET.js +2 -0
  54. package/dist/kempo/api/emails/GET.js.map +1 -0
  55. package/dist/kempo/api/emails/[id]/GET.js +2 -0
  56. package/dist/kempo/api/emails/[id]/GET.js.map +1 -0
  57. package/dist/kempo/api/emails/[id]/retry/POST.js +2 -0
  58. package/dist/kempo/api/emails/[id]/retry/POST.js.map +1 -0
  59. package/dist/kempo/api/emails/test/POST.js +2 -0
  60. package/dist/kempo/api/emails/test/POST.js.map +1 -0
  61. package/dist/kempo/api/fragments/DELETE.js +1 -1
  62. package/dist/kempo/api/fragments/DELETE.js.map +1 -1
  63. package/dist/kempo/api/fragments/GET.js +1 -1
  64. package/dist/kempo/api/fragments/GET.js.map +1 -1
  65. package/dist/kempo/api/fragments/POST.js +1 -1
  66. package/dist/kempo/api/fragments/POST.js.map +1 -1
  67. package/dist/kempo/api/fragments/disable/PUT.js +1 -1
  68. package/dist/kempo/api/fragments/disable/PUT.js.map +1 -1
  69. package/dist/kempo/api/fragments/enable/PUT.js +1 -1
  70. package/dist/kempo/api/fragments/enable/PUT.js.map +1 -1
  71. package/dist/kempo/api/fragments/file/GET.js +1 -1
  72. package/dist/kempo/api/fragments/file/GET.js.map +1 -1
  73. package/dist/kempo/api/fragments/file/PATCH.js +1 -1
  74. package/dist/kempo/api/fragments/file/PATCH.js.map +1 -1
  75. package/dist/kempo/api/fragments/file/PUT.js +1 -1
  76. package/dist/kempo/api/fragments/file/PUT.js.map +1 -1
  77. package/dist/kempo/api/fragments/lock/PUT.js +1 -1
  78. package/dist/kempo/api/fragments/lock/PUT.js.map +1 -1
  79. package/dist/kempo/api/globals/DELETE.js +1 -1
  80. package/dist/kempo/api/globals/DELETE.js.map +1 -1
  81. package/dist/kempo/api/globals/GET.js +1 -1
  82. package/dist/kempo/api/globals/GET.js.map +1 -1
  83. package/dist/kempo/api/globals/POST.js +1 -1
  84. package/dist/kempo/api/globals/POST.js.map +1 -1
  85. package/dist/kempo/api/globals/disable/PUT.js +1 -1
  86. package/dist/kempo/api/globals/disable/PUT.js.map +1 -1
  87. package/dist/kempo/api/globals/enable/PUT.js +1 -1
  88. package/dist/kempo/api/globals/enable/PUT.js.map +1 -1
  89. package/dist/kempo/api/globals/entry/GET.js +1 -1
  90. package/dist/kempo/api/globals/entry/GET.js.map +1 -1
  91. package/dist/kempo/api/globals/entry/PUT.js +1 -1
  92. package/dist/kempo/api/globals/entry/PUT.js.map +1 -1
  93. package/dist/kempo/api/globals/lock/PUT.js +1 -1
  94. package/dist/kempo/api/globals/lock/PUT.js.map +1 -1
  95. package/dist/kempo/api/menus/DELETE.js +2 -0
  96. package/dist/kempo/api/menus/DELETE.js.map +1 -0
  97. package/dist/kempo/api/menus/GET.js +2 -0
  98. package/dist/kempo/api/menus/GET.js.map +1 -0
  99. package/dist/kempo/api/menus/POST.js +2 -0
  100. package/dist/kempo/api/menus/POST.js.map +1 -0
  101. package/dist/kempo/api/menus/adopt/PUT.js +2 -0
  102. package/dist/kempo/api/menus/adopt/PUT.js.map +1 -0
  103. package/dist/kempo/api/menus/items/DELETE.js +2 -0
  104. package/dist/kempo/api/menus/items/DELETE.js.map +1 -0
  105. package/dist/kempo/api/menus/items/POST.js +2 -0
  106. package/dist/kempo/api/menus/items/POST.js.map +1 -0
  107. package/dist/kempo/api/menus/items/enabled/PUT.js +2 -0
  108. package/dist/kempo/api/menus/items/enabled/PUT.js.map +1 -0
  109. package/dist/kempo/api/menus/items/order/PUT.js +2 -0
  110. package/dist/kempo/api/menus/items/order/PUT.js.map +1 -0
  111. package/dist/kempo/api/menus/location/PUT.js +2 -0
  112. package/dist/kempo/api/menus/location/PUT.js.map +1 -0
  113. package/dist/kempo/api/pages/DELETE.js +1 -1
  114. package/dist/kempo/api/pages/DELETE.js.map +1 -1
  115. package/dist/kempo/api/pages/GET.js +1 -1
  116. package/dist/kempo/api/pages/GET.js.map +1 -1
  117. package/dist/kempo/api/pages/POST.js +1 -1
  118. package/dist/kempo/api/pages/POST.js.map +1 -1
  119. package/dist/kempo/api/pages/directories/GET.js +1 -1
  120. package/dist/kempo/api/pages/directories/GET.js.map +1 -1
  121. package/dist/kempo/api/pages/disable/PUT.js +1 -1
  122. package/dist/kempo/api/pages/disable/PUT.js.map +1 -1
  123. package/dist/kempo/api/pages/enable/PUT.js +1 -1
  124. package/dist/kempo/api/pages/enable/PUT.js.map +1 -1
  125. package/dist/kempo/api/pages/file/GET.js +1 -1
  126. package/dist/kempo/api/pages/file/GET.js.map +1 -1
  127. package/dist/kempo/api/pages/file/PATCH.js +1 -1
  128. package/dist/kempo/api/pages/file/PATCH.js.map +1 -1
  129. package/dist/kempo/api/pages/file/PUT.js +1 -1
  130. package/dist/kempo/api/pages/file/PUT.js.map +1 -1
  131. package/dist/kempo/api/pages/lock/PUT.js +1 -1
  132. package/dist/kempo/api/pages/lock/PUT.js.map +1 -1
  133. package/dist/kempo/api/pages/rescan/POST.js +1 -1
  134. package/dist/kempo/api/pages/rescan/POST.js.map +1 -1
  135. package/dist/kempo/api/pages/search/GET.js +1 -1
  136. package/dist/kempo/api/pages/search/GET.js.map +1 -1
  137. package/dist/kempo/api/pages/templates/GET.js +1 -1
  138. package/dist/kempo/api/pages/templates/GET.js.map +1 -1
  139. package/dist/kempo/api/settings/[owner]/[name]/POST.js +1 -1
  140. package/dist/kempo/api/settings/[owner]/[name]/POST.js.map +1 -1
  141. package/dist/kempo/api/templates/DELETE.js +1 -1
  142. package/dist/kempo/api/templates/DELETE.js.map +1 -1
  143. package/dist/kempo/api/templates/GET.js +1 -1
  144. package/dist/kempo/api/templates/GET.js.map +1 -1
  145. package/dist/kempo/api/templates/POST.js +1 -1
  146. package/dist/kempo/api/templates/POST.js.map +1 -1
  147. package/dist/kempo/api/templates/disable/PUT.js +1 -1
  148. package/dist/kempo/api/templates/disable/PUT.js.map +1 -1
  149. package/dist/kempo/api/templates/enable/PUT.js +1 -1
  150. package/dist/kempo/api/templates/enable/PUT.js.map +1 -1
  151. package/dist/kempo/api/templates/file/GET.js +1 -1
  152. package/dist/kempo/api/templates/file/GET.js.map +1 -1
  153. package/dist/kempo/api/templates/file/PATCH.js +1 -1
  154. package/dist/kempo/api/templates/file/PATCH.js.map +1 -1
  155. package/dist/kempo/api/templates/file/PUT.js +1 -1
  156. package/dist/kempo/api/templates/file/PUT.js.map +1 -1
  157. package/dist/kempo/api/templates/lock/PUT.js +1 -1
  158. package/dist/kempo/api/templates/lock/PUT.js.map +1 -1
  159. package/dist/kempo/components/DashboardWidget.js +4 -4
  160. package/dist/kempo/components/DashboardWidget.js.map +1 -1
  161. package/dist/kempo/icons/campaign.svg +1 -0
  162. package/dist/kempo/icons/fields.svg +1 -0
  163. package/dist/kempo/icons/open_in_new.svg +1 -0
  164. package/dist/kempo/sdk.js +1 -1
  165. package/dist/kempo/sdk.js.map +1 -1
  166. package/middleware/kempo.js +69 -12
  167. package/package.json +3 -4
  168. package/server/db/schema.js +56 -0
  169. package/server/sdk.js +26 -0
  170. package/server/utils/auth/requestPasswordReset.js +9 -3
  171. package/server/utils/auth/verifyEmail.js +10 -1
  172. package/server/utils/dashboard/coreWidgets.js +1 -0
  173. package/server/utils/dashboard/helpers.js +6 -0
  174. package/server/utils/email/defaultSender.js +2 -0
  175. package/server/utils/email/dispatchEmail.js +56 -0
  176. package/server/utils/email/emailSettings.js +21 -0
  177. package/server/utils/email/emailTemplatePaths.js +36 -0
  178. package/server/utils/email/getEmail.js +14 -0
  179. package/server/utils/email/getSenderEmail.js +11 -0
  180. package/server/utils/email/getSenderFrom.js +18 -0
  181. package/server/utils/email/isValidSender.js +4 -0
  182. package/server/utils/email/listEmailTemplates.js +23 -0
  183. package/server/utils/email/listEmails.js +35 -0
  184. package/server/utils/email/maintainEmails.js +46 -0
  185. package/server/utils/email/pruneEmails.js +29 -0
  186. package/server/utils/email/readEmailTemplate.js +28 -0
  187. package/server/utils/email/renderEmailTemplate.js +63 -0
  188. package/server/utils/email/resetEmailTemplate.js +15 -0
  189. package/server/utils/email/retryEmail.js +21 -0
  190. package/server/utils/email/saveEmailTemplate.js +38 -0
  191. package/server/utils/email/sendEmail.js +22 -32
  192. package/server/utils/email/sendEmailFromTemplate.js +9 -27
  193. package/server/utils/extensions/aliases.js +71 -0
  194. package/server/utils/extensions/checkClaims.js +32 -0
  195. package/server/utils/extensions/claims.js +54 -0
  196. package/server/utils/extensions/createTablesFromSchema.js +13 -20
  197. package/server/utils/extensions/enableExtensionTree.js +8 -0
  198. package/server/utils/extensions/installExtension.js +32 -0
  199. package/server/utils/extensions/readKempoConfig.js +2 -2
  200. package/server/utils/extensions/scopeCache.js +14 -1
  201. package/server/utils/extensions/scopes.js +72 -0
  202. package/server/utils/extensions/uninstallExtensionTree.js +2 -1
  203. package/server/utils/extensions/updateExtension.js +35 -0
  204. package/server/utils/global-content/createGlobalContent.js +8 -18
  205. package/server/utils/global-content/deleteGlobalContent.js +1 -16
  206. package/server/utils/global-content/disableGlobalContent.js +1 -16
  207. package/server/utils/global-content/enableGlobalContent.js +1 -16
  208. package/server/utils/global-content/helpers.js +40 -6
  209. package/server/utils/global-content/listGlobalContent.js +2 -2
  210. package/server/utils/global-content/setGlobalContentLocked.js +1 -16
  211. package/server/utils/global-content/updateGlobalContent.js +1 -16
  212. package/server/utils/hooks/triggerHook.js +17 -4
  213. package/server/utils/menus/adoptMenuItem.js +58 -0
  214. package/server/utils/menus/assignMenuLocation.js +47 -0
  215. package/server/utils/menus/deleteMenu.js +27 -0
  216. package/server/utils/menus/deleteMenuItems.js +27 -0
  217. package/server/utils/menus/findMenuLocations.js +41 -0
  218. package/server/utils/menus/findNavItems.js +74 -0
  219. package/server/utils/menus/findPages.js +73 -0
  220. package/server/utils/menus/listMenus.js +103 -0
  221. package/server/utils/menus/markup.js +55 -0
  222. package/server/utils/menus/reorderMenuItems.js +36 -0
  223. package/server/utils/menus/saveMenu.js +54 -0
  224. package/server/utils/menus/saveMenuItem.js +122 -0
  225. package/server/utils/menus/setMenuItemEnabled.js +31 -0
  226. package/server/utils/menus/store.js +90 -0
  227. package/server/utils/scheduler/helpers.js +36 -0
  228. package/server/utils/scheduler/listJobs.js +11 -0
  229. package/server/utils/scheduler/registerJobs.js +37 -0
  230. package/server/utils/scheduler/runJobs.js +90 -0
  231. package/server/utils/scheduler/scheduler.js +37 -0
  232. package/server/utils/scheduler/unregisterJobs.js +16 -0
  233. package/server/utils/settings/ensureEncryptionKey.js +132 -0
  234. package/server/utils/settings/listSettings.js +10 -5
  235. package/server/utils/settings/secretCrypto.js +5 -1
  236. package/server/utils/settings/setSetting.js +4 -0
  237. package/server/utils/settings/settingRules.js +21 -0
  238. package/server/utils/settings/systemSettings.js +40 -0
  239. package/server/utils/setup/seedDatabase.js +5 -60
  240. package/server/utils/setup/systemPermissions.js +75 -0
  241. package/dist/kempo/icons/create_new_folder.svg +0 -1
  242. package/dist/kempo/icons/credit_card.svg +0 -1
  243. package/dist/kempo/icons/login.svg +0 -1
  244. package/dist/kempo/icons/payments.svg +0 -1
  245. package/dist/kempo/icons/toll.svg +0 -1
  246. package/dist/kempo/icons/wallet.svg +0 -1
@@ -1,6 +1,7 @@
1
1
  import db from '../../db/index.js';
2
2
  import { verificationToken, user } from '../../db/schema.js';
3
3
  import { eq, and, gt } from 'drizzle-orm';
4
+ import triggerHook from '../hooks/triggerHook.js';
4
5
 
5
6
  export default async ({ token }) => {
6
7
  if(!token){
@@ -36,7 +37,15 @@ export default async ({ token }) => {
36
37
  .delete(verificationToken)
37
38
  .where(eq(verificationToken.token, token));
38
39
 
39
- return [null, {
40
+ /*
41
+ Someone has just proved they own this address, which is what an extension needs before it hands
42
+ them something that was only ever tied to the address (guest orders, say). Fires
43
+ `user:email_verified` with `{ userId, email }`.
44
+ */
45
+ const [verified] = await db.select({ email: user.email }).from(user).where(eq(user.id, tokenRecord.userId)).limit(1);
46
+ await triggerHook('user:email_verified', { userId: tokenRecord.userId, email: verified?.email ?? null });
47
+
48
+ return [null, {
40
49
  success: true,
41
50
  userId: tokenRecord.userId
42
51
  }];
@@ -4,6 +4,7 @@ const core = widget => ({
4
4
  owner: CORE_OWNER,
5
5
  description: null,
6
6
  icon: null,
7
+ iconSrc: null,
7
8
  defaultWidth: 2,
8
9
  defaultHeight: 1,
9
10
  minWidth: 1,
@@ -24,6 +24,7 @@ export const normalizeWidget = ({
24
24
  title,
25
25
  description = null,
26
26
  icon = null,
27
+ iconSrc = null,
27
28
  element,
28
29
  module,
29
30
  defaultWidth = 2,
@@ -57,6 +58,10 @@ export const normalizeWidget = ({
57
58
  return [{ code: 400, msg: 'Icon must be an icon name' }, null];
58
59
  }
59
60
 
61
+ if(iconSrc !== null && !(isLocalPath(iconSrc) && iconSrc.length <= 200)){
62
+ return [{ code: 400, msg: 'iconSrc must be the path to an icon file on this site, beginning with a single "/"' }, null];
63
+ }
64
+
60
65
  if(typeof element !== 'string' || !ELEMENT_PATTERN.test(element)){
61
66
  return [{ code: 400, msg: 'Element must be a custom element tag name such as "my-ext-widget"' }, null];
62
67
  }
@@ -128,6 +133,7 @@ export const normalizeWidget = ({
128
133
  title: title.trim(),
129
134
  description,
130
135
  icon,
136
+ iconSrc,
131
137
  element,
132
138
  module,
133
139
  defaultWidth,
@@ -0,0 +1,2 @@
1
+ /* The sender kempo uses until the site sets its own. Providers refuse it, so an email extension that knows a better one (the mailbox it signs in with) can use that instead, see `fromIsDefault`. */
2
+ export default 'noreply@example.com';
@@ -0,0 +1,56 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, eq, sql } from 'drizzle-orm';
4
+ import triggerHook from '../hooks/triggerHook.js';
5
+ import emailSettings from './emailSettings.js';
6
+ import defaultSender from './defaultSender.js';
7
+
8
+ export const SEND_HOOK = 'email:send';
9
+
10
+ /*
11
+ One try at sending a queued email.
12
+
13
+ The email is taken (queued -> sending, one conditional UPDATE, so of several processes only one sends it) and the `email:send`
14
+ hook is fired with `{ email }`. Each email extension answers with what it did, and the first to say it sent it ends the chain, so
15
+ with several installed only one sends and a failed one hands the email to the next:
16
+
17
+ export default async ({ email }) => ({ sent: true, provider: 'my-sender', messageId: 'abc' }); // it went
18
+ export default async ({ email }) => ({ sent: false, error: 'Mailbox full' }); // it tried and could not (throwing says the same)
19
+ export default async ({ email }) => ({ sent: false, skipped: true, error: 'No API key is saved' }); // it is not set up, so it did not try
20
+
21
+ `email` is `{ id, to, from, fromIsDefault, subject, html, type, attempt }`; `fromIsDefault` says the site has not set its own sender, so
22
+ `from` is only a placeholder and a sender that knows a better one (the mailbox it signs in with) can use that. A handler sends the email and returns; it never touches the log, which
23
+ kempo keeps. After the chain the email is `sent`, or goes back to `queued` for a retry, or is `failed` when it has no tries left
24
+ (the first try plus `email_max_retries`). Resolves like sendEmail.
25
+ */
26
+ const finish = async (id, changes) => {
27
+ const [row] = await db.update(email).set({ ...changes, updatedAt: new Date() }).where(eq(email.id, id)).returning();
28
+ return row;
29
+ };
30
+
31
+ export default async id => {
32
+ const [claimed] = await db.update(email)
33
+ .set({ status: 'sending', attempts: sql`${email.attempts} + 1`, updatedAt: new Date() })
34
+ .where(and(eq(email.id, id), eq(email.status, 'queued')))
35
+ .returning();
36
+ if(!claimed) return [{ code: 409, msg: 'That email is not waiting to be sent', emailId: id }, null];
37
+
38
+ const [hookError, fired] = await triggerHook(SEND_HOOK, { email: { id: claimed.id, to: claimed.recipient, from: claimed.sender, fromIsDefault: claimed.sender === defaultSender, subject: claimed.subject, html: claimed.html, type: claimed.type, attempt: claimed.attempts } }, { stopWhen: result => result?.sent === true });
39
+ const results = fired?.results ?? [];
40
+ const sent = results.find(entry => entry.result?.sent === true);
41
+
42
+ if(sent){
43
+ await finish(id, { status: 'sent', sentAt: new Date(), lastError: null, provider: String(sent.result.provider ?? sent.owner).slice(0, 100), providerRef: sent.result.messageId ? String(sent.result.messageId).slice(0, 200) : null });
44
+ return [null, { id, status: 'sent', provider: sent.result.provider ?? sent.owner }];
45
+ }
46
+
47
+ /* An extension that is not set up says so and is not counted as having tried: its reason is only shown when nothing else did. */
48
+ const said = entry => `${entry.owner}: ${entry.error ?? entry.result?.error ?? 'did not send it'}`;
49
+ const tried = results.filter(entry => !entry.result?.skipped);
50
+ const reasons = hookError ? [hookError.msg] : (tried.length ? tried : results).map(said);
51
+ const message = (tried.length || hookError ? reasons.join('; ') : results.length ? `No email extension is set up yet. ${reasons.join('; ')}` : '') || 'No email extension is installed to send email (install kempo-resend or kempo-smtp)';
52
+ const { maxRetries } = await emailSettings();
53
+ const final = claimed.attempts >= 1 + maxRetries;
54
+ await finish(id, { status: final ? 'failed' : 'queued', lastError: message.slice(0, 1000) });
55
+ return [{ code: tried.length ? 502 : 503, msg: message, emailId: id }, null];
56
+ };
@@ -0,0 +1,21 @@
1
+ import getSetting from '../settings/getSetting.js';
2
+
3
+ const DEFAULT_RETRIES = 1;
4
+ const DEFAULT_RETENTION_DAYS = 30;
5
+
6
+ const whole = (value, fallback) => (Number.isInteger(Number(value)) && value !== '' && value !== null && Number(value) >= 0 ? Number(value) : fallback);
7
+
8
+ /*
9
+ What the email settings say, with a safe value for anything unreadable: `maxRetries` (tries after the first), `retentionDays` (0 keeps
10
+ forever) and `retentionByType` ({ type: days }), which wins over `retentionDays` for that kind of email.
11
+ */
12
+ export default async () => {
13
+ const [, retries] = await getSetting('system', 'email_max_retries', DEFAULT_RETRIES);
14
+ const [, days] = await getSetting('system', 'email_retention_days', DEFAULT_RETENTION_DAYS);
15
+ const [, byType] = await getSetting('system', 'email_retention_by_type', {});
16
+ const retentionByType = {};
17
+ if(byType && typeof byType === 'object' && !Array.isArray(byType)){
18
+ for(const [type, value] of Object.entries(byType)) if(whole(value, null) !== null) retentionByType[type] = Number(value);
19
+ }
20
+ return { maxRetries: whole(retries, DEFAULT_RETRIES), retentionDays: whole(days, DEFAULT_RETENTION_DAYS), retentionByType };
21
+ };
@@ -0,0 +1,36 @@
1
+ import { join, dirname } from 'path';
2
+ import { fileURLToPath } from 'url';
3
+
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ const PART = '[a-z0-9][a-z0-9-]*';
6
+ const NAME = new RegExp(`^(?:(${PART})/)?(${PART})$`);
7
+
8
+ /*
9
+ Where an email template lives, the same places renderEmailTemplate looks:
10
+
11
+ "owner/name" the extension's own `node_modules/owner/emails/name.page.html`, which the site overrides at
12
+ `templates/emails/owner/name.page.html`
13
+ "name" kempo's own `app-templates/emails/name.page.html`, which the site overrides at
14
+ `templates/emails/name.page.html`
15
+ */
16
+ export const parseTemplateName = template => {
17
+ const match = typeof template === 'string' ? NAME.exec(template) : null;
18
+ return match ? { owner: match[1] ?? null, name: match[2] } : null;
19
+ };
20
+
21
+ export const siteEmailsDir = owner => (owner ? join(process.cwd(), 'templates/emails', owner) : join(process.cwd(), 'templates/emails'));
22
+
23
+ export const defaultEmailsDir = owner => (owner ? join(process.cwd(), 'node_modules', owner, 'emails') : join(here, '../../../app-templates/emails'));
24
+
25
+ export const locateTemplate = template => {
26
+ const parsed = parseTemplateName(template);
27
+ if(!parsed) return null;
28
+ return { ...parsed, template, sitePath: join(siteEmailsDir(parsed.owner), `${parsed.name}.page.html`), defaultPath: join(defaultEmailsDir(parsed.owner), `${parsed.name}.page.html`) };
29
+ };
30
+
31
+ export const isOwnerName = owner => new RegExp(`^${PART}$`).test(owner);
32
+
33
+ /* The page's `title`, which is what a list shows as the email's name. */
34
+ export const titleOf = source => /<page\b[^>]*\stitle="([^"]*)"/.exec(source)?.[1] ?? '';
35
+
36
+ export const sameSource = (a, b) => String(a).replace(/\r\n/g, '\n').trim() === String(b).replace(/\r\n/g, '\n').trim();
@@ -0,0 +1,14 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { eq } from 'drizzle-orm';
4
+
5
+ /* One email of the log, with its whole body. Resolves to [error, email]. */
6
+ export default async id => {
7
+ if(!id) return [{ code: 400, msg: 'An email id is required' }, null];
8
+ try {
9
+ const [row] = await db.select().from(email).where(eq(email.id, String(id))).limit(1);
10
+ return row ? [null, row] : [{ code: 404, msg: 'Email not found' }, null];
11
+ } catch {
12
+ return [{ code: 500, msg: 'Failed to read the email' }, null];
13
+ }
14
+ };
@@ -0,0 +1,11 @@
1
+ import getSenderFrom from './getSenderFrom.js';
2
+
3
+ /*
4
+ The bare address kempo sends its emails from (`shop@example.com`, out of `Shop <shop@example.com>`), for an extension that needs
5
+ one for the site, such as a shipping label's sender. It is the same address emails go out from (see getSenderFrom), so it is a
6
+ placeholder (noreply@example.com) until the site sets one. Resolves to the address, or null if there is none to find.
7
+ */
8
+ export default async () => {
9
+ const from = (await getSenderFrom()).trim();
10
+ return (/<([^<>\s]+@[^<>\s]+)>/.exec(from) ?? /^([^<>\s]+@[^<>\s]+)$/.exec(from))?.[1] ?? null;
11
+ };
@@ -0,0 +1,18 @@
1
+ import getSetting from '../settings/getSetting.js';
2
+ import isValidSender from './isValidSender.js';
3
+ import PLACEHOLDER from './defaultSender.js';
4
+
5
+ /*
6
+ What kempo sends its emails from, as the email provider takes it. In order:
7
+
8
+ the `system:email_from` setting edited on the admin Settings screen (it must be a valid address, or empty)
9
+ `EMAIL_FROM` the server environment; what the README and `kempo init` write
10
+ `SMTP_FROM` what the environment used to be called
11
+ a placeholder address (noreply@example.com), which most providers will refuse until the site sets its own
12
+
13
+ A value that is not an address is skipped rather than sent, so a bad environment value cannot stop every email.
14
+ */
15
+ export default async () => {
16
+ const [, stored] = await getSetting('system', 'email_from', '');
17
+ return [stored, process.env.EMAIL_FROM, process.env.SMTP_FROM].map(value => String(value ?? '').trim()).find(isValidSender) ?? PLACEHOLDER;
18
+ };
@@ -0,0 +1,4 @@
1
+ /* Whether text can be the address emails are sent from: `name@example.com` or `Shop Name <name@example.com>`, on one line. */
2
+ export default value => typeof value === 'string'
3
+ && value.length <= 200
4
+ && /^(?:[^<>\r\n@]+<[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+>|[^<>\s@]+@[^<>\s@]+\.[^<>\s@]+)$/.test(value.trim());
@@ -0,0 +1,23 @@
1
+ import { readdir } from 'fs/promises';
2
+ import { defaultEmailsDir, isOwnerName, siteEmailsDir } from './emailTemplatePaths.js';
3
+ import readEmailTemplate from './readEmailTemplate.js';
4
+
5
+ const SUFFIX = '.page.html';
6
+
7
+ const namesIn = async dir => (await readdir(dir).catch(() => [])).filter(file => file.endsWith(SUFFIX)).map(file => file.slice(0, -SUFFIX.length));
8
+
9
+ /*
10
+ The email templates of one extension (`owner`), or kempo's own and the site's (no owner): the packaged ones, and any the site has
11
+ added. Resolves to [error, [{ template, name, title, overridden, packaged }]] sorted by name.
12
+ */
13
+ export default async ({ owner = null } = {}) => {
14
+ if(owner !== null && !isOwnerName(owner)) return [{ code: 400, msg: 'Invalid extension name' }, null];
15
+
16
+ const names = [...new Set([...await namesIn(defaultEmailsDir(owner)), ...await namesIn(siteEmailsDir(owner))])].sort();
17
+ const templates = [];
18
+ for(const name of names){
19
+ const [error, found] = await readEmailTemplate({ template: owner ? `${owner}/${name}` : name });
20
+ if(!error) templates.push({ template: found.template, name, title: found.title, overridden: found.overridden, packaged: found.defaultSource !== null });
21
+ }
22
+ return [null, templates];
23
+ };
@@ -0,0 +1,35 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
4
+
5
+ const STATUSES = ['queued', 'sending', 'sent', 'failed'];
6
+
7
+ /*
8
+ The log, newest first, without the bodies (read one with getEmail). `status`, `type` and `search` (a part of the recipient or the
9
+ subject) narrow it. Resolves to [error, { emails, total, limit, offset }].
10
+ */
11
+ export default async ({ status, type, search, limit = 50, offset = 0 } = {}) => {
12
+ try {
13
+ const conditions = [];
14
+ if(status){
15
+ if(!STATUSES.includes(status)) return [{ code: 400, msg: `Status must be one of ${STATUSES.join(', ')}` }, null];
16
+ conditions.push(eq(email.status, status));
17
+ }
18
+ if(type) conditions.push(eq(email.type, String(type)));
19
+ if(search){
20
+ const like = `%${String(search).replace(/[\\%_]/g, '\\$&')}%`;
21
+ conditions.push(or(ilike(email.recipient, like), ilike(email.subject, like)));
22
+ }
23
+ const where = conditions.length ? and(...conditions) : undefined;
24
+ const take = Math.min(Math.max(Number(limit) || 50, 1), 200);
25
+ const skip = Math.max(Number(offset) || 0, 0);
26
+ const emails = await db.select({
27
+ id: email.id, type: email.type, recipient: email.recipient, sender: email.sender, subject: email.subject, status: email.status, attempts: email.attempts,
28
+ lastError: email.lastError, provider: email.provider, createdAt: email.createdAt, updatedAt: email.updatedAt, sentAt: email.sentAt,
29
+ }).from(email).where(where).orderBy(desc(email.createdAt)).limit(take).offset(skip);
30
+ const [{ total }] = await db.select({ total: sql`count(*)::int` }).from(email).where(where);
31
+ return [null, { emails, total, limit: take, offset: skip }];
32
+ } catch {
33
+ return [{ code: 500, msg: 'Failed to read the email log' }, null];
34
+ }
35
+ };
@@ -0,0 +1,46 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, asc, eq, lt, or, gt, sql } from 'drizzle-orm';
4
+ import dispatchEmail from './dispatchEmail.js';
5
+ import emailSettings from './emailSettings.js';
6
+ import pruneEmails from './pruneEmails.js';
7
+
8
+ const MINUTE_MS = 60 * 1000;
9
+ const STUCK_MS = 5 * MINUTE_MS;
10
+ const BACKOFF_MS = MINUTE_MS;
11
+ const TOO_OLD_MS = 24 * 60 * MINUTE_MS;
12
+ const BATCH = 50;
13
+
14
+ /*
15
+ The email job, run every minute by the scheduler (kempo registers it itself):
16
+
17
+ - a `sending` email that has not finished in five minutes (the process died) goes back to `queued`;
18
+ - a `queued` email with tries left is sent again, a minute after its last try at the earliest, up to 50 at a time;
19
+ - a `queued` email older than a day is marked `failed` instead, so a password-reset link is never sent a day late;
20
+ - emails older than the retention settings are deleted.
21
+ */
22
+ export default async () => {
23
+ const now = Date.now();
24
+ const { maxRetries } = await emailSettings();
25
+
26
+ await db.update(email)
27
+ .set({ status: 'queued', lastError: 'The send did not finish' })
28
+ .where(and(eq(email.status, 'sending'), lt(email.updatedAt, new Date(now - STUCK_MS))));
29
+
30
+ await db.update(email)
31
+ .set({ status: 'failed', lastError: sql`coalesce(${email.lastError}, 'Never sent') || ' (too old to send now)'`, updatedAt: new Date() })
32
+ .where(and(eq(email.status, 'queued'), lt(email.createdAt, new Date(now - TOO_OLD_MS))));
33
+
34
+ const due = await db.select({ id: email.id }).from(email)
35
+ .where(and(
36
+ eq(email.status, 'queued'),
37
+ gt(email.createdAt, new Date(now - TOO_OLD_MS)),
38
+ lt(email.attempts, 1 + maxRetries),
39
+ or(eq(email.attempts, 0), lt(email.updatedAt, new Date(now - BACKOFF_MS))),
40
+ ))
41
+ .orderBy(asc(email.createdAt))
42
+ .limit(BATCH);
43
+ for(const { id } of due) await dispatchEmail(id);
44
+
45
+ await pruneEmails();
46
+ };
@@ -0,0 +1,29 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, lt, eq, notInArray } from 'drizzle-orm';
4
+ import emailSettings from './emailSettings.js';
5
+
6
+ const DAY_MS = 24 * 60 * 60 * 1000;
7
+
8
+ /*
9
+ Deletes the emails that are older than the settings keep them. A kind of email in `email_retention_by_type` is kept that many days,
10
+ every other kind `email_retention_days`; 0 keeps it forever. Resolves to [error, { deleted }].
11
+ */
12
+ export default async ({ now = new Date() } = {}) => {
13
+ try {
14
+ const { retentionDays, retentionByType } = await emailSettings();
15
+ const specific = Object.keys(retentionByType);
16
+ let deleted = 0;
17
+ for(const [type, days] of Object.entries(retentionByType)){
18
+ if(days === 0) continue;
19
+ deleted += (await db.delete(email).where(and(eq(email.type, type), lt(email.createdAt, new Date(now.getTime() - days * DAY_MS)))).returning({ id: email.id })).length;
20
+ }
21
+ if(retentionDays > 0){
22
+ const older = lt(email.createdAt, new Date(now.getTime() - retentionDays * DAY_MS));
23
+ deleted += (await db.delete(email).where(specific.length ? and(notInArray(email.type, specific), older) : older).returning({ id: email.id })).length;
24
+ }
25
+ return [null, { deleted }];
26
+ } catch {
27
+ return [{ code: 500, msg: 'Failed to delete old emails' }, null];
28
+ }
29
+ };
@@ -0,0 +1,28 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { locateTemplate, sameSource, titleOf } from './emailTemplatePaths.js';
3
+
4
+ const read = path => readFile(path, 'utf8').catch(() => null);
5
+
6
+ /*
7
+ One email template as the site would send it: `source` is the site's own version when it has one and the packaged one
8
+ otherwise, `defaultSource` is the packaged one (null when there is none), and `overridden` says the site's differs from it.
9
+ Resolves to [error, { template, owner, name, title, source, defaultSource, overridden }].
10
+ */
11
+ export default async ({ template } = {}) => {
12
+ const located = locateTemplate(template);
13
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
14
+
15
+ const [site, packaged] = await Promise.all([read(located.sitePath), read(located.defaultPath)]);
16
+ if(site === null && packaged === null) return [{ code: 404, msg: `Email template not found: ${template}` }, null];
17
+
18
+ const source = site ?? packaged;
19
+ return [null, {
20
+ template,
21
+ owner: located.owner,
22
+ name: located.name,
23
+ title: titleOf(source),
24
+ source,
25
+ defaultSource: packaged,
26
+ overridden: site !== null && packaged !== null && !sameSource(site, packaged),
27
+ }];
28
+ };
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'fs';
2
+ import { fileURLToPath } from 'url';
3
+ import { dirname, join } from 'path';
4
+ import { renderPage, renderExternalPage } from 'kempo-server/templating';
5
+ import shake from 'kempo-css/shake';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ /*
10
+ Renders an email template to HTML, without sending it.
11
+
12
+ A plain name ("verify-email") is looked up in the site's `templates/emails`, then in kempo's own.
13
+
14
+ A name with an owner ("my-extension/order-paid") is an extension's template. The site may override
15
+ it at `templates/emails/my-extension/order-paid.page.html`; otherwise it is the extension's own
16
+ `emails/order-paid.page.html`. Either way the layout (`<page template="email">`) is the site's
17
+ `templates/emails/email.template.html` when it has one, and kempo's otherwise, so every email a site
18
+ sends looks the same.
19
+
20
+ Returns [error, html].
21
+ */
22
+ export default async ({ template, data = {} } = {}) => {
23
+ if(!template){
24
+ return [{ code: 400, msg: 'Template name is required' }, null];
25
+ }
26
+ if(!/^[a-z0-9][a-z0-9-]*(\/[a-z0-9][a-z0-9-]*)?$/.test(template)){
27
+ return [{ code: 400, msg: `Invalid email template name: ${template}` }, null];
28
+ }
29
+
30
+ const consumerDir = join(process.cwd(), 'templates/emails');
31
+ const moduleDir = join(__dirname, '../../../app-templates/emails');
32
+ const [owner, name] = template.includes('/') ? template.split('/') : [null, template];
33
+
34
+ let pagePath;
35
+ let rootDir;
36
+ if(owner){
37
+ const override = join(consumerDir, owner, `${name}.page.html`);
38
+ pagePath = existsSync(override) ? override : join(process.cwd(), 'node_modules', owner, 'emails', `${name}.page.html`);
39
+ rootDir = existsSync(join(consumerDir, 'email.template.html')) ? consumerDir : moduleDir;
40
+ } else {
41
+ rootDir = existsSync(join(consumerDir, `${name}.page.html`)) ? consumerDir : moduleDir;
42
+ pagePath = join(rootDir, `${name}.page.html`);
43
+ }
44
+
45
+ if(!existsSync(pagePath)){
46
+ return [{ code: 404, msg: `Email template not found: ${template}` }, null];
47
+ }
48
+
49
+ const themePath = join(process.cwd(), 'public/styles.css');
50
+ const themeOpts = existsSync(themePath) ? { theme: themePath } : {};
51
+
52
+ try {
53
+ let html = owner
54
+ ? await renderExternalPage(pagePath, rootDir, rootDir, {}, data)
55
+ : await renderPage(pagePath, rootDir, {}, data);
56
+ const css = shake(html, { ...themeOpts, colorMode: 'light' });
57
+ html = html.replace('</head>', `<style>${css}</style></head>`);
58
+ return [null, html];
59
+ } catch(error){
60
+ console.error('Email render error:', error);
61
+ return [{ code: 500, msg: 'Failed to render the email template' }, null];
62
+ }
63
+ };
@@ -0,0 +1,15 @@
1
+ import { readFile, rm } from 'fs/promises';
2
+ import { locateTemplate } from './emailTemplatePaths.js';
3
+
4
+ /* Removes the site's version of an email template so the packaged one is used again. Resolves to [error, { template }]. */
5
+ export default async ({ template } = {}) => {
6
+ const located = locateTemplate(template);
7
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
8
+ if(await readFile(located.defaultPath, 'utf8').catch(() => null) === null) return [{ code: 409, msg: 'There is no packaged version to go back to' }, null];
9
+ try {
10
+ await rm(located.sitePath, { force: true });
11
+ return [null, { template }];
12
+ } catch {
13
+ return [{ code: 500, msg: 'The template could not be reset' }, null];
14
+ }
15
+ };
@@ -0,0 +1,21 @@
1
+ import db from '../../db/index.js';
2
+ import { email } from '../../db/schema.js';
3
+ import { and, eq, inArray } from 'drizzle-orm';
4
+ import dispatchEmail from './dispatchEmail.js';
5
+
6
+ /*
7
+ Tries an email that did not go again, as if it were new: it gets the full number of tries once more. Only a `queued` or `failed`
8
+ email can be tried again (one that is `sent` or being sent is left alone). Resolves like sendEmail.
9
+ */
10
+ export default async id => {
11
+ try {
12
+ const [row] = await db.update(email)
13
+ .set({ status: 'queued', attempts: 0, lastError: null, updatedAt: new Date() })
14
+ .where(and(eq(email.id, String(id ?? '')), inArray(email.status, ['queued', 'failed'])))
15
+ .returning({ id: email.id });
16
+ if(!row) return [{ code: 409, msg: 'Only an email that is waiting or failed can be tried again' }, null];
17
+ } catch {
18
+ return [{ code: 500, msg: 'Failed to retry the email' }, null];
19
+ }
20
+ return dispatchEmail(String(id));
21
+ };
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
2
+ import { dirname } from 'path';
3
+ import { locateTemplate } from './emailTemplatePaths.js';
4
+ import renderEmailTemplate from './renderEmailTemplate.js';
5
+
6
+ const MAX_SOURCE = 100000;
7
+
8
+ /*
9
+ The site's own version of an email template, written to `templates/emails/` so it is used from the next email on (the packaged
10
+ one is never touched). Only a template that exists can be saved, so this cannot make up new names. The new version is rendered
11
+ once before it is kept, and the old one is put back when it does not render, so a typo cannot stop the site sending that email.
12
+ Resolves to [error, { template }].
13
+ */
14
+ export default async ({ template, source } = {}) => {
15
+ const located = locateTemplate(template);
16
+ if(!located) return [{ code: 400, msg: 'Invalid email template name' }, null];
17
+ if(typeof source !== 'string' || !source.trim()) return [{ code: 400, msg: 'The template cannot be empty' }, null];
18
+ if(source.length > MAX_SOURCE) return [{ code: 400, msg: `The template can be at most ${MAX_SOURCE} characters` }, null];
19
+ if(source.includes('\u0000')) return [{ code: 400, msg: 'The template cannot contain null characters' }, null];
20
+
21
+ const [site, packaged] = await Promise.all([readFile(located.sitePath, 'utf8').catch(() => null), readFile(located.defaultPath, 'utf8').catch(() => null)]);
22
+ if(site === null && packaged === null) return [{ code: 404, msg: `Email template not found: ${template}` }, null];
23
+
24
+ try {
25
+ await mkdir(dirname(located.sitePath), { recursive: true });
26
+ await writeFile(located.sitePath, source);
27
+ } catch {
28
+ return [{ code: 500, msg: 'The template could not be saved' }, null];
29
+ }
30
+
31
+ const [renderError] = await renderEmailTemplate({ template, data: {} });
32
+ if(renderError){
33
+ if(site === null) await rm(located.sitePath, { force: true });
34
+ else await writeFile(located.sitePath, site);
35
+ return [{ code: 400, msg: `The template does not render, so it was not saved: ${renderError.msg}` }, null];
36
+ }
37
+ return [null, { template }];
38
+ };
@@ -1,48 +1,38 @@
1
- import { Resend } from 'resend';
1
+ import crypto from 'crypto';
2
+ import db from '../../db/index.js';
3
+ import { email } from '../../db/schema.js';
4
+ import getSenderFrom from './getSenderFrom.js';
5
+ import dispatchEmail from './dispatchEmail.js';
2
6
 
3
7
  /*
4
- Constructed on first send, not at import. The Resend constructor throws when the key is missing,
5
- and this module is reachable from the server SDK, so building it at module scope made an unset
6
- RESEND_API_KEY fail every route that transitively imports it rather than just disabling email.
7
- */
8
- let resend = null;
9
- const client = () => (resend ??= new Resend(process.env.RESEND_API_KEY));
10
-
11
- export default async ({ to, subject, html }) => {
12
- if(!process.env.RESEND_API_KEY){
13
- return [{ code: 500, msg: 'RESEND_API_KEY not configured' }, null];
14
- }
8
+ Asks for an email to be sent. Kempo does not send email itself: this saves the email in the log (the `email` table) and hands it to
9
+ whichever email extension is installed (kempo-resend, kempo-smtp...) through the `email:send` hook; see dispatchEmail.js.
15
10
 
11
+ `template` is the name of the template the email was made from, if any, and is its type in the log. It resolves to
12
+ `[null, { id, status: 'sent' }]` when a sender sent it, or `[{ code, msg, emailId }, null]` when none did: 503 when there is no email
13
+ extension installed, 502 when one tried and failed. Either way the email is in the log, and a failed one is tried again (see the
14
+ `email_max_retries` setting).
15
+ */
16
+ export default async ({ to, subject, html, template = null } = {}) => {
16
17
  if(!to){
17
18
  return [{ code: 400, msg: 'Recipient email is required' }, null];
18
19
  }
19
-
20
+
20
21
  if(!subject){
21
22
  return [{ code: 400, msg: 'Email subject is required' }, null];
22
23
  }
23
-
24
+
24
25
  if(!html){
25
26
  return [{ code: 400, msg: 'Email content is required' }, null];
26
27
  }
27
28
 
28
- const from = process.env.SMTP_FROM || 'onboarding@resend.dev';
29
-
29
+ const id = crypto.randomUUID();
30
+ const now = new Date();
30
31
  try {
31
- const { data, error } = await client().emails.send({
32
- from,
33
- to,
34
- subject,
35
- html,
36
- });
37
-
38
- if(error){
39
- console.error('Email send error:', error);
40
- return [{ code: 500, msg: error.message || 'Failed to send email' }, null];
41
- }
42
-
43
- return [null, { success: true, messageId: data.id }];
44
- } catch(error){
45
- console.error('Email send error:', error);
46
- return [{ code: 500, msg: 'Failed to send email' }, null];
32
+ await db.insert(email).values({ id, type: template || 'other', recipient: String(to), sender: await getSenderFrom(), subject: String(subject), html: String(html), status: 'queued', attempts: 0, createdAt: now, updatedAt: now });
33
+ } catch(error) {
34
+ return [{ code: 500, msg: 'The email could not be saved to send' }, null];
47
35
  }
36
+
37
+ return dispatchEmail(id);
48
38
  };