agentmail-clone-v1 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 (227) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/docs-deploy.yml +37 -0
  3. package/.github/workflows/landing-preview.yml +43 -0
  4. package/.github/workflows/openapi-lint.yml +31 -0
  5. package/.github/workflows/sdk-generate-check.yml +66 -0
  6. package/.github/workflows/sdk-release.yml +62 -0
  7. package/CHANGELOG.md +10 -0
  8. package/README.md +208 -0
  9. package/RELEASING.md +43 -0
  10. package/docs/api-reference/api-keys.mdx +11 -0
  11. package/docs/api-reference/domains.mdx +13 -0
  12. package/docs/api-reference/emails.mdx +26 -0
  13. package/docs/api-reference/inboxes.mdx +13 -0
  14. package/docs/api-reference/metrics.mdx +10 -0
  15. package/docs/authentication.mdx +25 -0
  16. package/docs/docs.json +35 -0
  17. package/docs/errors.mdx +34 -0
  18. package/docs/favicon.svg +5 -0
  19. package/docs/idempotency.mdx +18 -0
  20. package/docs/index.mdx +24 -0
  21. package/docs/quickstart.mdx +134 -0
  22. package/landing/DEPLOYING.md +33 -0
  23. package/landing/favicon.svg +5 -0
  24. package/landing/index.html +129 -0
  25. package/landing/main.js +45 -0
  26. package/landing/privacy.html +29 -0
  27. package/landing/styles.css +356 -0
  28. package/landing/terms.html +29 -0
  29. package/netlify.toml +15 -0
  30. package/openapi/openapi.yaml +1016 -0
  31. package/package.json +34 -0
  32. package/render.yaml +48 -0
  33. package/scripts/generate-sdk-py.sh +16 -0
  34. package/scripts/generate-sdk-ts.sh +16 -0
  35. package/scripts/migrate.js +66 -0
  36. package/scripts/validate-docs.js +56 -0
  37. package/scripts/validate-landing.js +39 -0
  38. package/sdks/python/README.md +40 -0
  39. package/sdks/python/emailagent_sdk/__init__.py +157 -0
  40. package/sdks/python/generated/.openapi-generator/FILES +101 -0
  41. package/sdks/python/generated/.openapi-generator/VERSION +1 -0
  42. package/sdks/python/generated/.openapi-generator-ignore +23 -0
  43. package/sdks/python/generated/emailagent_sdk_generated/__init__.py +105 -0
  44. package/sdks/python/generated/emailagent_sdk_generated/api/__init__.py +9 -0
  45. package/sdks/python/generated/emailagent_sdk_generated/api/api_keys_api.py +1162 -0
  46. package/sdks/python/generated/emailagent_sdk_generated/api/domains_api.py +1168 -0
  47. package/sdks/python/generated/emailagent_sdk_generated/api/emails_api.py +1232 -0
  48. package/sdks/python/generated/emailagent_sdk_generated/api/inboxes_api.py +1191 -0
  49. package/sdks/python/generated/emailagent_sdk_generated/api/metrics_api.py +285 -0
  50. package/sdks/python/generated/emailagent_sdk_generated/api_client.py +801 -0
  51. package/sdks/python/generated/emailagent_sdk_generated/api_response.py +21 -0
  52. package/sdks/python/generated/emailagent_sdk_generated/configuration.py +586 -0
  53. package/sdks/python/generated/emailagent_sdk_generated/docs/APIKeysApi.md +334 -0
  54. package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyCreated.md +35 -0
  55. package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyCreatedResponse.md +29 -0
  56. package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyListItem.md +35 -0
  57. package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyListResponse.md +29 -0
  58. package/sdks/python/generated/emailagent_sdk_generated/docs/CreateApiKeyRequest.md +30 -0
  59. package/sdks/python/generated/emailagent_sdk_generated/docs/CreateApiKeyRequestScopes.md +29 -0
  60. package/sdks/python/generated/emailagent_sdk_generated/docs/CreateDomainRequest.md +29 -0
  61. package/sdks/python/generated/emailagent_sdk_generated/docs/CreateInboxRequest.md +31 -0
  62. package/sdks/python/generated/emailagent_sdk_generated/docs/Domain.md +37 -0
  63. package/sdks/python/generated/emailagent_sdk_generated/docs/DomainListResponse.md +29 -0
  64. package/sdks/python/generated/emailagent_sdk_generated/docs/DomainResponse.md +29 -0
  65. package/sdks/python/generated/emailagent_sdk_generated/docs/DomainsApi.md +336 -0
  66. package/sdks/python/generated/emailagent_sdk_generated/docs/Email.md +43 -0
  67. package/sdks/python/generated/emailagent_sdk_generated/docs/EmailListResponse.md +29 -0
  68. package/sdks/python/generated/emailagent_sdk_generated/docs/EmailResponse.md +29 -0
  69. package/sdks/python/generated/emailagent_sdk_generated/docs/EmailsApi.md +353 -0
  70. package/sdks/python/generated/emailagent_sdk_generated/docs/ErrorResponse.md +29 -0
  71. package/sdks/python/generated/emailagent_sdk_generated/docs/Inbox.md +38 -0
  72. package/sdks/python/generated/emailagent_sdk_generated/docs/InboxListResponse.md +29 -0
  73. package/sdks/python/generated/emailagent_sdk_generated/docs/InboxResponse.md +29 -0
  74. package/sdks/python/generated/emailagent_sdk_generated/docs/InboxesApi.md +337 -0
  75. package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsApi.md +83 -0
  76. package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsData.md +35 -0
  77. package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsResponse.md +29 -0
  78. package/sdks/python/generated/emailagent_sdk_generated/docs/OkResponse.md +29 -0
  79. package/sdks/python/generated/emailagent_sdk_generated/docs/PlanLimits.md +32 -0
  80. package/sdks/python/generated/emailagent_sdk_generated/docs/SendEmailRequest.md +32 -0
  81. package/sdks/python/generated/emailagent_sdk_generated/docs/UpdateEmailReadRequest.md +29 -0
  82. package/sdks/python/generated/emailagent_sdk_generated/docs/UpdateInboxRequest.md +29 -0
  83. package/sdks/python/generated/emailagent_sdk_generated/exceptions.py +216 -0
  84. package/sdks/python/generated/emailagent_sdk_generated/models/__init__.py +41 -0
  85. package/sdks/python/generated/emailagent_sdk_generated/models/api_key_created.py +113 -0
  86. package/sdks/python/generated/emailagent_sdk_generated/models/api_key_created_response.py +91 -0
  87. package/sdks/python/generated/emailagent_sdk_generated/models/api_key_list_item.py +123 -0
  88. package/sdks/python/generated/emailagent_sdk_generated/models/api_key_list_response.py +95 -0
  89. package/sdks/python/generated/emailagent_sdk_generated/models/create_api_key_request.py +93 -0
  90. package/sdks/python/generated/emailagent_sdk_generated/models/create_api_key_request_scopes.py +143 -0
  91. package/sdks/python/generated/emailagent_sdk_generated/models/create_domain_request.py +87 -0
  92. package/sdks/python/generated/emailagent_sdk_generated/models/create_inbox_request.py +91 -0
  93. package/sdks/python/generated/emailagent_sdk_generated/models/domain.py +134 -0
  94. package/sdks/python/generated/emailagent_sdk_generated/models/domain_list_response.py +95 -0
  95. package/sdks/python/generated/emailagent_sdk_generated/models/domain_response.py +91 -0
  96. package/sdks/python/generated/emailagent_sdk_generated/models/email.py +175 -0
  97. package/sdks/python/generated/emailagent_sdk_generated/models/email_list_response.py +95 -0
  98. package/sdks/python/generated/emailagent_sdk_generated/models/email_response.py +91 -0
  99. package/sdks/python/generated/emailagent_sdk_generated/models/error_response.py +87 -0
  100. package/sdks/python/generated/emailagent_sdk_generated/models/inbox.py +136 -0
  101. package/sdks/python/generated/emailagent_sdk_generated/models/inbox_list_response.py +95 -0
  102. package/sdks/python/generated/emailagent_sdk_generated/models/inbox_response.py +91 -0
  103. package/sdks/python/generated/emailagent_sdk_generated/models/metrics_data.py +110 -0
  104. package/sdks/python/generated/emailagent_sdk_generated/models/metrics_response.py +91 -0
  105. package/sdks/python/generated/emailagent_sdk_generated/models/ok_response.py +87 -0
  106. package/sdks/python/generated/emailagent_sdk_generated/models/plan_limits.py +93 -0
  107. package/sdks/python/generated/emailagent_sdk_generated/models/send_email_request.py +93 -0
  108. package/sdks/python/generated/emailagent_sdk_generated/models/update_email_read_request.py +87 -0
  109. package/sdks/python/generated/emailagent_sdk_generated/models/update_inbox_request.py +92 -0
  110. package/sdks/python/generated/emailagent_sdk_generated/rest.py +258 -0
  111. package/sdks/python/generated/emailagent_sdk_generated/test/__init__.py +0 -0
  112. package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_created.py +68 -0
  113. package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_created_response.py +52 -0
  114. package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_list_item.py +66 -0
  115. package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_list_response.py +56 -0
  116. package/sdks/python/generated/emailagent_sdk_generated/test/test_api_keys_api.py +59 -0
  117. package/sdks/python/generated/emailagent_sdk_generated/test/test_create_api_key_request.py +53 -0
  118. package/sdks/python/generated/emailagent_sdk_generated/test/test_create_api_key_request_scopes.py +50 -0
  119. package/sdks/python/generated/emailagent_sdk_generated/test/test_create_domain_request.py +52 -0
  120. package/sdks/python/generated/emailagent_sdk_generated/test/test_create_inbox_request.py +54 -0
  121. package/sdks/python/generated/emailagent_sdk_generated/test/test_domain.py +70 -0
  122. package/sdks/python/generated/emailagent_sdk_generated/test/test_domain_list_response.py +56 -0
  123. package/sdks/python/generated/emailagent_sdk_generated/test/test_domain_response.py +52 -0
  124. package/sdks/python/generated/emailagent_sdk_generated/test/test_domains_api.py +59 -0
  125. package/sdks/python/generated/emailagent_sdk_generated/test/test_email.py +79 -0
  126. package/sdks/python/generated/emailagent_sdk_generated/test/test_email_list_response.py +56 -0
  127. package/sdks/python/generated/emailagent_sdk_generated/test/test_email_response.py +52 -0
  128. package/sdks/python/generated/emailagent_sdk_generated/test/test_emails_api.py +59 -0
  129. package/sdks/python/generated/emailagent_sdk_generated/test/test_error_response.py +52 -0
  130. package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox.py +68 -0
  131. package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox_list_response.py +56 -0
  132. package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox_response.py +52 -0
  133. package/sdks/python/generated/emailagent_sdk_generated/test/test_inboxes_api.py +59 -0
  134. package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_api.py +38 -0
  135. package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_data.py +72 -0
  136. package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_response.py +74 -0
  137. package/sdks/python/generated/emailagent_sdk_generated/test/test_ok_response.py +52 -0
  138. package/sdks/python/generated/emailagent_sdk_generated/test/test_plan_limits.py +58 -0
  139. package/sdks/python/generated/emailagent_sdk_generated/test/test_send_email_request.py +56 -0
  140. package/sdks/python/generated/emailagent_sdk_generated/test/test_update_email_read_request.py +52 -0
  141. package/sdks/python/generated/emailagent_sdk_generated/test/test_update_inbox_request.py +52 -0
  142. package/sdks/python/generated/emailagent_sdk_generated_README.md +140 -0
  143. package/sdks/python/openapitools.json +7 -0
  144. package/sdks/python/pyproject.toml +19 -0
  145. package/sdks/typescript/README.md +41 -0
  146. package/sdks/typescript/generated/.openapi-generator/FILES +41 -0
  147. package/sdks/typescript/generated/.openapi-generator/VERSION +1 -0
  148. package/sdks/typescript/generated/.openapi-generator-ignore +23 -0
  149. package/sdks/typescript/generated/package.json +21 -0
  150. package/sdks/typescript/generated/src/apis/APIKeysApi.ts +314 -0
  151. package/sdks/typescript/generated/src/apis/DomainsApi.ts +314 -0
  152. package/sdks/typescript/generated/src/apis/EmailsApi.ts +350 -0
  153. package/sdks/typescript/generated/src/apis/InboxesApi.ts +329 -0
  154. package/sdks/typescript/generated/src/apis/MetricsApi.ts +93 -0
  155. package/sdks/typescript/generated/src/apis/index.ts +7 -0
  156. package/sdks/typescript/generated/src/index.ts +5 -0
  157. package/sdks/typescript/generated/src/models/ApiKeyCreated.ts +123 -0
  158. package/sdks/typescript/generated/src/models/ApiKeyCreatedResponse.ts +74 -0
  159. package/sdks/typescript/generated/src/models/ApiKeyListItem.ts +121 -0
  160. package/sdks/typescript/generated/src/models/ApiKeyListResponse.ts +74 -0
  161. package/sdks/typescript/generated/src/models/CreateApiKeyRequest.ts +82 -0
  162. package/sdks/typescript/generated/src/models/CreateApiKeyRequestScopes.ts +45 -0
  163. package/sdks/typescript/generated/src/models/CreateDomainRequest.ts +66 -0
  164. package/sdks/typescript/generated/src/models/CreateInboxRequest.ts +82 -0
  165. package/sdks/typescript/generated/src/models/Domain.ts +152 -0
  166. package/sdks/typescript/generated/src/models/DomainListResponse.ts +74 -0
  167. package/sdks/typescript/generated/src/models/DomainResponse.ts +74 -0
  168. package/sdks/typescript/generated/src/models/Email.ts +222 -0
  169. package/sdks/typescript/generated/src/models/EmailListResponse.ts +74 -0
  170. package/sdks/typescript/generated/src/models/EmailResponse.ts +74 -0
  171. package/sdks/typescript/generated/src/models/ErrorResponse.ts +66 -0
  172. package/sdks/typescript/generated/src/models/Inbox.ts +159 -0
  173. package/sdks/typescript/generated/src/models/InboxListResponse.ts +74 -0
  174. package/sdks/typescript/generated/src/models/InboxResponse.ts +74 -0
  175. package/sdks/typescript/generated/src/models/MetricsData.ts +139 -0
  176. package/sdks/typescript/generated/src/models/MetricsResponse.ts +74 -0
  177. package/sdks/typescript/generated/src/models/OkResponse.ts +66 -0
  178. package/sdks/typescript/generated/src/models/PlanLimits.ts +93 -0
  179. package/sdks/typescript/generated/src/models/SendEmailRequest.ts +91 -0
  180. package/sdks/typescript/generated/src/models/UpdateEmailReadRequest.ts +66 -0
  181. package/sdks/typescript/generated/src/models/UpdateInboxRequest.ts +66 -0
  182. package/sdks/typescript/generated/src/models/index.ts +27 -0
  183. package/sdks/typescript/generated/src/runtime.ts +432 -0
  184. package/sdks/typescript/generated/tsconfig.esm.json +7 -0
  185. package/sdks/typescript/generated/tsconfig.json +16 -0
  186. package/sdks/typescript/openapitools.json +8 -0
  187. package/sdks/typescript/package.json +27 -0
  188. package/sdks/typescript/src/index.ts +138 -0
  189. package/sdks/typescript/tsconfig.json +14 -0
  190. package/sql/001_init.sql +143 -0
  191. package/sql/002_local_auth.sql +38 -0
  192. package/sql/003_domain_routes.sql +2 -0
  193. package/sql/004_reliability_primitives.sql +75 -0
  194. package/sql/005_auth_email_flows.sql +22 -0
  195. package/src/config.js +30 -0
  196. package/src/db.js +25 -0
  197. package/src/lib/api-auth.js +55 -0
  198. package/src/lib/auth.js +71 -0
  199. package/src/lib/csrf.js +46 -0
  200. package/src/lib/dodo.js +67 -0
  201. package/src/lib/email-templates.js +67 -0
  202. package/src/lib/idempotency.js +85 -0
  203. package/src/lib/mailgun.js +188 -0
  204. package/src/lib/plan.js +24 -0
  205. package/src/lib/rate-limit.js +43 -0
  206. package/src/lib/security.js +62 -0
  207. package/src/lib/session.js +21 -0
  208. package/src/lib/store.js +638 -0
  209. package/src/lib/transactional-mailer.js +54 -0
  210. package/src/lib/validation.js +30 -0
  211. package/src/routes/api.js +485 -0
  212. package/src/routes/app.js +699 -0
  213. package/src/routes/auth.js +404 -0
  214. package/src/routes/webhooks.js +257 -0
  215. package/src/server.js +79 -0
  216. package/src/views/pages/admin.ejs +58 -0
  217. package/src/views/pages/api-keys.ejs +56 -0
  218. package/src/views/pages/billing.ejs +71 -0
  219. package/src/views/pages/domains.ejs +106 -0
  220. package/src/views/pages/inboxes.ejs +127 -0
  221. package/src/views/pages/login.ejs +57 -0
  222. package/src/views/pages/metrics.ejs +34 -0
  223. package/src/views/pages/reset-password.ejs +19 -0
  224. package/src/views/partials/bottom.ejs +3 -0
  225. package/src/views/partials/csrf-field.ejs +3 -0
  226. package/src/views/partials/flash.ejs +3 -0
  227. package/src/views/partials/top.ejs +130 -0
