arcway 0.1.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +711 -0
  3. package/client/env.js +55 -0
  4. package/client/fetcher.js +50 -0
  5. package/client/graphql.js +35 -0
  6. package/client/head.js +140 -0
  7. package/client/hooks/use-api.js +80 -0
  8. package/client/hooks/use-debounce.js +12 -0
  9. package/client/hooks/use-form.js +86 -0
  10. package/client/hooks/use-graphql.js +30 -0
  11. package/client/hooks/use-interval.js +12 -0
  12. package/client/hooks/use-mutation.js +27 -0
  13. package/client/hooks/use-query.js +45 -0
  14. package/client/hooks/web/use-click-outside.js +22 -0
  15. package/client/hooks/web/use-local-storage.js +42 -0
  16. package/client/index.js +62 -0
  17. package/client/page-loader.js +155 -0
  18. package/client/provider.js +53 -0
  19. package/client/query.js +13 -0
  20. package/client/router.jsx +303 -0
  21. package/client/ui/accordion.jsx +65 -0
  22. package/client/ui/accordion.stories.jsx +48 -0
  23. package/client/ui/alert-dialog.jsx +122 -0
  24. package/client/ui/alert-dialog.stories.jsx +44 -0
  25. package/client/ui/alert.jsx +52 -0
  26. package/client/ui/alert.stories.jsx +31 -0
  27. package/client/ui/app-shell.jsx +39 -0
  28. package/client/ui/app-shell.stories.jsx +51 -0
  29. package/client/ui/aspect-ratio.jsx +6 -0
  30. package/client/ui/aspect-ratio.stories.jsx +69 -0
  31. package/client/ui/avatar.jsx +78 -0
  32. package/client/ui/avatar.stories.jsx +62 -0
  33. package/client/ui/badge.jsx +34 -0
  34. package/client/ui/badge.stories.js +32 -0
  35. package/client/ui/breadcrumb.jsx +86 -0
  36. package/client/ui/breadcrumb.stories.jsx +43 -0
  37. package/client/ui/button-group.jsx +58 -0
  38. package/client/ui/button-group.stories.jsx +67 -0
  39. package/client/ui/button.jsx +46 -0
  40. package/client/ui/button.stories.js +72 -0
  41. package/client/ui/calendar.jsx +172 -0
  42. package/client/ui/card.jsx +57 -0
  43. package/client/ui/card.stories.jsx +33 -0
  44. package/client/ui/carousel.jsx +167 -0
  45. package/client/ui/chart.jsx +244 -0
  46. package/client/ui/checkbox.jsx +24 -0
  47. package/client/ui/checkbox.stories.js +33 -0
  48. package/client/ui/collapsible.jsx +12 -0
  49. package/client/ui/collapsible.stories.jsx +42 -0
  50. package/client/ui/combobox.jsx +223 -0
  51. package/client/ui/command.jsx +128 -0
  52. package/client/ui/context-menu.jsx +170 -0
  53. package/client/ui/context-menu.stories.jsx +35 -0
  54. package/client/ui/dialog.jsx +109 -0
  55. package/client/ui/dialog.stories.jsx +37 -0
  56. package/client/ui/direction.jsx +9 -0
  57. package/client/ui/drawer.jsx +87 -0
  58. package/client/ui/dropdown-menu.jsx +172 -0
  59. package/client/ui/dropdown-menu.stories.jsx +34 -0
  60. package/client/ui/empty.jsx +76 -0
  61. package/client/ui/empty.stories.jsx +64 -0
  62. package/client/ui/field.jsx +174 -0
  63. package/client/ui/field.stories.jsx +118 -0
  64. package/client/ui/form.jsx +17 -0
  65. package/client/ui/hooks/use-mobile.js +16 -0
  66. package/client/ui/hover-card.jsx +26 -0
  67. package/client/ui/hover-card.stories.jsx +28 -0
  68. package/client/ui/index.js +649 -0
  69. package/client/ui/input-group.jsx +116 -0
  70. package/client/ui/input-group.stories.jsx +65 -0
  71. package/client/ui/input-otp.jsx +62 -0
  72. package/client/ui/input.jsx +16 -0
  73. package/client/ui/input.stories.js +27 -0
  74. package/client/ui/item.jsx +155 -0
  75. package/client/ui/item.stories.jsx +118 -0
  76. package/client/ui/kbd.jsx +24 -0
  77. package/client/ui/kbd.stories.jsx +32 -0
  78. package/client/ui/label.jsx +16 -0
  79. package/client/ui/label.stories.js +25 -0
  80. package/client/ui/lib/utils.js +6 -0
  81. package/client/ui/main-content.jsx +30 -0
  82. package/client/ui/menubar.jsx +189 -0
  83. package/client/ui/menubar.stories.jsx +43 -0
  84. package/client/ui/native-select.jsx +34 -0
  85. package/client/ui/native-select.stories.jsx +67 -0
  86. package/client/ui/navigation-menu.jsx +120 -0
  87. package/client/ui/navigation-menu.stories.jsx +45 -0
  88. package/client/ui/pagination.jsx +92 -0
  89. package/client/ui/pagination.stories.jsx +52 -0
  90. package/client/ui/panel.jsx +66 -0
  91. package/client/ui/popover.jsx +54 -0
  92. package/client/ui/popover.stories.jsx +27 -0
  93. package/client/ui/progress.jsx +19 -0
  94. package/client/ui/progress.stories.js +34 -0
  95. package/client/ui/radio-group.jsx +33 -0
  96. package/client/ui/radio-group.stories.jsx +49 -0
  97. package/client/ui/resizable.jsx +33 -0
  98. package/client/ui/scroll-area.jsx +41 -0
  99. package/client/ui/scroll-area.stories.jsx +43 -0
  100. package/client/ui/select.jsx +145 -0
  101. package/client/ui/select.stories.jsx +80 -0
  102. package/client/ui/separator.jsx +18 -0
  103. package/client/ui/separator.stories.jsx +37 -0
  104. package/client/ui/sheet.jsx +95 -0
  105. package/client/ui/sheet.stories.jsx +56 -0
  106. package/client/ui/sidebar.jsx +544 -0
  107. package/client/ui/skeleton.jsx +8 -0
  108. package/client/ui/skeleton.stories.js +23 -0
  109. package/client/ui/slider.jsx +41 -0
  110. package/client/ui/slider.stories.js +31 -0
  111. package/client/ui/sonner.jsx +37 -0
  112. package/client/ui/spinner.jsx +14 -0
  113. package/client/ui/spinner.stories.js +16 -0
  114. package/client/ui/style-mira.css +1316 -0
  115. package/client/ui/switch.jsx +22 -0
  116. package/client/ui/switch.stories.js +44 -0
  117. package/client/ui/table.jsx +33 -0
  118. package/client/ui/table.stories.jsx +42 -0
  119. package/client/ui/tabs.jsx +63 -0
  120. package/client/ui/tabs.stories.jsx +45 -0
  121. package/client/ui/textarea.jsx +15 -0
  122. package/client/ui/textarea.stories.js +33 -0
  123. package/client/ui/theme.css +459 -0
  124. package/client/ui/toggle-group.jsx +62 -0
  125. package/client/ui/toggle-group.stories.jsx +68 -0
  126. package/client/ui/toggle.jsx +34 -0
  127. package/client/ui/toggle.stories.js +46 -0
  128. package/client/ui/tooltip.jsx +37 -0
  129. package/client/ui/tooltip.stories.jsx +32 -0
  130. package/client/ui/use-transition.js +35 -0
  131. package/client/ws.js +132 -0
  132. package/package.json +134 -0
  133. package/server/bin/cli.js +42 -0
  134. package/server/bin/commands/build.js +23 -0
  135. package/server/bin/commands/dev.js +57 -0
  136. package/server/bin/commands/docs.js +30 -0
  137. package/server/bin/commands/graphql-schema.js +32 -0
  138. package/server/bin/commands/lint.js +35 -0
  139. package/server/bin/commands/mcp.js +26 -0
  140. package/server/bin/commands/migrate.js +82 -0
  141. package/server/bin/commands/schema.js +41 -0
  142. package/server/bin/commands/seed.js +36 -0
  143. package/server/bin/commands/start.js +31 -0
  144. package/server/bin/commands/test.js +20 -0
  145. package/server/bin/solo.js +4 -0
  146. package/server/boot/index.js +150 -0
  147. package/server/boot.js +2 -0
  148. package/server/build.js +23 -0
  149. package/server/cache/drivers/memory.js +23 -0
  150. package/server/cache/drivers/redis.js +28 -0
  151. package/server/cache/index.js +69 -0
  152. package/server/config/loader.js +89 -0
  153. package/server/config/modules/api.js +17 -0
  154. package/server/config/modules/build.js +9 -0
  155. package/server/config/modules/cache.js +10 -0
  156. package/server/config/modules/database.js +29 -0
  157. package/server/config/modules/events.js +15 -0
  158. package/server/config/modules/files.js +15 -0
  159. package/server/config/modules/jobs.js +20 -0
  160. package/server/config/modules/logger.js +9 -0
  161. package/server/config/modules/mail.js +11 -0
  162. package/server/config/modules/mcp.js +9 -0
  163. package/server/config/modules/pages.js +20 -0
  164. package/server/config/modules/queue.js +10 -0
  165. package/server/config/modules/redis.js +9 -0
  166. package/server/config/modules/server.js +30 -0
  167. package/server/config/modules/session.js +9 -0
  168. package/server/config/modules/websocket.js +11 -0
  169. package/server/constants.js +67 -0
  170. package/server/context.js +15 -0
  171. package/server/db/index.js +87 -0
  172. package/server/db/schema/drivers/mysql.js +28 -0
  173. package/server/db/schema/drivers/pg.js +34 -0
  174. package/server/db/schema/drivers/sqlite.js +22 -0
  175. package/server/db/schema/index.js +78 -0
  176. package/server/db/seeds.js +22 -0
  177. package/server/discovery.js +67 -0
  178. package/server/docs/openapi.js +153 -0
  179. package/server/env.js +17 -0
  180. package/server/events/drivers/memory.js +45 -0
  181. package/server/events/drivers/redis.js +64 -0
  182. package/server/events/handler.js +67 -0
  183. package/server/events/index.js +35 -0
  184. package/server/events/pattern.js +5 -0
  185. package/server/files/drivers/local.js +83 -0
  186. package/server/files/drivers/s3.js +113 -0
  187. package/server/files/index.js +57 -0
  188. package/server/filewatcher/index.js +156 -0
  189. package/server/glob.js +6 -0
  190. package/server/graphql/discovery.js +70 -0
  191. package/server/graphql/handler.js +41 -0
  192. package/server/graphql/index.js +13 -0
  193. package/server/graphql/loaders.js +19 -0
  194. package/server/graphql/merge.js +48 -0
  195. package/server/graphql/subscriptions.js +43 -0
  196. package/server/health.js +34 -0
  197. package/server/helpers.js +9 -0
  198. package/server/index.js +55 -0
  199. package/server/internals.js +139 -0
  200. package/server/jobs/cron.js +10 -0
  201. package/server/jobs/drivers/knex-queue.js +207 -0
  202. package/server/jobs/drivers/lease.js +148 -0
  203. package/server/jobs/drivers/memory-queue.js +134 -0
  204. package/server/jobs/queue.js +27 -0
  205. package/server/jobs/runner.js +197 -0
  206. package/server/jobs/throughput.js +63 -0
  207. package/server/lib/vault/encrypt.js +40 -0
  208. package/server/lib/vault/ids.js +9 -0
  209. package/server/lib/vault/index.js +14 -0
  210. package/server/lib/vault/jwt.js +55 -0
  211. package/server/lib/vault/password.js +10 -0
  212. package/server/lint/boundaries.js +77 -0
  213. package/server/logger/index.js +130 -0
  214. package/server/mail/drivers/console.js +31 -0
  215. package/server/mail/drivers/smtp.js +34 -0
  216. package/server/mail/imap.js +105 -0
  217. package/server/mail/inbound-store.js +58 -0
  218. package/server/mail/inbound.js +79 -0
  219. package/server/mail/index.js +112 -0
  220. package/server/mcp/debug-api.js +137 -0
  221. package/server/mcp/helpers.js +30 -0
  222. package/server/mcp/index.js +77 -0
  223. package/server/mcp/runtime.js +7 -0
  224. package/server/mcp/server.js +19 -0
  225. package/server/mcp/tools/debugging.js +133 -0
  226. package/server/mcp/tools/introspection.js +87 -0
  227. package/server/middlewares/cors.js +30 -0
  228. package/server/middlewares/index.js +3 -0
  229. package/server/middlewares/require-session.js +15 -0
  230. package/server/module-loader.js +9 -0
  231. package/server/pages/build-client.js +187 -0
  232. package/server/pages/build-css.js +47 -0
  233. package/server/pages/build-manifest.js +55 -0
  234. package/server/pages/build-plugins.js +75 -0
  235. package/server/pages/build-server.js +115 -0
  236. package/server/pages/build.js +116 -0
  237. package/server/pages/discovery.js +120 -0
  238. package/server/pages/fonts.js +128 -0
  239. package/server/pages/handler.js +276 -0
  240. package/server/pages/hmr.js +176 -0
  241. package/server/pages/pages-router.js +78 -0
  242. package/server/pages/ssr.js +276 -0
  243. package/server/pages/static.js +92 -0
  244. package/server/pages/watcher.js +90 -0
  245. package/server/queue/drivers/knex.js +67 -0
  246. package/server/queue/drivers/redis.js +91 -0
  247. package/server/queue/index.js +61 -0
  248. package/server/rate-limit/consume.js +21 -0
  249. package/server/rate-limit/drivers/memory.js +24 -0
  250. package/server/rate-limit/drivers/redis.js +32 -0
  251. package/server/rate-limit/index.js +33 -0
  252. package/server/redis/index.js +67 -0
  253. package/server/ring-buffer.js +44 -0
  254. package/server/route.js +4 -0
  255. package/server/router/api-router.js +317 -0
  256. package/server/router/cors.js +31 -0
  257. package/server/router/middleware.js +91 -0
  258. package/server/router/routes.js +132 -0
  259. package/server/server.js +35 -0
  260. package/server/session/helpers.js +21 -0
  261. package/server/session/index.js +89 -0
  262. package/server/static/index.js +36 -0
  263. package/server/system-jobs/index.js +50 -0
  264. package/server/system-routes/index.js +84 -0
  265. package/server/testing/index.js +263 -0
  266. package/server/validation.js +41 -0
  267. package/server/watcher.js +34 -0
  268. package/server/web-server.js +231 -0
  269. package/server/ws/discovery.js +54 -0
  270. package/server/ws/index.js +14 -0
  271. package/server/ws/realtime.js +318 -0
  272. package/server/ws/registry.js +17 -0
  273. package/server/ws/server.js +152 -0
  274. package/server/ws/ws-router.js +335 -0
