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.
- package/.env.example +20 -0
- package/.github/workflows/docs-deploy.yml +37 -0
- package/.github/workflows/landing-preview.yml +43 -0
- package/.github/workflows/openapi-lint.yml +31 -0
- package/.github/workflows/sdk-generate-check.yml +66 -0
- package/.github/workflows/sdk-release.yml +62 -0
- package/CHANGELOG.md +10 -0
- package/README.md +208 -0
- package/RELEASING.md +43 -0
- package/docs/api-reference/api-keys.mdx +11 -0
- package/docs/api-reference/domains.mdx +13 -0
- package/docs/api-reference/emails.mdx +26 -0
- package/docs/api-reference/inboxes.mdx +13 -0
- package/docs/api-reference/metrics.mdx +10 -0
- package/docs/authentication.mdx +25 -0
- package/docs/docs.json +35 -0
- package/docs/errors.mdx +34 -0
- package/docs/favicon.svg +5 -0
- package/docs/idempotency.mdx +18 -0
- package/docs/index.mdx +24 -0
- package/docs/quickstart.mdx +134 -0
- package/landing/DEPLOYING.md +33 -0
- package/landing/favicon.svg +5 -0
- package/landing/index.html +129 -0
- package/landing/main.js +45 -0
- package/landing/privacy.html +29 -0
- package/landing/styles.css +356 -0
- package/landing/terms.html +29 -0
- package/netlify.toml +15 -0
- package/openapi/openapi.yaml +1016 -0
- package/package.json +34 -0
- package/render.yaml +48 -0
- package/scripts/generate-sdk-py.sh +16 -0
- package/scripts/generate-sdk-ts.sh +16 -0
- package/scripts/migrate.js +66 -0
- package/scripts/validate-docs.js +56 -0
- package/scripts/validate-landing.js +39 -0
- package/sdks/python/README.md +40 -0
- package/sdks/python/emailagent_sdk/__init__.py +157 -0
- package/sdks/python/generated/.openapi-generator/FILES +101 -0
- package/sdks/python/generated/.openapi-generator/VERSION +1 -0
- package/sdks/python/generated/.openapi-generator-ignore +23 -0
- package/sdks/python/generated/emailagent_sdk_generated/__init__.py +105 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/__init__.py +9 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/api_keys_api.py +1162 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/domains_api.py +1168 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/emails_api.py +1232 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/inboxes_api.py +1191 -0
- package/sdks/python/generated/emailagent_sdk_generated/api/metrics_api.py +285 -0
- package/sdks/python/generated/emailagent_sdk_generated/api_client.py +801 -0
- package/sdks/python/generated/emailagent_sdk_generated/api_response.py +21 -0
- package/sdks/python/generated/emailagent_sdk_generated/configuration.py +586 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/APIKeysApi.md +334 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyCreated.md +35 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyCreatedResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyListItem.md +35 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/ApiKeyListResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/CreateApiKeyRequest.md +30 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/CreateApiKeyRequestScopes.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/CreateDomainRequest.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/CreateInboxRequest.md +31 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/Domain.md +37 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/DomainListResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/DomainResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/DomainsApi.md +336 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/Email.md +43 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/EmailListResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/EmailResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/EmailsApi.md +353 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/ErrorResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/Inbox.md +38 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/InboxListResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/InboxResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/InboxesApi.md +337 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsApi.md +83 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsData.md +35 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/MetricsResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/OkResponse.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/PlanLimits.md +32 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/SendEmailRequest.md +32 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/UpdateEmailReadRequest.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/docs/UpdateInboxRequest.md +29 -0
- package/sdks/python/generated/emailagent_sdk_generated/exceptions.py +216 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/__init__.py +41 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/api_key_created.py +113 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/api_key_created_response.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/api_key_list_item.py +123 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/api_key_list_response.py +95 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/create_api_key_request.py +93 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/create_api_key_request_scopes.py +143 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/create_domain_request.py +87 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/create_inbox_request.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/domain.py +134 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/domain_list_response.py +95 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/domain_response.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/email.py +175 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/email_list_response.py +95 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/email_response.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/error_response.py +87 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/inbox.py +136 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/inbox_list_response.py +95 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/inbox_response.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/metrics_data.py +110 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/metrics_response.py +91 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/ok_response.py +87 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/plan_limits.py +93 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/send_email_request.py +93 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/update_email_read_request.py +87 -0
- package/sdks/python/generated/emailagent_sdk_generated/models/update_inbox_request.py +92 -0
- package/sdks/python/generated/emailagent_sdk_generated/rest.py +258 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/__init__.py +0 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_created.py +68 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_created_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_list_item.py +66 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_api_key_list_response.py +56 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_api_keys_api.py +59 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_create_api_key_request.py +53 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_create_api_key_request_scopes.py +50 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_create_domain_request.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_create_inbox_request.py +54 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_domain.py +70 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_domain_list_response.py +56 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_domain_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_domains_api.py +59 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_email.py +79 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_email_list_response.py +56 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_email_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_emails_api.py +59 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_error_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox.py +68 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox_list_response.py +56 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_inbox_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_inboxes_api.py +59 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_api.py +38 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_data.py +72 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_metrics_response.py +74 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_ok_response.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_plan_limits.py +58 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_send_email_request.py +56 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_update_email_read_request.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated/test/test_update_inbox_request.py +52 -0
- package/sdks/python/generated/emailagent_sdk_generated_README.md +140 -0
- package/sdks/python/openapitools.json +7 -0
- package/sdks/python/pyproject.toml +19 -0
- package/sdks/typescript/README.md +41 -0
- package/sdks/typescript/generated/.openapi-generator/FILES +41 -0
- package/sdks/typescript/generated/.openapi-generator/VERSION +1 -0
- package/sdks/typescript/generated/.openapi-generator-ignore +23 -0
- package/sdks/typescript/generated/package.json +21 -0
- package/sdks/typescript/generated/src/apis/APIKeysApi.ts +314 -0
- package/sdks/typescript/generated/src/apis/DomainsApi.ts +314 -0
- package/sdks/typescript/generated/src/apis/EmailsApi.ts +350 -0
- package/sdks/typescript/generated/src/apis/InboxesApi.ts +329 -0
- package/sdks/typescript/generated/src/apis/MetricsApi.ts +93 -0
- package/sdks/typescript/generated/src/apis/index.ts +7 -0
- package/sdks/typescript/generated/src/index.ts +5 -0
- package/sdks/typescript/generated/src/models/ApiKeyCreated.ts +123 -0
- package/sdks/typescript/generated/src/models/ApiKeyCreatedResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/ApiKeyListItem.ts +121 -0
- package/sdks/typescript/generated/src/models/ApiKeyListResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/CreateApiKeyRequest.ts +82 -0
- package/sdks/typescript/generated/src/models/CreateApiKeyRequestScopes.ts +45 -0
- package/sdks/typescript/generated/src/models/CreateDomainRequest.ts +66 -0
- package/sdks/typescript/generated/src/models/CreateInboxRequest.ts +82 -0
- package/sdks/typescript/generated/src/models/Domain.ts +152 -0
- package/sdks/typescript/generated/src/models/DomainListResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/DomainResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/Email.ts +222 -0
- package/sdks/typescript/generated/src/models/EmailListResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/EmailResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/ErrorResponse.ts +66 -0
- package/sdks/typescript/generated/src/models/Inbox.ts +159 -0
- package/sdks/typescript/generated/src/models/InboxListResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/InboxResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/MetricsData.ts +139 -0
- package/sdks/typescript/generated/src/models/MetricsResponse.ts +74 -0
- package/sdks/typescript/generated/src/models/OkResponse.ts +66 -0
- package/sdks/typescript/generated/src/models/PlanLimits.ts +93 -0
- package/sdks/typescript/generated/src/models/SendEmailRequest.ts +91 -0
- package/sdks/typescript/generated/src/models/UpdateEmailReadRequest.ts +66 -0
- package/sdks/typescript/generated/src/models/UpdateInboxRequest.ts +66 -0
- package/sdks/typescript/generated/src/models/index.ts +27 -0
- package/sdks/typescript/generated/src/runtime.ts +432 -0
- package/sdks/typescript/generated/tsconfig.esm.json +7 -0
- package/sdks/typescript/generated/tsconfig.json +16 -0
- package/sdks/typescript/openapitools.json +8 -0
- package/sdks/typescript/package.json +27 -0
- package/sdks/typescript/src/index.ts +138 -0
- package/sdks/typescript/tsconfig.json +14 -0
- package/sql/001_init.sql +143 -0
- package/sql/002_local_auth.sql +38 -0
- package/sql/003_domain_routes.sql +2 -0
- package/sql/004_reliability_primitives.sql +75 -0
- package/sql/005_auth_email_flows.sql +22 -0
- package/src/config.js +30 -0
- package/src/db.js +25 -0
- package/src/lib/api-auth.js +55 -0
- package/src/lib/auth.js +71 -0
- package/src/lib/csrf.js +46 -0
- package/src/lib/dodo.js +67 -0
- package/src/lib/email-templates.js +67 -0
- package/src/lib/idempotency.js +85 -0
- package/src/lib/mailgun.js +188 -0
- package/src/lib/plan.js +24 -0
- package/src/lib/rate-limit.js +43 -0
- package/src/lib/security.js +62 -0
- package/src/lib/session.js +21 -0
- package/src/lib/store.js +638 -0
- package/src/lib/transactional-mailer.js +54 -0
- package/src/lib/validation.js +30 -0
- package/src/routes/api.js +485 -0
- package/src/routes/app.js +699 -0
- package/src/routes/auth.js +404 -0
- package/src/routes/webhooks.js +257 -0
- package/src/server.js +79 -0
- package/src/views/pages/admin.ejs +58 -0
- package/src/views/pages/api-keys.ejs +56 -0
- package/src/views/pages/billing.ejs +71 -0
- package/src/views/pages/domains.ejs +106 -0
- package/src/views/pages/inboxes.ejs +127 -0
- package/src/views/pages/login.ejs +57 -0
- package/src/views/pages/metrics.ejs +34 -0
- package/src/views/pages/reset-password.ejs +19 -0
- package/src/views/partials/bottom.ejs +3 -0
- package/src/views/partials/csrf-field.ejs +3 -0
- package/src/views/partials/flash.ejs +3 -0
- package/src/views/partials/top.ejs +130 -0
package/src/lib/store.js
ADDED
|
@@ -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
|
+
}
|