@@ -0,0 +1,638 @@
1
+ import { query, withTransaction } from '../db.js';
2
+ import { monthStartUtc, PLAN_LIMITS } from './plan.js';
3
+
4
+ export async function createUserWithDefaultOrg({ fullName, email, passwordHash }) {
5
+ return withTransaction(async (client) => {
6
+ const inserted = await client.query(
7
+ `INSERT INTO users (email, full_name, password_hash)
8
+ VALUES ($1, $2, $3)
9
+ RETURNING *`,
10
+ [email.toLowerCase(), fullName, passwordHash]
11
+ );
12
+ const user = inserted.rows[0];
13
+
14
+ const orgNameBase = fullName?.trim() || email.split('@')[0];
15
+ const orgInserted = await client.query(
16
+ 'INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING *',
17
+ [`${orgNameBase} Org`, 'free']
18
+ );
19
+ const organization = orgInserted.rows[0];
20
+ await client.query(
21
+ 'INSERT INTO memberships (user_id, org_id, role) VALUES ($1, $2, $3)',
22
+ [user.id, organization.id, 'owner']
23
+ );
24
+
25
+ return { user, organization };
26
+ });
27
+ }
28
+
29
+ export async function getUserByEmail(email) {
30
+ const result = await query('SELECT * FROM users WHERE email = $1', [String(email ?? '').toLowerCase()]);
31
+ return result.rows[0] ?? null;
32
+ }
33
+
34
+ export async function markUserEmailVerified(userId) {
35
+ const result = await query(
36
+ `UPDATE users
37
+ SET email_verified_at = COALESCE(email_verified_at, NOW())
38
+ WHERE id = $1
39
+ RETURNING *`,
40
+ [userId]
41
+ );
42
+ return result.rows[0] ?? null;
43
+ }
44
+
45
+ export async function updateUserPasswordHash(userId, passwordHash) {
46
+ const result = await query(
47
+ `UPDATE users
48
+ SET password_hash = $2
49
+ WHERE id = $1
50
+ RETURNING *`,
51
+ [userId, passwordHash]
52
+ );
53
+ return result.rows[0] ?? null;
54
+ }
55
+
56
+ export async function createAuthToken({ userId, purpose, tokenHash, expiresAt }) {
57
+ const result = await query(
58
+ `INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
59
+ VALUES ($1, $2, $3, $4)
60
+ RETURNING *`,
61
+ [userId, purpose, tokenHash, expiresAt]
62
+ );
63
+ return result.rows[0];
64
+ }
65
+
66
+ export async function consumeAuthToken({ purpose, tokenHash }) {
67
+ const result = await query(
68
+ `UPDATE auth_tokens
69
+ SET used_at = NOW()
70
+ WHERE purpose = $1
71
+ AND token_hash = $2
72
+ AND used_at IS NULL
73
+ AND expires_at > NOW()
74
+ RETURNING *`,
75
+ [purpose, tokenHash]
76
+ );
77
+ return result.rows[0] ?? null;
78
+ }
79
+
80
+ export async function getOrCreateDefaultOrganizationForUser({ userId, fullName, email }) {
81
+ return withTransaction(async (client) => {
82
+ const membershipResult = await client.query(
83
+ `SELECT o.*
84
+ FROM memberships m
85
+ JOIN organizations o ON o.id = m.org_id
86
+ WHERE m.user_id = $1
87
+ ORDER BY m.created_at ASC
88
+ LIMIT 1`,
89
+ [userId]
90
+ );
91
+
92
+ if (membershipResult.rowCount === 0) {
93
+ const orgNameBase = fullName?.trim() || email.split('@')[0];
94
+ const orgInserted = await client.query(
95
+ 'INSERT INTO organizations (name, plan) VALUES ($1, $2) RETURNING *',
96
+ [`${orgNameBase} Org`, 'free']
97
+ );
98
+ const organization = orgInserted.rows[0];
99
+ await client.query(
100
+ 'INSERT INTO memberships (user_id, org_id, role) VALUES ($1, $2, $3)',
101
+ [userId, organization.id, 'owner']
102
+ );
103
+ return organization;
104
+ }
105
+
106
+ return membershipResult.rows[0];
107
+ });
108
+ }
109
+
110
+ export async function getUserById(userId) {
111
+ const result = await query('SELECT * FROM users WHERE id = $1', [userId]);
112
+ return result.rows[0] ?? null;
113
+ }
114
+
115
+ export async function getOrganizationForUser(userId, orgId) {
116
+ const result = await query(
117
+ `SELECT o.*
118
+ FROM memberships m
119
+ JOIN organizations o ON o.id = m.org_id
120
+ WHERE m.user_id = $1 AND o.id = $2
121
+ LIMIT 1`,
122
+ [userId, orgId]
123
+ );
124
+ return result.rows[0] ?? null;
125
+ }
126
+
127
+ export async function getOrganizationById(orgId) {
128
+ const result = await query('SELECT * FROM organizations WHERE id = $1', [orgId]);
129
+ return result.rows[0] ?? null;
130
+ }
131
+
132
+ export async function updateOrganizationPlan(orgId, plan) {
133
+ const result = await query(
134
+ `UPDATE organizations
135
+ SET plan = $2
136
+ WHERE id = $1
137
+ RETURNING *`,
138
+ [orgId, plan]
139
+ );
140
+ return result.rows[0] ?? null;
141
+ }
142
+
143
+ function mapPlanRowToLimits(row) {
144
+ return {
145
+ maxInboxes: row.max_inboxes,
146
+ maxCustomDomains: row.max_custom_domains,
147
+ maxApiKeys: row.max_api_keys,
148
+ monthlyEmails: row.monthly_emails
149
+ };
150
+ }
151
+
152
+ export async function getPlanLimitsForPlan(plan) {
153
+ const safePlan = plan === 'paid' ? 'paid' : 'free';
154
+ const result = await query(
155
+ `SELECT max_inboxes, max_custom_domains, max_api_keys, monthly_emails
156
+ FROM plan_limits
157
+ WHERE plan = $1`,
158
+ [safePlan]
159
+ );
160
+
161
+ if (result.rowCount === 0) {
162
+ return PLAN_LIMITS[safePlan];
163
+ }
164
+
165
+ return mapPlanRowToLimits(result.rows[0]);
166
+ }
167
+
168
+ export async function getAllPlanLimits() {
169
+ const result = await query(
170
+ `SELECT plan, max_inboxes, max_custom_domains, max_api_keys, monthly_emails
171
+ FROM plan_limits
172
+ ORDER BY plan`
173
+ );
174
+
175
+ const merged = {
176
+ free: { ...PLAN_LIMITS.free },
177
+ paid: { ...PLAN_LIMITS.paid }
178
+ };
179
+
180
+ for (const row of result.rows) {
181
+ if (row.plan === 'free' || row.plan === 'paid') {
182
+ merged[row.plan] = mapPlanRowToLimits(row);
183
+ }
184
+ }
185
+
186
+ return merged;
187
+ }
188
+
189
+ export async function upsertPlanLimits(plan, limits) {
190
+ const safePlan = plan === 'paid' ? 'paid' : 'free';
191
+ const result = await query(
192
+ `INSERT INTO plan_limits (plan, max_inboxes, max_custom_domains, max_api_keys, monthly_emails, updated_at)
193
+ VALUES ($1, $2, $3, $4, $5, NOW())
194
+ ON CONFLICT (plan)
195
+ DO UPDATE SET
196
+ max_inboxes = EXCLUDED.max_inboxes,
197
+ max_custom_domains = EXCLUDED.max_custom_domains,
198
+ max_api_keys = EXCLUDED.max_api_keys,
199
+ monthly_emails = EXCLUDED.monthly_emails,
200
+ updated_at = NOW()
201
+ RETURNING plan, max_inboxes, max_custom_domains, max_api_keys, monthly_emails`,
202
+ [safePlan, limits.maxInboxes, limits.maxCustomDomains, limits.maxApiKeys, limits.monthlyEmails]
203
+ );
204
+ return mapPlanRowToLimits(result.rows[0]);
205
+ }
206
+
207
+ export async function listInboxes(orgId) {
208
+ const result = await query(
209
+ `SELECT *
210
+ FROM inboxes
211
+ WHERE org_id = $1 AND status = 'active'
212
+ ORDER BY created_at DESC`,
213
+ [orgId]
214
+ );
215
+ return result.rows;
216
+ }
217
+
218
+ export async function countActiveInboxes(orgId) {
219
+ const result = await query(
220
+ `SELECT COUNT(*)::int AS count
221
+ FROM inboxes
222
+ WHERE org_id = $1 AND status = 'active'`,
223
+ [orgId]
224
+ );
225
+ return result.rows[0].count;
226
+ }
227
+
228
+ export async function createInbox({ orgId, localPart, domainName, displayName }) {
229
+ const emailAddress = `${localPart}@${domainName}`.toLowerCase();
230
+ const result = await query(
231
+ `INSERT INTO inboxes (org_id, local_part, domain_name, email_address, display_name)
232
+ VALUES ($1, $2, $3, $4, $5)
233
+ RETURNING *`,
234
+ [orgId, localPart.toLowerCase(), domainName.toLowerCase(), emailAddress, displayName ?? null]
235
+ );
236
+ return result.rows[0];
237
+ }
238
+
239
+ export async function getInboxById(orgId, inboxId) {
240
+ const result = await query(
241
+ `SELECT * FROM inboxes WHERE org_id = $1 AND id = $2 AND status = 'active'`,
242
+ [orgId, inboxId]
243
+ );
244
+ return result.rows[0] ?? null;
245
+ }
246
+
247
+ export async function getInboxByEmail(emailAddress) {
248
+ const result = await query(
249
+ `SELECT * FROM inboxes WHERE email_address = $1 AND status = 'active' LIMIT 1`,
250
+ [String(emailAddress ?? '').toLowerCase()]
251
+ );
252
+ return result.rows[0] ?? null;
253
+ }
254
+
255
+ export async function updateInboxDisplayName(orgId, inboxId, displayName) {
256
+ const result = await query(
257
+ `UPDATE inboxes
258
+ SET display_name = $3, updated_at = NOW()
259
+ WHERE org_id = $1 AND id = $2 AND status = 'active'
260
+ RETURNING *`,
261
+ [orgId, inboxId, displayName || null]
262
+ );
263
+ return result.rows[0] ?? null;
264
+ }
265
+
266
+ export async function deleteInbox(orgId, inboxId) {
267
+ const result = await query(
268
+ `UPDATE inboxes
269
+ SET status = 'deleted', deleted_at = NOW(), updated_at = NOW()
270
+ WHERE org_id = $1 AND id = $2 AND status = 'active'
271
+ RETURNING *`,
272
+ [orgId, inboxId]
273
+ );
274
+ return result.rows[0] ?? null;
275
+ }
276
+
277
+ export async function listDomains(orgId) {
278
+ const result = await query(
279
+ `SELECT *
280
+ FROM domains
281
+ WHERE org_id = $1 AND status <> 'deleted'
282
+ ORDER BY created_at DESC`,
283
+ [orgId]
284
+ );
285
+ return result.rows;
286
+ }
287
+
288
+ export async function countCustomDomains(orgId) {
289
+ const result = await query(
290
+ `SELECT COUNT(*)::int AS count
291
+ FROM domains
292
+ WHERE org_id = $1 AND status <> 'deleted'`,
293
+ [orgId]
294
+ );
295
+ return result.rows[0].count;
296
+ }
297
+
298
+ export async function createDomain({ orgId, name, providerDomainId, providerRouteId, status, dnsRecords }) {
299
+ const result = await query(
300
+ `INSERT INTO domains (org_id, name, provider_domain_id, provider_route_id, status, dns_records_json)
301
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb)
302
+ RETURNING *`,
303
+ [
304
+ orgId,
305
+ name.toLowerCase(),
306
+ providerDomainId ?? null,
307
+ providerRouteId ?? null,
308
+ status ?? 'pending',
309
+ JSON.stringify(dnsRecords ?? [])
310
+ ]
311
+ );
312
+ return result.rows[0];
313
+ }
314
+
315
+ export async function getDomainById(orgId, domainId) {
316
+ const result = await query(
317
+ `SELECT * FROM domains WHERE org_id = $1 AND id = $2 AND status <> 'deleted'`,
318
+ [orgId, domainId]
319
+ );
320
+ return result.rows[0] ?? null;
321
+ }
322
+
323
+ export async function updateDomainStatusAndRecords(orgId, domainId, status, dnsRecords, providerRouteId = undefined) {
324
+ const routeValue = providerRouteId === undefined ? null : providerRouteId;
325
+ const result = await query(
326
+ `UPDATE domains
327
+ SET status = $3,
328
+ dns_records_json = $4::jsonb,
329
+ provider_route_id = COALESCE($5, provider_route_id),
330
+ updated_at = NOW()
331
+ WHERE org_id = $1 AND id = $2
332
+ RETURNING *`,
333
+ [orgId, domainId, status, JSON.stringify(dnsRecords ?? []), routeValue]
334
+ );
335
+ return result.rows[0] ?? null;
336
+ }
337
+
338
+ export async function deleteDomain(orgId, domainId) {
339
+ const result = await query(
340
+ `UPDATE domains
341
+ SET status = 'deleted', updated_at = NOW()
342
+ WHERE org_id = $1 AND id = $2 AND status <> 'deleted'
343
+ RETURNING *`,
344
+ [orgId, domainId]
345
+ );
346
+ return result.rows[0] ?? null;
347
+ }
348
+
349
+ export async function createApiKey({ orgId, name, keyPrefix, keyHash, scopes }) {
350
+ const result = await query(
351
+ `INSERT INTO api_keys (org_id, name, key_prefix, key_hash, scopes)
352
+ VALUES ($1, $2, $3, $4, $5)
353
+ RETURNING id, org_id, name, key_prefix, scopes, created_at`,
354
+ [orgId, name, keyPrefix, keyHash, scopes]
355
+ );
356
+ return result.rows[0];
357
+ }
358
+
359
+ export async function listApiKeys(orgId) {
360
+ const result = await query(
361
+ `SELECT id, name, key_prefix, scopes, last_used_at, revoked_at, created_at
362
+ FROM api_keys
363
+ WHERE org_id = $1
364
+ ORDER BY created_at DESC`,
365
+ [orgId]
366
+ );
367
+ return result.rows;
368
+ }
369
+
370
+ export async function countActiveApiKeys(orgId) {
371
+ const result = await query(
372
+ `SELECT COUNT(*)::int AS count
373
+ FROM api_keys
374
+ WHERE org_id = $1 AND revoked_at IS NULL`,
375
+ [orgId]
376
+ );
377
+ return result.rows[0].count;
378
+ }
379
+
380
+ export async function getActiveApiKeyById(orgId, apiKeyId) {
381
+ const result = await query(
382
+ `SELECT id, org_id, name, key_prefix, scopes, created_at
383
+ FROM api_keys
384
+ WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL
385
+ LIMIT 1`,
386
+ [orgId, apiKeyId]
387
+ );
388
+ return result.rows[0] ?? null;
389
+ }
390
+
391
+ export async function revokeApiKey(orgId, apiKeyId) {
392
+ const result = await query(
393
+ `UPDATE api_keys
394
+ SET revoked_at = NOW()
395
+ WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL
396
+ RETURNING id`,
397
+ [orgId, apiKeyId]
398
+ );
399
+ return result.rowCount > 0;
400
+ }
401
+
402
+ export async function getApiKeyByHash(hash) {
403
+ const result = await query(
404
+ `SELECT *
405
+ FROM api_keys
406
+ WHERE key_hash = $1 AND revoked_at IS NULL
407
+ LIMIT 1`,
408
+ [hash]
409
+ );
410
+ return result.rows[0] ?? null;
411
+ }
412
+
413
+ export async function touchApiKey(apiKeyId) {
414
+ await query('UPDATE api_keys SET last_used_at = NOW() WHERE id = $1', [apiKeyId]);
415
+ }
416
+
417
+ export async function listEmails(orgId, inboxId) {
418
+ const result = await query(
419
+ `SELECT *
420
+ FROM emails
421
+ WHERE org_id = $1
422
+ AND ($2::uuid IS NULL OR inbox_id = $2)
423
+ AND status = 'active'
424
+ ORDER BY created_at DESC
425
+ LIMIT 200`,
426
+ [orgId, inboxId ?? null]
427
+ );
428
+ return result.rows;
429
+ }
430
+
431
+ export async function getEmailById(orgId, emailId) {
432
+ const result = await query(
433
+ `SELECT *
434
+ FROM emails
435
+ WHERE org_id = $1 AND id = $2 AND status = 'active'`,
436
+ [orgId, emailId]
437
+ );
438
+ return result.rows[0] ?? null;
439
+ }
440
+
441
+ export async function createEmail({
442
+ orgId,
443
+ inboxId,
444
+ direction,
445
+ deliveryStatus,
446
+ messageId,
447
+ subject,
448
+ fromAddress,
449
+ toAddresses,
450
+ textBody,
451
+ htmlBody,
452
+ providerPayload,
453
+ isRead = false
454
+ }) {
455
+ const result = await query(
456
+ `INSERT INTO emails (
457
+ org_id, inbox_id, direction, delivery_status, message_id, subject, from_address,
458
+ to_addresses, text_body, html_body, provider_payload, is_read
459
+ )
460
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12)
461
+ RETURNING *`,
462
+ [
463
+ orgId,
464
+ inboxId ?? null,
465
+ direction,
466
+ deliveryStatus ?? 'unknown',
467
+ messageId ?? null,
468
+ subject ?? '',
469
+ fromAddress,
470
+ toAddresses ?? [],
471
+ textBody ?? null,
472
+ htmlBody ?? null,
473
+ JSON.stringify(providerPayload ?? {}),
474
+ isRead
475
+ ]
476
+ );
477
+ return result.rows[0];
478
+ }
479
+
480
+ export async function updateEmailReadStatus(orgId, emailId, isRead) {
481
+ const result = await query(
482
+ `UPDATE emails
483
+ SET is_read = $3
484
+ WHERE org_id = $1 AND id = $2 AND status = 'active'
485
+ RETURNING *`,
486
+ [orgId, emailId, Boolean(isRead)]
487
+ );
488
+ return result.rows[0] ?? null;
489
+ }
490
+
491
+ export async function updateEmailDeliveryStatus(orgId, messageId, deliveryStatus, providerPayload) {
492
+ const result = await query(
493
+ `UPDATE emails
494
+ SET delivery_status = $3,
495
+ provider_payload = COALESCE($4::jsonb, provider_payload)
496
+ WHERE org_id = $1 AND message_id = $2
497
+ RETURNING *`,
498
+ [orgId, messageId, deliveryStatus, providerPayload ? JSON.stringify(providerPayload) : null]
499
+ );
500
+ return result.rows;
501
+ }
502
+
503
+ export async function deleteEmail(orgId, emailId) {
504
+ const result = await query(
505
+ `UPDATE emails
506
+ SET status = 'deleted'
507
+ WHERE org_id = $1 AND id = $2 AND status = 'active'
508
+ RETURNING *`,
509
+ [orgId, emailId]
510
+ );
511
+ return result.rows[0] ?? null;
512
+ }
513
+
514
+ export async function incrementUsage(orgId, type, amount = 1) {
515
+ const monthStart = monthStartUtc();
516
+ if (type !== 'sent' && type !== 'inbound') {
517
+ throw new Error('Invalid usage type');
518
+ }
519
+
520
+ const sentDelta = type === 'sent' ? amount : 0;
521
+ const inboundDelta = type === 'inbound' ? amount : 0;
522
+
523
+ await query(
524
+ `INSERT INTO usage_monthly (org_id, month_start, sent_count, inbound_count)
525
+ VALUES ($1, $2, $3, $4)
526
+ ON CONFLICT (org_id, month_start)
527
+ DO UPDATE SET
528
+ sent_count = usage_monthly.sent_count + EXCLUDED.sent_count,
529
+ inbound_count = usage_monthly.inbound_count + EXCLUDED.inbound_count,
530
+ updated_at = NOW()`,
531
+ [orgId, monthStart, sentDelta, inboundDelta]
532
+ );
533
+ }
534
+
535
+ export async function getUsageForCurrentMonth(orgId) {
536
+ const monthStart = monthStartUtc();
537
+ const result = await query(
538
+ `SELECT sent_count, inbound_count
539
+ FROM usage_monthly
540
+ WHERE org_id = $1 AND month_start = $2`,
541
+ [orgId, monthStart]
542
+ );
543
+
544
+ if (result.rowCount === 0) {
545
+ return { sent_count: 0, inbound_count: 0 };
546
+ }
547
+
548
+ return result.rows[0];
549
+ }
550
+
551
+ export async function cleanupExpiredIdempotency() {
552
+ await query('DELETE FROM idempotency_records WHERE expires_at < NOW()');
553
+ }
554
+
555
+ export async function getIdempotencyRecord(orgId, idempotencyKey, method, requestPath) {
556
+ const result = await query(
557
+ `SELECT *
558
+ FROM idempotency_records
559
+ WHERE org_id = $1
560
+ AND idempotency_key = $2
561
+ AND method = $3
562
+ AND request_path = $4
563
+ LIMIT 1`,
564
+ [orgId, idempotencyKey, method, requestPath]
565
+ );
566
+ return result.rows[0] ?? null;
567
+ }
568
+
569
+ export async function createIdempotencyRecord({ orgId, idempotencyKey, method, requestPath, requestHash }) {
570
+ const result = await query(
571
+ `INSERT INTO idempotency_records
572
+ (org_id, idempotency_key, method, request_path, request_hash, status)
573
+ VALUES ($1, $2, $3, $4, $5, 'in_progress')
574
+ ON CONFLICT (org_id, idempotency_key, method, request_path)
575
+ DO NOTHING
576
+ RETURNING *`,
577
+ [orgId, idempotencyKey, method, requestPath, requestHash]
578
+ );
579
+ return result.rows[0] ?? null;
580
+ }
581
+
582
+ export async function completeIdempotencyRecord(recordId, responseStatus, responseBody) {
583
+ await query(
584
+ `UPDATE idempotency_records
585
+ SET status = 'completed',
586
+ response_status = $2,
587
+ response_body = $3::jsonb
588
+ WHERE id = $1`,
589
+ [recordId, responseStatus, JSON.stringify(responseBody ?? {})]
590
+ );
591
+ }
592
+
593
+ export async function deleteIdempotencyRecord(recordId) {
594
+ await query('DELETE FROM idempotency_records WHERE id = $1', [recordId]);
595
+ }
596
+
597
+ export async function recordWebhookEvent(provider, eventId, eventType, payload) {
598
+ const result = await query(
599
+ `INSERT INTO webhook_events (provider, event_id, event_type, payload)
600
+ VALUES ($1, $2, $3, $4::jsonb)
601
+ ON CONFLICT (provider, event_id)
602
+ DO NOTHING
603
+ RETURNING id`,
604
+ [provider, eventId, eventType ?? null, JSON.stringify(payload ?? {})]
605
+ );
606
+ return result.rowCount > 0;
607
+ }
608
+
609
+ export async function getOrgMetricsSnapshot(orgId) {
610
+ const [usage, inboxCount, domainCount, apiKeyCount] = await Promise.all([
611
+ getUsageForCurrentMonth(orgId),
612
+ countActiveInboxes(orgId),
613
+ countCustomDomains(orgId),
614
+ countActiveApiKeys(orgId)
615
+ ]);
616
+
617
+ return {
618
+ sentCount: usage.sent_count,
619
+ inboundCount: usage.inbound_count,
620
+ inboxCount,
621
+ customDomainCount: domainCount,
622
+ apiKeyCount
623
+ };
624
+ }
625
+
626
+ export async function getGlobalAdminMetrics() {
627
+ const [inboxes, domains, apiKeys] = await Promise.all([
628
+ query('SELECT COUNT(*)::bigint AS count FROM inboxes'),
629
+ query('SELECT COUNT(*)::bigint AS count FROM domains'),
630
+ query('SELECT COUNT(*)::bigint AS count FROM api_keys')
631
+ ]);
632
+
633
+ return {
634
+ totalInboxesCreated: Number(inboxes.rows[0].count),
635
+ totalDomainsCreated: Number(domains.rows[0].count),
636
+ totalApiKeysCreated: Number(apiKeys.rows[0].count)
637
+ };
638
+ }
@@ -0,0 +1,54 @@
1
+ import { config } from '../config.js';
2
+ import { mailgun } from './mailgun.js';
3
+ import {
4
+ buildPasswordResetEmailTemplate,
5
+ buildVerificationEmailTemplate,
6
+ buildWelcomeEmailTemplate
7
+ } from './email-templates.js';
8
+
9
+ function senderDomain() {
10
+ return String(config.mailgunAccountDomain || config.appSharedDomain || '').toLowerCase();
11
+ }
12
+
13
+ function senderFrom() {
14
+ if (config.mailFromEmail) {
15
+ return config.mailFromEmail;
16
+ }
17
+ const domain = senderDomain();
18
+ return `EmailAgent <no-reply@${domain}>`;
19
+ }
20
+
21
+ async function sendTemplateEmail({ to, template }) {
22
+ if (!mailgun.isConfigured()) {
23
+ return { skipped: true, reason: 'mailgun_not_configured' };
24
+ }
25
+
26
+ const domain = senderDomain();
27
+ if (!domain) {
28
+ throw new Error('No sending domain configured for transactional emails');
29
+ }
30
+
31
+ return mailgun.sendMessage({
32
+ domain,
33
+ from: senderFrom(),
34
+ to,
35
+ subject: template.subject,
36
+ text: template.text,
37
+ html: template.html
38
+ });
39
+ }
40
+
41
+ export async function sendVerificationEmail({ to, fullName, verifyUrl }) {
42
+ const template = buildVerificationEmailTemplate({ fullName, verifyUrl });
43
+ return sendTemplateEmail({ to, template });
44
+ }
45
+
46
+ export async function sendWelcomeEmail({ to, fullName }) {
47
+ const template = buildWelcomeEmailTemplate({ fullName, appUrl: config.appBaseUrl });
48
+ return sendTemplateEmail({ to, template });
49
+ }
50
+
51
+ export async function sendPasswordResetEmail({ to, fullName, resetUrl }) {
52
+ const template = buildPasswordResetEmailTemplate({ fullName, resetUrl });
53
+ return sendTemplateEmail({ to, template });
54
+ }
@@ -0,0 +1,30 @@
1
+ export function normalizeLocalPart(value) {
2
+ return String(value ?? '')
3
+ .trim()
4
+ .toLowerCase();
5
+ }
6
+
7
+ export function normalizeDomain(value) {
8
+ return String(value ?? '')
9
+ .trim()
10
+ .toLowerCase();
11
+ }
12
+
13
+ export function parseEmailList(csv) {
14
+ return String(csv ?? '')
15
+ .split(',')
16
+ .map((x) => x.trim())
17
+ .filter(Boolean);
18
+ }
19
+
20
+ export function isValidLocalPart(value) {
21
+ return /^[a-z0-9._%+-]{2,64}$/.test(value);
22
+ }
23
+
24
+ export function isValidDomain(value) {
25
+ return /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(value);
26
+ }
27
+
28
+ export function isValidEmail(value) {
29
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
30
+ }