@hogsend/core 0.11.0 → 0.12.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/core",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,7 +32,7 @@
32
32
  "drizzle-orm": "^0.45.2",
33
33
  "iana-db-timezones": "^0.3.0",
34
34
  "zod": "^4.4.3",
35
- "@hogsend/db": "^0.11.0"
35
+ "@hogsend/db": "^0.12.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "latest",
@@ -0,0 +1,104 @@
1
+ import { describe, expectTypeOf, it } from "vitest";
2
+ import type {
3
+ DnsRecord,
4
+ DnsRecordPurpose,
5
+ DnsRecordStatus,
6
+ DomainStatus,
7
+ DomainsCapability,
8
+ DomainVerificationState,
9
+ } from "./domains.js";
10
+ import { defineEmailProvider, type EmailProvider } from "./email.js";
11
+
12
+ describe("DnsRecord contract (pinned in PROJECT_SPEC §a)", () => {
13
+ it("pins the literal unions", () => {
14
+ expectTypeOf<DnsRecordPurpose>().toEqualTypeOf<
15
+ | "verification"
16
+ | "spf"
17
+ | "dkim"
18
+ | "return_path"
19
+ | "tracking"
20
+ | "mx"
21
+ | "other"
22
+ >();
23
+ expectTypeOf<DnsRecordStatus>().toEqualTypeOf<
24
+ "pending" | "verified" | "failed" | "unknown"
25
+ >();
26
+ expectTypeOf<DnsRecord["type"]>().toEqualTypeOf<"TXT" | "CNAME" | "MX">();
27
+ expectTypeOf<DnsRecord["name"]>().toEqualTypeOf<string>();
28
+ expectTypeOf<DnsRecord["value"]>().toEqualTypeOf<string>();
29
+ expectTypeOf<DnsRecord["ttl"]>().toEqualTypeOf<number | undefined>();
30
+ expectTypeOf<DnsRecord["priority"]>().toEqualTypeOf<number | undefined>();
31
+ expectTypeOf<DnsRecord["purpose"]>().toEqualTypeOf<DnsRecordPurpose>();
32
+ expectTypeOf<DnsRecord["status"]>().toEqualTypeOf<DnsRecordStatus>();
33
+ });
34
+ });
35
+
36
+ describe("DomainStatus contract", () => {
37
+ it("pins the verification state union + member types", () => {
38
+ expectTypeOf<DomainVerificationState>().toEqualTypeOf<
39
+ "not_found" | "pending" | "verified" | "failed"
40
+ >();
41
+ expectTypeOf<DomainStatus["domain"]>().toEqualTypeOf<string>();
42
+ expectTypeOf<
43
+ DomainStatus["state"]
44
+ >().toEqualTypeOf<DomainVerificationState>();
45
+ expectTypeOf<DomainStatus["records"]>().toEqualTypeOf<DnsRecord[]>();
46
+ expectTypeOf<DomainStatus["providerId"]>().toEqualTypeOf<string>();
47
+ expectTypeOf<DomainStatus["checkedAt"]>().toEqualTypeOf<string>();
48
+ expectTypeOf<DomainStatus["raw"]>().toEqualTypeOf<unknown>();
49
+ });
50
+ });
51
+
52
+ describe("DomainsCapability contract", () => {
53
+ it("pins the method signatures", () => {
54
+ expectTypeOf<DomainsCapability["create"]>().toEqualTypeOf<
55
+ (domain: string) => Promise<DomainStatus>
56
+ >();
57
+ expectTypeOf<DomainsCapability["get"]>().toEqualTypeOf<
58
+ (domain: string) => Promise<DomainStatus | null>
59
+ >();
60
+ expectTypeOf<DomainsCapability["records"]>().toEqualTypeOf<
61
+ (domain: string) => Promise<DnsRecord[]>
62
+ >();
63
+ expectTypeOf<DomainsCapability["verify"]>().toEqualTypeOf<
64
+ ((domain: string) => Promise<DomainStatus>) | undefined
65
+ >();
66
+ });
67
+
68
+ it("is an OPTIONAL EmailProvider member — presence is the capability gate", () => {
69
+ expectTypeOf<EmailProvider["domains"]>().toEqualTypeOf<
70
+ DomainsCapability | undefined
71
+ >();
72
+ });
73
+
74
+ it("round-trips through defineEmailProvider", () => {
75
+ const status: DomainStatus = {
76
+ domain: "mysite.com",
77
+ state: "pending",
78
+ records: [],
79
+ providerId: "fake",
80
+ checkedAt: new Date().toISOString(),
81
+ };
82
+ const domains: DomainsCapability = {
83
+ create: async () => status,
84
+ get: async () => null,
85
+ records: async () => [],
86
+ verify: async () => status,
87
+ };
88
+ const provider = defineEmailProvider({
89
+ meta: { id: "fake", name: "Fake" },
90
+ send: async () => ({ id: "1" }),
91
+ sendBatch: async () => ({ results: [] }),
92
+ verifyWebhook: () => {
93
+ throw new Error("unused");
94
+ },
95
+ parseWebhook: () => {
96
+ throw new Error("unused");
97
+ },
98
+ domains,
99
+ });
100
+ expectTypeOf(provider.domains).toEqualTypeOf<
101
+ DomainsCapability | undefined
102
+ >();
103
+ });
104
+ });
@@ -0,0 +1,100 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Provider-neutral sending-domain contract (the OPTIONAL `domains` capability)
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /**
6
+ * What a DNS record is FOR, provider-neutrally. Each provider's domains
7
+ * implementation maps its own labels onto this union (Resend: SPF/DKIM record
8
+ * kinds; Postmark: DKIM + Return-Path). `other` is the conservative fallback
9
+ * for anything a provider invents that has no neutral name yet.
10
+ */
11
+ export type DnsRecordPurpose =
12
+ | "verification"
13
+ | "spf"
14
+ | "dkim"
15
+ | "return_path"
16
+ | "tracking"
17
+ | "mx"
18
+ | "other";
19
+
20
+ /**
21
+ * Per-record verification status as the PROVIDER reports it. `unknown` is for
22
+ * providers that don't report per-record status at all — the overall
23
+ * {@link DomainStatus.state} remains the authoritative signal.
24
+ */
25
+ export type DnsRecordStatus = "pending" | "verified" | "failed" | "unknown";
26
+
27
+ /**
28
+ * One DNS record the operator must add at their DNS host. This is the neutral
29
+ * shape every surface renders (admin route, `hogsend domain`, Studio Setup) and
30
+ * the CLI auto-apply (`dns-apply.ts`) writes via the Cloudflare/Vercel APIs.
31
+ */
32
+ export interface DnsRecord {
33
+ type: "TXT" | "CNAME" | "MX";
34
+ /** Host as the provider reports it (may be relative, e.g. "resend._domainkey"). */
35
+ name: string;
36
+ value: string;
37
+ ttl?: number;
38
+ /** MX only. */
39
+ priority?: number;
40
+ purpose: DnsRecordPurpose;
41
+ status: DnsRecordStatus;
42
+ }
43
+
44
+ /**
45
+ * Overall domain verification state. `not_found` means the provider does not
46
+ * know the domain (it was never created there); the engine also reports it
47
+ * when the capability is supported but the domain hasn't been added yet.
48
+ */
49
+ export type DomainVerificationState =
50
+ | "not_found"
51
+ | "pending"
52
+ | "verified"
53
+ | "failed";
54
+
55
+ /**
56
+ * The provider-neutral snapshot of one sending domain: its overall state plus
57
+ * the DNS records required to verify it. Returned by every
58
+ * {@link DomainsCapability} method that resolves a domain.
59
+ */
60
+ export interface DomainStatus {
61
+ domain: string;
62
+ state: DomainVerificationState;
63
+ records: DnsRecord[];
64
+ /** {@link EmailProviderMeta.id} of the provider that produced this status. */
65
+ providerId: string;
66
+ /** ISO 8601 instant this status was fetched from the provider. */
67
+ checkedAt: string;
68
+ /** The untouched provider payload, for debugging / escape hatch. */
69
+ raw?: unknown;
70
+ }
71
+
72
+ /**
73
+ * Optional provider capability for managing sending domains. PRESENCE IS THE
74
+ * GATE: a provider that cannot manage domains simply omits the member, and the
75
+ * engine/CLI/Studio degrade gracefully (admin POSTs return 501
76
+ * `provider_unsupported`, `EngineDomainStatus.supported` is `false`).
77
+ *
78
+ * Like the send wire, this is a DUMB translation layer: plain HTTP against the
79
+ * provider's domains API, normalized into {@link DomainStatus}/{@link DnsRecord}.
80
+ * Caching, env derivation, and test-mode policy all live in the engine's
81
+ * `DomainStatusService` — never here.
82
+ */
83
+ export interface DomainsCapability {
84
+ /**
85
+ * Register the domain with the provider. IDEMPOTENT: when the domain already
86
+ * exists there, implementations fall through to a lookup and return the
87
+ * existing status rather than throwing.
88
+ */
89
+ create(domain: string): Promise<DomainStatus>;
90
+ /** Current status, or `null` when the provider doesn't know the domain. */
91
+ get(domain: string): Promise<DomainStatus | null>;
92
+ /** The DNS records required to verify the domain. */
93
+ records(domain: string): Promise<DnsRecord[]>;
94
+ /**
95
+ * Trigger a provider-side verification pass where supported (e.g. Resend's
96
+ * `POST /domains/:id/verify`). Providers without an explicit verify endpoint
97
+ * omit this; callers fall back to `get`.
98
+ */
99
+ verify?(domain: string): Promise<DomainStatus>;
100
+ }
@@ -1,3 +1,5 @@
1
+ import type { DomainsCapability } from "./domains.js";
2
+
1
3
  // ---------------------------------------------------------------------------
2
4
  // Send options (HTML-only wire — NO React)
3
5
  // ---------------------------------------------------------------------------
@@ -292,6 +294,13 @@ export interface EmailProvider {
292
294
  * is treated conservatively (no native tracking assumed, no scheduled send).
293
295
  */
294
296
  readonly capabilities?: EmailProviderCapabilities;
297
+ /**
298
+ * Optional sending-domain management capability ({@link DomainsCapability}).
299
+ * PRESENCE IS THE GATE — no `capabilities` flag mirrors it. Absent ⇒ the
300
+ * engine's domain-status service reports `supported: false` and the admin
301
+ * domain POSTs return 501; everything else degrades gracefully.
302
+ */
303
+ readonly domains?: DomainsCapability;
295
304
 
296
305
  /** Deliver a single message. Returns the provider message id. */
297
306
  send(options: SendEmailOptions): Promise<SendResult>;
@@ -1,2 +1,3 @@
1
1
  export * from "./analytics.js";
2
+ export * from "./domains.js";
2
3
  export * from "./email.js";