@@ -0,0 +1,10 @@
1
+ import bcrypt from 'bcryptjs';
2
+ const DEFAULT_ROUNDS = 10;
3
+ async function hashPassword(password, rounds = DEFAULT_ROUNDS) {
4
+ const salt = await bcrypt.genSalt(rounds);
5
+ return bcrypt.hash(password, salt);
6
+ }
7
+ async function verifyPassword(password, hash) {
8
+ return bcrypt.compare(password, hash);
9
+ }
10
+ export { hashPassword, verifyPassword };
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { glob } from '../glob.js';
4
+ import { NODE_BUILTINS, SERVER_ONLY_PACKAGES } from '../constants.js';
5
+ function extractImports(source) {
6
+ const imports = [];
7
+ const lines = source.split('\n');
8
+ for (let i = 0; i < lines.length; i++) {
9
+ const lineText = lines[i];
10
+ const lineNum = i + 1;
11
+ const staticMatch = lineText.match(/(?:import|export)\s+.*?\s+from\s+['"]([^'"]+)['"]/);
12
+ if (staticMatch) {
13
+ imports.push({ path: staticMatch[1], line: lineNum });
14
+ continue;
15
+ }
16
+ const sideEffectMatch = lineText.match(/import\s+['"]([^'"]+)['"]/);
17
+ if (sideEffectMatch) {
18
+ imports.push({ path: sideEffectMatch[1], line: lineNum });
19
+ continue;
20
+ }
21
+ const dynamicMatch = lineText.match(/import\(\s*['"]([^'"]+)['"]\s*\)/);
22
+ if (dynamicMatch) {
23
+ imports.push({ path: dynamicMatch[1], line: lineNum });
24
+ continue;
25
+ }
26
+ const requireMatch = lineText.match(/require\(\s*['"]([^'"]+)['"]\s*\)/);
27
+ if (requireMatch) {
28
+ imports.push({ path: requireMatch[1], line: lineNum });
29
+ }
30
+ }
31
+ return imports;
32
+ }
33
+ function getPackageName(importPath) {
34
+ const parts = importPath.split('/');
35
+ return importPath.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
36
+ }
37
+ async function checkServerClientBoundaries(rootDir) {
38
+ const violations = [];
39
+ let filesScanned = 0;
40
+ const clientPatterns = [
41
+ path.join(rootDir, 'client/**/*.{js,jsx}'),
42
+ path.join(rootDir, 'client/ui/**/*.{js,jsx}'),
43
+ ];
44
+ for (const pattern of clientPatterns) {
45
+ const files = await glob(pattern, { nodir: true });
46
+ for (const file of files) {
47
+ const source = await fs.readFile(file, 'utf-8');
48
+ const imports = extractImports(source);
49
+ filesScanned++;
50
+ for (const imp of imports) {
51
+ if (imp.path.startsWith('.')) continue;
52
+ if (NODE_BUILTINS.has(imp.path)) {
53
+ violations.push({
54
+ file: path.relative(rootDir, file),
55
+ line: imp.line,
56
+ kind: 'server-in-client',
57
+ importPath: imp.path,
58
+ message: `Client code cannot import Node.js built-in "${imp.path}". This module is not available in the browser.`,
59
+ });
60
+ continue;
61
+ }
62
+ const pkgName = getPackageName(imp.path);
63
+ if (SERVER_ONLY_PACKAGES.has(pkgName)) {
64
+ violations.push({
65
+ file: path.relative(rootDir, file),
66
+ line: imp.line,
67
+ kind: 'server-in-client',
68
+ importPath: imp.path,
69
+ message: `Client code cannot import server-only package "${pkgName}". This package requires Node.js and must not be in browser bundles.`,
70
+ });
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return { violations, filesScanned };
76
+ }
77
+ export { checkServerClientBoundaries, extractImports };
@@ -0,0 +1,130 @@
1
+ import pino from 'pino';
2
+ import RingBuffer from '../ring-buffer.js';
3
+
4
+ function wrapPino(p) {
5
+ return {
6
+ debug(message, data) {
7
+ if (data) p.debug(data, message);
8
+ else p.debug(message);
9
+ },
10
+ info(message, data) {
11
+ if (data) p.info(data, message);
12
+ else p.info(message);
13
+ },
14
+ warn(message, data) {
15
+ if (data) p.warn(data, message);
16
+ else p.warn(message);
17
+ },
18
+ error(message, data) {
19
+ if (data) p.error(data, message);
20
+ else p.error(message);
21
+ },
22
+ };
23
+ }
24
+
25
+ class Logger {
26
+ _base;
27
+ _ring;
28
+ _fields;
29
+ _root;
30
+
31
+ constructor({ level, destination, buffer, pretty } = {}) {
32
+ const opts = {
33
+ level: level ?? 'info',
34
+ messageKey: 'message',
35
+ formatters: {
36
+ level(label) {
37
+ return { level: label };
38
+ },
39
+ },
40
+ timestamp: () => `,"time":"${new Date().toISOString()}"`,
41
+ };
42
+ if (!destination && pretty !== false) {
43
+ opts.transport = {
44
+ target: 'pino-pretty',
45
+ options: { colorize: true },
46
+ };
47
+ }
48
+ const p = destination ? pino(opts, destination) : pino(opts);
49
+ this._base = wrapPino(p);
50
+ this._ring = buffer ? new RingBuffer(buffer) : null;
51
+ this._fields = null;
52
+ this._root = null;
53
+ }
54
+
55
+ debug(message, data) {
56
+ this._log('debug', message, data);
57
+ }
58
+
59
+ info(message, data) {
60
+ this._log('info', message, data);
61
+ }
62
+
63
+ warn(message, data) {
64
+ this._log('warn', message, data);
65
+ }
66
+
67
+ error(message, data) {
68
+ this._log('error', message, data);
69
+ }
70
+
71
+ _log(level, message, data) {
72
+ const merged = this._fields ? (data ? { ...this._fields, ...data } : this._fields) : data;
73
+ if (this._root) {
74
+ this._root._log(level, message, merged);
75
+ return;
76
+ }
77
+ if (this._ring) {
78
+ this._ring.push({
79
+ level,
80
+ message,
81
+ ...(merged ? { data: merged } : {}),
82
+ timestamp: new Date().toISOString(),
83
+ ...(merged?.logger ? { logger: merged.logger } : {}),
84
+ });
85
+ }
86
+ this._base[level](message, merged);
87
+ }
88
+
89
+ query(filters) {
90
+ if (!this._ring) return [];
91
+ let entries = this._ring.toArray();
92
+ if (filters?.level) entries = entries.filter((e) => e.level === filters.level);
93
+ if (filters?.logger) entries = entries.filter((e) => e.logger === filters.logger);
94
+ if (filters?.requestId) entries = entries.filter((e) => e.data?.requestId === filters.requestId);
95
+ if (filters?.message) entries = entries.filter((e) => e.message === filters.message);
96
+ if (filters?.method) entries = entries.filter((e) => e.data?.method === filters.method.toUpperCase());
97
+ if (filters?.path) entries = entries.filter((e) => e.data?.path?.includes(filters.path));
98
+ if (filters?.status) entries = entries.filter((e) => e.data?.status === filters.status);
99
+ if (filters?.minDurationMs) entries = entries.filter((e) => e.data?.durationMs >= filters.minDurationMs);
100
+ if (filters?.eventName) {
101
+ entries = entries.filter((e) => e.data?.eventName === filters.eventName || e.data?.eventName?.includes(filters.eventName));
102
+ }
103
+ if (filters?.since) {
104
+ const sinceTs = new Date(filters.since).getTime();
105
+ entries = entries.filter((e) => new Date(e.timestamp).getTime() >= sinceTs);
106
+ }
107
+ if (filters?.limit && filters.limit > 0) entries = entries.slice(-filters.limit);
108
+ return entries;
109
+ }
110
+
111
+ errors(limit, since) {
112
+ return this.query({ level: 'error', limit, since });
113
+ }
114
+
115
+ extend(fields) {
116
+ const child = Object.create(Logger.prototype);
117
+ const hasFields = Object.keys(fields).length > 0;
118
+ child._fields = hasFields
119
+ ? this._fields
120
+ ? { ...this._fields, ...fields }
121
+ : fields
122
+ : this._fields;
123
+ child._root = this._root ?? this;
124
+ child._base = null;
125
+ child._ring = null;
126
+ return child;
127
+ }
128
+ }
129
+
130
+ export default Logger;
@@ -0,0 +1,31 @@
1
+ class ConsoleMailDriver {
2
+ /** Captured messages for test assertions. */
3
+ sent = [];
4
+ async send(message) {
5
+ this.sent.push(message);
6
+ const recipients = normalizeRecipients(message.to);
7
+ console.log(
8
+ '[mail] \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500',
9
+ );
10
+ console.log(`[mail] From: ${message.from}`);
11
+ console.log(`[mail] To: ${recipients.join(', ')}`);
12
+ if (message.cc) console.log(`[mail] CC: ${normalizeRecipients(message.cc).join(', ')}`);
13
+ if (message.bcc) console.log(`[mail] BCC: ${normalizeRecipients(message.bcc).join(', ')}`);
14
+ if (message.replyTo) console.log(`[mail] ReplyTo: ${message.replyTo}`);
15
+ console.log(`[mail] Subject: ${message.subject}`);
16
+ if (message.text) console.log(`[mail] Text: ${message.text}`);
17
+ if (message.html) console.log(`[mail] HTML: ${message.html}`);
18
+ console.log(
19
+ '[mail] \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500',
20
+ );
21
+ return {
22
+ accepted: recipients,
23
+ rejected: [],
24
+ messageId: `console-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@local`,
25
+ };
26
+ }
27
+ }
28
+ function normalizeRecipients(to) {
29
+ return Array.isArray(to) ? to : [to];
30
+ }
31
+ export default ConsoleMailDriver;
@@ -0,0 +1,34 @@
1
+ import { createTransport } from 'nodemailer';
2
+ class SmtpMailDriver {
3
+ transporter;
4
+ constructor(smtpConfig) {
5
+ this.transporter = createTransport({
6
+ host: smtpConfig.host,
7
+ port: smtpConfig.port ?? 587,
8
+ secure: smtpConfig.secure ?? false,
9
+ auth: smtpConfig.auth ? { user: smtpConfig.auth.user, pass: smtpConfig.auth.pass } : void 0,
10
+ });
11
+ }
12
+ async send(message) {
13
+ const info = await this.transporter.sendMail({
14
+ from: message.from,
15
+ to: Array.isArray(message.to) ? message.to.join(', ') : message.to,
16
+ cc: message.cc ? (Array.isArray(message.cc) ? message.cc.join(', ') : message.cc) : void 0,
17
+ bcc: message.bcc
18
+ ? Array.isArray(message.bcc)
19
+ ? message.bcc.join(', ')
20
+ : message.bcc
21
+ : void 0,
22
+ replyTo: message.replyTo,
23
+ subject: message.subject,
24
+ text: message.text,
25
+ html: message.html,
26
+ });
27
+ return {
28
+ accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [],
29
+ rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [],
30
+ messageId: info.messageId,
31
+ };
32
+ }
33
+ }
34
+ export default SmtpMailDriver;
@@ -0,0 +1,105 @@
1
+ import { ImapFlow } from 'imapflow';
2
+ import { simpleParser } from 'mailparser';
3
+ import { toErrorMessage } from '../helpers.js';
4
+ const DEFAULT_MAILBOX = 'INBOX';
5
+ const DEFAULT_IMAP_PORT = 993;
6
+ const SEEN_FLAG = '\\Seen';
7
+ const LOG_PREFIX = '[mail/imap]';
8
+ async function fetchUnseenMessages(config, log) {
9
+ const mailbox = config.mailbox ?? DEFAULT_MAILBOX;
10
+ const client = new ImapFlow({
11
+ host: config.host,
12
+ port: config.port ?? DEFAULT_IMAP_PORT,
13
+ secure: config.tls !== false,
14
+ auth: {
15
+ user: config.auth.user,
16
+ pass: config.auth.pass,
17
+ },
18
+ logger: false,
19
+ // Suppress imapflow's internal logging
20
+ });
21
+ const emails = [];
22
+ try {
23
+ await client.connect();
24
+ } catch (err) {
25
+ log.error(`${LOG_PREFIX} Failed to connect`, {
26
+ error: toErrorMessage(err),
27
+ host: config.host,
28
+ });
29
+ return emails;
30
+ }
31
+ try {
32
+ const lock = await client.getMailboxLock(mailbox);
33
+ try {
34
+ const messages = client.fetch(
35
+ { seen: false },
36
+ {
37
+ source: true,
38
+ uid: true,
39
+ flags: true,
40
+ envelope: true,
41
+ },
42
+ );
43
+ for await (const msg of messages) {
44
+ try {
45
+ const parsed = await simpleParser(msg.source);
46
+ const email = {
47
+ messageId: parsed.messageId || `unknown-${msg.uid}-${Date.now()}`,
48
+ from: extractAddress(parsed.from),
49
+ to: extractAddresses(parsed.to),
50
+ cc: parsed.cc ? extractAddresses(parsed.cc) : void 0,
51
+ subject: parsed.subject ?? '(no subject)',
52
+ text: parsed.text ?? void 0,
53
+ html: typeof parsed.html === 'string' ? parsed.html : void 0,
54
+ hasAttachments: (parsed.attachments?.length ?? 0) > 0,
55
+ date: parsed.date ?? new Date(),
56
+ headers: extractHeaders(parsed.headers),
57
+ };
58
+ emails.push(email);
59
+ await client.messageFlagsAdd({ uid: msg.uid }, [SEEN_FLAG], { uid: true });
60
+ } catch (parseErr) {
61
+ log.error(`${LOG_PREFIX} Failed to parse message`, {
62
+ uid: msg.uid,
63
+ error: parseErr instanceof Error ? parseErr.message : String(parseErr),
64
+ });
65
+ }
66
+ }
67
+ } finally {
68
+ lock.release();
69
+ }
70
+ } catch (err) {
71
+ log.error(`${LOG_PREFIX} Error fetching messages`, {
72
+ error: toErrorMessage(err),
73
+ });
74
+ } finally {
75
+ await client.logout().catch(() => {});
76
+ }
77
+ return emails;
78
+ }
79
+ function extractAddress(addr) {
80
+ if (!addr) return 'unknown@unknown';
81
+ if (addr.value && Array.isArray(addr.value) && addr.value.length > 0) {
82
+ return addr.value[0].address ?? 'unknown@unknown';
83
+ }
84
+ if (typeof addr === 'string') return addr;
85
+ return 'unknown@unknown';
86
+ }
87
+ function extractAddresses(addr) {
88
+ if (!addr) return [];
89
+ if (addr.value && Array.isArray(addr.value)) {
90
+ return addr.value.map((v) => v.address ?? 'unknown@unknown');
91
+ }
92
+ if (typeof addr === 'string') return [addr];
93
+ return [];
94
+ }
95
+ function extractHeaders(headers) {
96
+ const result = {};
97
+ if (!headers) return result;
98
+ if (typeof headers.forEach === 'function') {
99
+ headers.forEach((value, key) => {
100
+ result[key] = typeof value === 'string' ? value : String(value);
101
+ });
102
+ }
103
+ return result;
104
+ }
105
+ export { fetchUnseenMessages };
@@ -0,0 +1,58 @@
1
+ const INBOUND_MAIL_TABLE = '__inbound_mail';
2
+ async function ensureInboundMailTable(db) {
3
+ const exists = await db.schema.hasTable(INBOUND_MAIL_TABLE);
4
+ if (exists) return;
5
+ await db.schema.createTable(INBOUND_MAIL_TABLE, (table) => {
6
+ table.increments('id').primary();
7
+ table.string('message_id').notNullable().unique();
8
+ table.string('from_addr').notNullable();
9
+ table.text('to_addrs').notNullable();
10
+ table.text('cc_addrs').nullable();
11
+ table.string('subject').nullable();
12
+ table.text('text_body').nullable();
13
+ table.text('html_body').nullable();
14
+ table.boolean('has_attachments').notNullable().defaultTo(false);
15
+ table.timestamp('received_at').notNullable();
16
+ table.timestamp('created_at').defaultTo(db.fn.now());
17
+ });
18
+ }
19
+ async function storeInboundEmail(db, email) {
20
+ try {
21
+ await db(INBOUND_MAIL_TABLE).insert({
22
+ message_id: email.messageId,
23
+ from_addr: email.from,
24
+ to_addrs: JSON.stringify(email.to),
25
+ cc_addrs: email.cc ? JSON.stringify(email.cc) : null,
26
+ subject: email.subject,
27
+ text_body: email.text ?? null,
28
+ html_body: email.html ?? null,
29
+ has_attachments: email.hasAttachments,
30
+ received_at: email.date.toISOString(),
31
+ });
32
+ return true;
33
+ } catch (err) {
34
+ if (
35
+ err.code === 'SQLITE_CONSTRAINT' ||
36
+ err.code === '23505' || // PostgreSQL unique violation
37
+ (err.message && err.message.includes('UNIQUE constraint failed'))
38
+ ) {
39
+ return false;
40
+ }
41
+ throw err;
42
+ }
43
+ }
44
+ async function pruneInboundEmails(db, retentionDays) {
45
+ const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1e3);
46
+ return db(INBOUND_MAIL_TABLE).where('received_at', '<', cutoff.toISOString()).delete();
47
+ }
48
+ async function hasMessage(db, messageId) {
49
+ const row = await db(INBOUND_MAIL_TABLE).where('message_id', messageId).first('id');
50
+ return !!row;
51
+ }
52
+ export {
53
+ INBOUND_MAIL_TABLE,
54
+ ensureInboundMailTable,
55
+ hasMessage,
56
+ pruneInboundEmails,
57
+ storeInboundEmail,
58
+ };
@@ -0,0 +1,79 @@
1
+ import { addSystemJobEntry } from '../system-jobs/index.js';
2
+ import { toErrorMessage } from '../helpers.js';
3
+ import { fetchUnseenMessages } from './imap.js';
4
+ import { ensureInboundMailTable, storeInboundEmail, pruneInboundEmails } from './inbound-store.js';
5
+ const POLL_INBOUND_JOB_NAME = 'poll-inbound-mail';
6
+ const PRUNE_INBOUND_JOB_NAME = 'prune-inbound-mail';
7
+ const DEFAULT_RETENTION_DAYS = 30;
8
+ const DEFAULT_POLL_INTERVAL_SECONDS = 30;
9
+ function registerInboundMailJobs(db, inboundConfig, log, onEmail) {
10
+ if (inboundConfig.driver !== 'imap' || !inboundConfig.imap) {
11
+ throw new Error('mail.inbound.imap config is required when inbound driver is "imap"');
12
+ }
13
+ const imapConfig = inboundConfig.imap;
14
+ const pollSeconds = imapConfig.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS;
15
+ const cronMinutes = Math.max(1, Math.floor(pollSeconds / 60));
16
+ const cronExpr = cronMinutes === 1 ? '* * * * *' : `*/${cronMinutes} * * * *`;
17
+ addSystemJobEntry({
18
+ definition: {
19
+ name: POLL_INBOUND_JOB_NAME,
20
+ schedule: cronExpr,
21
+ handler: async () => {
22
+ log.info('[mail/inbound] Polling for new messages...');
23
+ const emails = await fetchUnseenMessages(imapConfig, log);
24
+ if (emails.length === 0) {
25
+ log.info('[mail/inbound] No new messages');
26
+ return;
27
+ }
28
+ log.info(`[mail/inbound] Fetched ${emails.length} new message(s)`);
29
+ let stored = 0;
30
+ for (const email of emails) {
31
+ const isNew = await storeInboundEmail(db, email);
32
+ if (isNew) {
33
+ stored++;
34
+ try {
35
+ await onEmail(email);
36
+ } catch (err) {
37
+ log.error('[mail/inbound] Error in email handler', {
38
+ messageId: email.messageId,
39
+ error: toErrorMessage(err),
40
+ });
41
+ }
42
+ } else {
43
+ log.info(`[mail/inbound] Duplicate skipped: ${email.messageId}`);
44
+ }
45
+ }
46
+ log.info(
47
+ `[mail/inbound] Stored ${stored} new message(s), ${emails.length - stored} duplicate(s)`,
48
+ );
49
+ },
50
+ },
51
+ shouldRegister: (config) => !!config.mail?.inbound,
52
+ description: 'Poll IMAP mailbox for inbound email',
53
+ });
54
+ const retentionDays = inboundConfig.retentionDays ?? DEFAULT_RETENTION_DAYS;
55
+ if (retentionDays && retentionDays > 0) {
56
+ addSystemJobEntry({
57
+ definition: {
58
+ name: PRUNE_INBOUND_JOB_NAME,
59
+ schedule: '0 3 * * *',
60
+ // Daily at 3 AM
61
+ handler: async () => {
62
+ log.info(`[mail/inbound] Pruning messages older than ${retentionDays} days...`);
63
+ const deleted = await pruneInboundEmails(db, retentionDays);
64
+ log.info(`[mail/inbound] Pruned ${deleted} message(s)`);
65
+ },
66
+ },
67
+ shouldRegister: (config) => {
68
+ const retention = config.mail?.inbound?.retentionDays;
69
+ return !!config.mail?.inbound && retention !== 0 && retention !== null;
70
+ },
71
+ description: 'Prune old inbound email messages',
72
+ });
73
+ }
74
+ }
75
+ async function initInboundMail(db, inboundConfig, log, onEmail) {
76
+ await ensureInboundMailTable(db);
77
+ registerInboundMailJobs(db, inboundConfig, log, onEmail);
78
+ }
79
+ export { POLL_INBOUND_JOB_NAME, PRUNE_INBOUND_JOB_NAME, initInboundMail, registerInboundMailJobs };
@@ -0,0 +1,112 @@
1
+ import { SYSTEM_JOB_DOMAIN } from '../system-jobs/index.js';
2
+ import { addSystemJobEntry } from '../system-jobs/index.js';
3
+ import ConsoleMailDriver from './drivers/console.js';
4
+ import SmtpMailDriver from './drivers/smtp.js';
5
+ import { initInboundMail } from './inbound.js';
6
+
7
+ const MAIL_JOB_NAME = 'send-mail';
8
+ const MAIL_JOB_QUALIFIED = `${SYSTEM_JOB_DOMAIN}/${MAIL_JOB_NAME}`;
9
+ const MAIL_TOPIC = 'mail:outbound';
10
+
11
+ class Mail {
12
+ _driver = null;
13
+ _config;
14
+ _db;
15
+ _log;
16
+ _queue = null;
17
+ _enabled = false;
18
+
19
+ constructor(config, { db, log, queue } = {}) {
20
+ this._config = config;
21
+ this._db = db;
22
+ this._log = log;
23
+ this._queue = queue;
24
+ this._enabled = !!(config && config.enabled !== false);
25
+
26
+ if (this._enabled) {
27
+ this._driver = createDriver(config);
28
+ registerSendJob(this._driver, queue, config.throughput);
29
+ log?.info(`Mail driver: ${config.driver}`);
30
+ } else if (config) {
31
+ log?.info('Mail: disabled');
32
+ }
33
+ }
34
+
35
+ get enabled() {
36
+ return this._enabled;
37
+ }
38
+
39
+ async send(message) {
40
+ if (!this._enabled || !this._driver) {
41
+ throw new Error(
42
+ 'Mail is not configured. Add a "mail" section to your arcway.config.js to enable email.',
43
+ );
44
+ }
45
+ const from = message.from ?? this._config.from;
46
+ return this._driver.send({ ...message, from });
47
+ }
48
+
49
+ async queue(message) {
50
+ if (!this._enabled || !this._driver) {
51
+ throw new Error(
52
+ 'Mail is not configured. Add a "mail" section to your arcway.config.js to enable email.',
53
+ );
54
+ }
55
+ if (!this._queue) {
56
+ throw new Error(
57
+ 'Cannot queue mail: queue is not available. Use mail.send() for immediate delivery.',
58
+ );
59
+ }
60
+ const payload = {
61
+ ...message,
62
+ from: message.from ?? this._config.from,
63
+ };
64
+ await this._queue.push(MAIL_TOPIC, payload);
65
+ }
66
+
67
+ async initInbound(onEmail) {
68
+ if (!this._enabled || !this._config?.inbound) return;
69
+ await initInboundMail(this._db, this._config.inbound, this._log, onEmail);
70
+ this._log?.info(`Mail inbound: ${this._config.inbound.driver}`);
71
+ }
72
+ }
73
+
74
+ function createDriver(config) {
75
+ switch (config.driver) {
76
+ case 'smtp': {
77
+ if (!config.smtp) {
78
+ throw new Error('mail.smtp config is required when driver is "smtp"');
79
+ }
80
+ return new SmtpMailDriver(config.smtp);
81
+ }
82
+ case 'console':
83
+ return new ConsoleMailDriver();
84
+ default:
85
+ throw new Error(`Unknown mail driver: "${config.driver}"`);
86
+ }
87
+ }
88
+
89
+ function registerSendJob(driver, queue, throughput) {
90
+ addSystemJobEntry({
91
+ definition: {
92
+ name: MAIL_JOB_NAME,
93
+ handler: async () => {
94
+ if (!queue) return;
95
+ const items = await queue.pop(MAIL_TOPIC, 10);
96
+ const ids = [];
97
+ for (const item of items) {
98
+ await driver.send(item.data);
99
+ ids.push(item.id);
100
+ }
101
+ if (ids.length > 0) {
102
+ await queue.remove(ids);
103
+ }
104
+ },
105
+ throughput,
106
+ },
107
+ shouldRegister: (config) => !!config.mail,
108
+ description: 'Send queued outbound email',
109
+ });
110
+ }
111
+
112
+ export { MAIL_JOB_NAME, MAIL_JOB_QUALIFIED, MAIL_TOPIC, Mail };