@hogsend/engine 0.10.0 → 0.12.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/package.json +9 -7
- package/src/app.ts +28 -17
- package/src/container.ts +85 -20
- package/src/env.ts +40 -0
- package/src/index.ts +35 -2
- package/src/lib/auth.ts +78 -1
- package/src/lib/boot.ts +22 -0
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- package/src/lib/domain-status.ts +327 -0
- package/src/lib/email-providers-from-env.ts +5 -0
- package/src/lib/email-service-types.ts +8 -2
- package/src/lib/mailer.ts +131 -5
- package/src/lib/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/test-mode.ts +123 -0
- package/src/lib/tracked.ts +93 -6
- package/src/middleware/rate-limit.ts +38 -3
- package/src/routes/admin/domain.ts +181 -0
- package/src/routes/admin/index.ts +2 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Admin sending-domain routes. The provider's optional `domains` capability is
|
|
7
|
+
* the gate: when the active provider has none, the POSTs return 501
|
|
8
|
+
* `{ error: "provider_unsupported" }` and the GET reports `supported: false`.
|
|
9
|
+
* Provider API keys never leave the server — the CLI (`hogsend domain`) and
|
|
10
|
+
* Studio's Setup view only ever talk to these routes.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Mirrors the pinned `DnsRecord` shape (@hogsend/core providers/domains.ts).
|
|
14
|
+
const DnsRecordSchema = z.object({
|
|
15
|
+
type: z.enum(["TXT", "CNAME", "MX"]),
|
|
16
|
+
name: z.string(),
|
|
17
|
+
value: z.string(),
|
|
18
|
+
ttl: z.number().optional(),
|
|
19
|
+
priority: z.number().optional(),
|
|
20
|
+
purpose: z.enum([
|
|
21
|
+
"verification",
|
|
22
|
+
"spf",
|
|
23
|
+
"dkim",
|
|
24
|
+
"return_path",
|
|
25
|
+
"tracking",
|
|
26
|
+
"mx",
|
|
27
|
+
"other",
|
|
28
|
+
]),
|
|
29
|
+
status: z.enum(["pending", "verified", "failed", "unknown"]),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Mirrors the pinned `DomainStatus` shape.
|
|
33
|
+
const DomainStatusSchema = z.object({
|
|
34
|
+
domain: z.string(),
|
|
35
|
+
state: z.enum(["not_found", "pending", "verified", "failed"]),
|
|
36
|
+
records: z.array(DnsRecordSchema),
|
|
37
|
+
providerId: z.string(),
|
|
38
|
+
checkedAt: z.string(),
|
|
39
|
+
raw: z.unknown().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Mirrors the pinned `TestModeState` shape (stubbed inactive until F3).
|
|
43
|
+
const TestModeStateSchema = z.object({
|
|
44
|
+
active: z.boolean(),
|
|
45
|
+
reason: z.enum(["env_flag", "domain_unverified"]).nullable(),
|
|
46
|
+
redirectTo: z.string().nullable(),
|
|
47
|
+
fromOverride: z.string().nullable(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Mirrors the pinned `EngineDomainStatus` shape.
|
|
51
|
+
const EngineDomainStatusSchema = z.object({
|
|
52
|
+
domain: z.string().nullable(),
|
|
53
|
+
providerId: z.string(),
|
|
54
|
+
supported: z.boolean(),
|
|
55
|
+
status: DomainStatusSchema.nullable(),
|
|
56
|
+
testMode: TestModeStateSchema,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Pinned domain validation regex (PROJECT_SPEC §e). */
|
|
60
|
+
const DOMAIN_RE = /^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/i;
|
|
61
|
+
|
|
62
|
+
const getDomainRoute = createRoute({
|
|
63
|
+
method: "get",
|
|
64
|
+
path: "/",
|
|
65
|
+
tags: ["Admin — Domain"],
|
|
66
|
+
summary: "Sending-domain status (records, verification state, test mode)",
|
|
67
|
+
request: {
|
|
68
|
+
query: z.object({
|
|
69
|
+
refresh: z.coerce.boolean().optional(),
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
responses: {
|
|
73
|
+
200: {
|
|
74
|
+
content: {
|
|
75
|
+
"application/json": { schema: EngineDomainStatusSchema },
|
|
76
|
+
},
|
|
77
|
+
description:
|
|
78
|
+
"Cached domain status for the active email provider; ?refresh=true forces a provider round-trip",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const addDomainRoute = createRoute({
|
|
84
|
+
method: "post",
|
|
85
|
+
path: "/",
|
|
86
|
+
tags: ["Admin — Domain"],
|
|
87
|
+
summary: "Register the sending domain with the active email provider",
|
|
88
|
+
request: {
|
|
89
|
+
body: {
|
|
90
|
+
content: {
|
|
91
|
+
"application/json": {
|
|
92
|
+
schema: z.object({
|
|
93
|
+
domain: z.string().regex(DOMAIN_RE, "invalid domain"),
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
responses: {
|
|
100
|
+
200: {
|
|
101
|
+
content: {
|
|
102
|
+
"application/json": { schema: EngineDomainStatusSchema },
|
|
103
|
+
},
|
|
104
|
+
description: "Domain registered (idempotent) — fresh status",
|
|
105
|
+
},
|
|
106
|
+
501: {
|
|
107
|
+
content: { "application/json": { schema: errorSchema } },
|
|
108
|
+
description: "The active provider has no domains capability",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const verifyDomainRoute = createRoute({
|
|
114
|
+
method: "post",
|
|
115
|
+
path: "/verify",
|
|
116
|
+
tags: ["Admin — Domain"],
|
|
117
|
+
summary: "Trigger a provider-side verification pass for the sending domain",
|
|
118
|
+
responses: {
|
|
119
|
+
200: {
|
|
120
|
+
content: {
|
|
121
|
+
"application/json": { schema: EngineDomainStatusSchema },
|
|
122
|
+
},
|
|
123
|
+
description: "Verification pass triggered — fresh status",
|
|
124
|
+
},
|
|
125
|
+
400: {
|
|
126
|
+
content: { "application/json": { schema: errorSchema } },
|
|
127
|
+
description: "No sending domain configured (EMAIL_DOMAIN / EMAIL_FROM)",
|
|
128
|
+
},
|
|
129
|
+
501: {
|
|
130
|
+
content: { "application/json": { schema: errorSchema } },
|
|
131
|
+
description: "The active provider has no domains capability",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
export const domainRouter = new OpenAPIHono<AppEnv>()
|
|
137
|
+
.openapi(getDomainRoute, async (c) => {
|
|
138
|
+
const { domainStatus } = c.get("container");
|
|
139
|
+
const { refresh } = c.req.valid("query");
|
|
140
|
+
const status = await domainStatus.getStatus({ refresh });
|
|
141
|
+
return c.json(status, 200);
|
|
142
|
+
})
|
|
143
|
+
.openapi(addDomainRoute, async (c) => {
|
|
144
|
+
const { domainStatus, emailProvider } = c.get("container");
|
|
145
|
+
const { domain } = c.req.valid("json");
|
|
146
|
+
|
|
147
|
+
if (!emailProvider.domains) {
|
|
148
|
+
return c.json({ error: "provider_unsupported" }, 501);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Idempotent at the provider (an existing domain falls through to lookup).
|
|
152
|
+
await emailProvider.domains.create(domain);
|
|
153
|
+
|
|
154
|
+
// Bust + refresh the cached snapshot so the response reflects the create.
|
|
155
|
+
const status = await domainStatus.getStatus({ refresh: true });
|
|
156
|
+
return c.json(status, 200);
|
|
157
|
+
})
|
|
158
|
+
.openapi(verifyDomainRoute, async (c) => {
|
|
159
|
+
const { domainStatus, emailProvider } = c.get("container");
|
|
160
|
+
|
|
161
|
+
if (!emailProvider.domains) {
|
|
162
|
+
return c.json({ error: "provider_unsupported" }, 501);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const current = await domainStatus.getStatus();
|
|
166
|
+
if (!current.domain) {
|
|
167
|
+
return c.json({ error: "no_domain_configured" }, 400);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Prefer the provider's explicit verification pass; fall back to a plain
|
|
171
|
+
// status fetch for providers without one.
|
|
172
|
+
const capability = emailProvider.domains;
|
|
173
|
+
if (capability.verify) {
|
|
174
|
+
await capability.verify(current.domain);
|
|
175
|
+
} else {
|
|
176
|
+
await capability.get(current.domain);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const status = await domainStatus.getStatus({ refresh: true });
|
|
180
|
+
return c.json(status, 200);
|
|
181
|
+
});
|
|
@@ -10,6 +10,7 @@ import { bucketsRouter } from "./buckets.js";
|
|
|
10
10
|
import { bulkRouter } from "./bulk.js";
|
|
11
11
|
import { contactsRouter } from "./contacts.js";
|
|
12
12
|
import { dlqRouter } from "./dlq.js";
|
|
13
|
+
import { domainRouter } from "./domain.js";
|
|
13
14
|
import { emailsRouter } from "./emails.js";
|
|
14
15
|
import { eventsRouter } from "./events.js";
|
|
15
16
|
import { journeyLogsRouter } from "./journey-logs.js";
|
|
@@ -44,3 +45,4 @@ adminRouter.route("/webhooks", webhooksRouter);
|
|
|
44
45
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
45
46
|
adminRouter.route("/alerts", alertsRouter);
|
|
46
47
|
adminRouter.route("/dlq", dlqRouter);
|
|
48
|
+
adminRouter.route("/domain", domainRouter);
|