git-paid 2.1.2 → 2.1.4

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/lib/inbox.js CHANGED
@@ -1,31 +1,120 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
- import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- const INBOX_STORE_PATH = path.join(os.tmpdir(), "postbuild-verification-inbox.json");
5
+ const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
6
+ const memoryInboxMessages = new Map(); // id -> msg
6
7
 
7
- function loadStore() {
8
+ function getLocalStorePath() {
8
9
  try {
9
- if (fs.existsSync(INBOX_STORE_PATH)) {
10
- return JSON.parse(fs.readFileSync(INBOX_STORE_PATH, "utf8"));
10
+ const dir = path.join(process.cwd(), ".postbuild");
11
+ if (fs.existsSync(dir)) {
12
+ return path.join(dir, "inbox-messages.json");
11
13
  }
12
14
  } catch {}
13
- return { messages: [] };
15
+ return null;
14
16
  }
15
17
 
16
- function saveStore(store) {
18
+ function pruneAndLoadMessages() {
19
+ const now = Date.now();
20
+ const storePath = getLocalStorePath();
21
+ if (storePath && fs.existsSync(storePath)) {
22
+ try {
23
+ const parsed = JSON.parse(fs.readFileSync(storePath, "utf8"));
24
+ if (Array.isArray(parsed?.messages)) {
25
+ for (const m of parsed.messages) {
26
+ if (m && m.id && (!m.deleteAfterMs || m.deleteAfterMs > now)) {
27
+ memoryInboxMessages.set(m.id, m);
28
+ }
29
+ }
30
+ }
31
+ } catch {}
32
+ }
33
+
34
+ for (const [id, m] of memoryInboxMessages.entries()) {
35
+ if (m.deleteAfterMs && m.deleteAfterMs <= now) {
36
+ memoryInboxMessages.delete(id);
37
+ }
38
+ }
39
+
40
+ return Array.from(memoryInboxMessages.values()).sort((a, b) =>
41
+ String(b.receivedAt || "").localeCompare(String(a.receivedAt || ""))
42
+ );
43
+ }
44
+
45
+ function persistMessages() {
46
+ const storePath = getLocalStorePath();
47
+ if (!storePath) return;
17
48
  try {
18
- fs.writeFileSync(INBOX_STORE_PATH, JSON.stringify(store, null, 2), "utf8");
49
+ const messages = pruneAndLoadMessages().slice(0, 200);
50
+ fs.writeFileSync(storePath, JSON.stringify({ messages }, null, 2), "utf8");
19
51
  } catch {}
20
52
  }
21
53
 
54
+ /**
55
+ * Generates the dedicated @postbuild.dev bot inbox address for a target domain
56
+ * from the full hostname, with dots replaced by hyphens, so different TLDs never
57
+ * collide (acme.com -> acme-com-bot@postbuild.dev, acme.io -> acme-io-bot@postbuild.dev).
58
+ * postbuild.dev itself keeps the operator alias postbuild-bot@postbuild.dev.
59
+ * Hostnames longer than the slug limit are truncated and suffixed with a short hash.
60
+ */
61
+ const MAX_INBOX_SLUG = 40;
62
+
63
+ export function getBotInboxForDomain(rawDomain = "", ownerKey = "") {
64
+ const cleanDomain = String(rawDomain || "")
65
+ .trim()
66
+ .toLowerCase()
67
+ .replace(/^https?:\/\//, "")
68
+ .replace(/^www\./, "")
69
+ .split("/")[0]
70
+ .split(":")[0]
71
+ .replace(/\.$/, "");
72
+
73
+ if (!cleanDomain || !cleanDomain.includes(".")) {
74
+ if (ownerKey) {
75
+ const ownerHash = crypto
76
+ .createHash("sha256")
77
+ .update(String(ownerKey).toLowerCase().trim())
78
+ .digest("hex")
79
+ .slice(0, 8);
80
+ return `project-${ownerHash}-bot@postbuild.dev`;
81
+ }
82
+ return "unassigned-bot@postbuild.dev";
83
+ }
84
+
85
+ if (cleanDomain === "postbuild.dev") {
86
+ return "postbuild-bot@postbuild.dev";
87
+ }
88
+
89
+ const hasHyphenOrMultiDot = cleanDomain.includes("-") || cleanDomain.split(".").length > 2;
90
+
91
+ let slug =
92
+ cleanDomain
93
+ .replace(/[^a-z0-9]+/g, "-")
94
+ .replace(/-+/g, "-")
95
+ .replace(/^-|-$/g, "") || "project";
96
+
97
+ if (slug.length > MAX_INBOX_SLUG || hasHyphenOrMultiDot) {
98
+ const hash = crypto.createHash("sha256").update(`pb-inbox:v2:${cleanDomain}`).digest("hex").slice(0, 6);
99
+ const maxBase = MAX_INBOX_SLUG - 7;
100
+ const base = slug.length > maxBase ? slug.slice(0, maxBase).replace(/-$/, "") : slug;
101
+ slug = `${base}-${hash}`;
102
+ }
103
+
104
+ return `${slug}-bot@postbuild.dev`;
105
+ }
106
+
107
+ export function isValidPostbuildInboxAddress(addr = "") {
108
+ const clean = String(addr || "").trim().toLowerCase();
109
+ return /^[a-z0-9][a-z0-9._-]{1,48}@postbuild\.dev$/.test(clean);
110
+ }
111
+
22
112
  /**
23
113
  * Extracts confirmation/verification links and 4-to-8 digit OTP codes from email text/HTML.
24
114
  */
25
115
  export function extractVerificationSignals(subject = "", text = "", html = "") {
26
116
  const combined = `${subject}\n${text}\n${html.replace(/<[^>]+>/g, " ")}`;
27
117
 
28
- // Extract all https:// URLs
29
118
  const urlMatches = `${text}\n${html}`.match(/https?:\/\/[^\s"'<>)+]+/gi) || [];
30
119
  const cleanedUrls = [...new Set(urlMatches.map((u) => u.replace(/[.,;!?]+$/, "")))];
31
120
 
@@ -33,7 +122,6 @@ export function extractVerificationSignals(subject = "", text = "", html = "") {
33
122
  const verificationLinks = cleanedUrls.filter((u) => verificationKeywords.test(u));
34
123
  const primaryLink = verificationLinks[0] || cleanedUrls[0] || null;
35
124
 
36
- // Extract 6-digit OTP code (or 4-8 digit code near "code", "OTP", "PIN", "verification")
37
125
  let otpCode = null;
38
126
  const contextualOtp = combined.match(/(?:code|otp|pin|token|verification|confirm)[:\s#-]*([0-9]{4,8})\b/i);
39
127
  if (contextualOtp) {
@@ -52,12 +140,36 @@ export function extractVerificationSignals(subject = "", text = "", html = "") {
52
140
  };
53
141
  }
54
142
 
143
+ export function sanitizeSubjectForDisplay(subject = "", otpCode = "") {
144
+ let clean = String(subject || "")
145
+ .replace(/\s*\((?:Code|OTP|Pin|Verification\s*Code)[:\s#-]*[0-9]{4,8}\)/gi, "")
146
+ .replace(/\s*[-–—:]\s*(?:Code|OTP|Pin)[:\s#-]*[0-9]{4,8}\b/gi, "");
147
+ if (otpCode) {
148
+ const escaped = String(otpCode).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
149
+ clean = clean.replace(new RegExp(`\\b${escaped}\\b`, "g"), "••••••");
150
+ }
151
+ return clean.trim() || "Confirm your directory submission";
152
+ }
153
+
55
154
  /**
56
- * Ingests an inbound email webhook payload (Resend `email.received`, Postmark, ForwardEmail, or raw JSON).
155
+ * Ingests a verified inbound email webhook payload.
156
+ * Rejects mail addressed to unknown or non-@postbuild.dev recipients.
57
157
  */
58
158
  export async function ingestInboundEmail(rawPayload = {}) {
59
159
  const payload = rawPayload?.data && typeof rawPayload.data === "object" ? rawPayload.data : rawPayload;
60
160
 
161
+ const toRaw = payload.to || payload.recipient || payload.envelope?.to || "";
162
+ const toAddress = (Array.isArray(toRaw) ? toRaw[0] : String(toRaw))
163
+ .replace(/^.*<([^>]+)>.*$/, "$1")
164
+ .trim()
165
+ .toLowerCase();
166
+
167
+ if (!isValidPostbuildInboxAddress(toAddress)) {
168
+ const err = new Error(`Rejected inbound email for unknown or invalid recipient: ${toAddress || "empty"}`);
169
+ err.statusCode = 422;
170
+ throw err;
171
+ }
172
+
61
173
  let textBody = String(payload.text || payload.body || payload.rawText || "").trim();
62
174
  let htmlBody = String(payload.html || "").trim();
63
175
  let subject = String(payload.subject || "Confirm your directory submission").trim();
@@ -67,7 +179,8 @@ export async function ingestInboundEmail(rawPayload = {}) {
67
179
  if (resendEmailId && resendApiKey && !textBody && !htmlBody) {
68
180
  try {
69
181
  const resp = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(resendEmailId)}`, {
70
- headers: { Authorization: `Bearer ${resendApiKey}` }
182
+ headers: { Authorization: `Bearer ${resendApiKey}` },
183
+ signal: AbortSignal.timeout(3500)
71
184
  });
72
185
  if (resp.ok) {
73
186
  const fullEmail = await resp.json();
@@ -78,139 +191,313 @@ export async function ingestInboundEmail(rawPayload = {}) {
78
191
  } catch {}
79
192
  }
80
193
 
81
- const toRaw = payload.to || payload.recipient || payload.envelope?.to || "postbuild-bot@postbuild.dev";
82
- const toAddress = (Array.isArray(toRaw) ? toRaw[0] : String(toRaw))
83
- .replace(/^.*<([^>]+)>.*$/, "$1")
84
- .trim()
85
- .toLowerCase();
86
-
87
194
  const fromRaw = payload.from || payload.sender || payload.envelope?.from || "no-reply@directory-verifier.com";
88
195
  const fromAddress = String(fromRaw).trim();
89
196
 
90
197
  const signals = extractVerificationSignals(subject, textBody, htmlBody);
198
+ const displaySubject = sanitizeSubjectForDisplay(subject, signals.otpCode);
199
+ const receivedDate = payload.created_at ? new Date(payload.created_at) : new Date();
200
+ const receivedMs = Number.isNaN(receivedDate.getTime()) ? Date.now() : receivedDate.getTime();
201
+ const deleteAfterMs = receivedMs + RETENTION_MS;
91
202
 
92
203
  const msg = {
93
- id: resendEmailId || `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
204
+ id: resendEmailId || `msg_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`,
94
205
  to: toAddress,
95
206
  from: fromAddress,
96
- subject,
207
+ subject: displaySubject,
97
208
  snippet: (textBody || htmlBody.replace(/<[^>]+>/g, " ")).slice(0, 220).trim(),
98
209
  primaryLink: signals.primaryLink,
99
210
  verificationLinks: signals.verificationLinks,
100
211
  otpCode: signals.otpCode,
101
212
  status: signals.primaryLink || signals.otpCode ? "action_ready" : "received",
102
- receivedAt: payload.created_at || new Date().toISOString()
213
+ receivedAt: new Date(receivedMs).toISOString(),
214
+ deleteAfter: new Date(deleteAfterMs).toISOString(),
215
+ deleteAfterMs
103
216
  };
104
217
 
105
- const store = loadStore();
106
- const existsIdx = store.messages.findIndex((m) => m.id === msg.id);
107
- if (existsIdx >= 0) {
108
- store.messages[existsIdx] = msg;
109
- } else {
110
- store.messages.unshift(msg);
111
- }
112
- store.messages = store.messages.slice(0, 100);
113
- saveStore(store);
218
+ memoryInboxMessages.set(msg.id, msg);
219
+ persistMessages();
114
220
 
115
221
  return msg;
116
222
  }
117
223
 
118
224
  /**
119
- * Syncs recent inbound emails directly from Resend Receiving API (`GET /emails/receiving`)
120
- * so serverless cold starts never lose verification emails.
225
+ * Syncs recent inbound emails from Resend Receiving API (`GET /emails/receiving`)
226
+ * filtered strictly by exact recipient match.
121
227
  */
122
228
  async function syncFromResendReceivingApi(targetInbox = "") {
123
229
  const resendApiKey = (process.env.RESEND_API_KEY || "").trim();
124
- if (!resendApiKey) return;
230
+ if (!resendApiKey || !isValidPostbuildInboxAddress(targetInbox)) return;
231
+
232
+ const normalizedTarget = targetInbox.trim().toLowerCase();
125
233
 
126
234
  try {
127
- const controller = new AbortController();
128
- const timer = setTimeout(() => controller.abort(), 3500);
129
- const resp = await fetch("https://api.resend.com/emails/receiving?limit=20", {
235
+ const resp = await fetch("https://api.resend.com/emails/receiving?limit=25", {
130
236
  headers: { Authorization: `Bearer ${resendApiKey}` },
131
- signal: controller.signal
237
+ signal: AbortSignal.timeout(3500)
132
238
  });
133
- clearTimeout(timer);
134
239
  if (!resp.ok) return;
135
240
 
136
241
  const data = await resp.json();
137
242
  const items = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : [];
138
- const store = loadStore();
139
- const knownIds = new Set(store.messages.map((m) => m.id));
140
- const prefix = targetInbox ? targetInbox.split("@")[0].toLowerCase() : "";
141
243
 
142
- for (const item of items.slice(0, 12)) {
244
+ for (const item of items.slice(0, 15)) {
143
245
  const id = item.id || item.email_id;
144
- if (!id || knownIds.has(id)) continue;
246
+ if (!id || memoryInboxMessages.has(id)) continue;
247
+
248
+ const recipients = (Array.isArray(item.to) ? item.to : [item.to])
249
+ .filter(Boolean)
250
+ .map((r) => String(r).replace(/^.*<([^>]+)>.*$/, "$1").trim().toLowerCase());
251
+
252
+ if (!recipients.includes(normalizedTarget)) continue;
145
253
 
146
- const toList = Array.isArray(item.to) ? item.to.join(" ").toLowerCase() : String(item.to || "").toLowerCase();
147
- if (prefix && !toList.includes(prefix) && !toList.includes("postbuild.dev")) continue;
254
+ let fullItem = item;
255
+ if (!item.text && !item.html) {
256
+ try {
257
+ const detResp = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(id)}`, {
258
+ headers: { Authorization: `Bearer ${resendApiKey}` },
259
+ signal: AbortSignal.timeout(2500)
260
+ });
261
+ if (detResp.ok) {
262
+ fullItem = await detResp.json();
263
+ }
264
+ } catch {}
265
+ }
148
266
 
149
- await ingestInboundEmail(item);
267
+ await ingestInboundEmail(fullItem);
150
268
  }
151
269
  } catch {}
152
270
  }
153
271
 
272
+ export function normalizeInboxInput(raw = "") {
273
+ const s = String(raw || "").trim().toLowerCase();
274
+ if (!s) return "";
275
+ if (s.includes("@") && !s.startsWith("http")) {
276
+ return s;
277
+ }
278
+ return getBotInboxForDomain(s);
279
+ }
280
+
154
281
  /**
155
- * Lists verification emails for a given bot inbox address (e.g., postbuild-bot@postbuild.dev).
156
- * Queries Resend Receiving API directly when RESEND_API_KEY is present, or syncs from postbuild.dev when run via local CLI.
282
+ * Lists verification emails for a specific bot inbox address using exact recipient matching.
157
283
  */
158
284
  export async function listInboxMessages(inboxAddress = "", options = {}) {
159
- const normalized = String(inboxAddress || "postbuild-bot@postbuild.dev").trim().toLowerCase();
285
+ const normalized = normalizeInboxInput(inboxAddress);
160
286
 
161
287
  if (process.env.RESEND_API_KEY) {
162
288
  await syncFromResendReceivingApi(normalized);
163
- } else if (options.allowRemoteFallback !== false && !process.env.VERCEL) {
289
+ } else if (options.allowRemoteFallback !== false && !process.env.VERCEL && options.bearerToken) {
164
290
  try {
165
- const controller = new AbortController();
166
- const timer = setTimeout(() => controller.abort(), 3000);
167
291
  const remoteResp = await fetch(
168
292
  `https://www.postbuild.dev/api/dispatch?action=inbox_list&inbox=${encodeURIComponent(normalized)}`,
169
- { signal: controller.signal }
293
+ {
294
+ headers: {
295
+ Authorization: `Bearer ${options.bearerToken}`
296
+ },
297
+ signal: AbortSignal.timeout(3000)
298
+ }
170
299
  );
171
- clearTimeout(timer);
172
300
  if (remoteResp.ok) {
173
301
  const remoteData = await remoteResp.json();
174
- if (Array.isArray(remoteData.messages) && remoteData.messages.length > 0) {
175
- const store = loadStore();
176
- const existingIds = new Set(store.messages.map((m) => m.id));
302
+ if (Array.isArray(remoteData.messages)) {
177
303
  for (const rm of remoteData.messages) {
178
- if (!existingIds.has(rm.id)) store.messages.push(rm);
304
+ if (rm && rm.id && rm.to === normalized) {
305
+ memoryInboxMessages.set(rm.id, rm);
306
+ }
179
307
  }
180
- saveStore(store);
308
+ persistMessages();
181
309
  }
182
310
  }
183
311
  } catch {}
184
312
  }
185
313
 
186
- const store = loadStore();
187
- const filtered = normalized
188
- ? store.messages.filter((m) => m.to === normalized || m.to.includes(normalized.split("@")[0]))
189
- : store.messages;
314
+ const allMessages = pruneAndLoadMessages();
315
+ // Exact address match only — never substring match
316
+ const filtered = allMessages
317
+ .filter((m) => m.to === normalized)
318
+ .map((m) => ({
319
+ ...m,
320
+ subject: sanitizeSubjectForDisplay(m.subject, m.otpCode),
321
+ verificationUrl: m.primaryLink && /^https:\/\//i.test(m.primaryLink) ? m.primaryLink : null
322
+ }));
190
323
 
191
324
  return {
192
325
  inboxAddress: normalized,
326
+ retentionDays: 30,
193
327
  count: filtered.length,
194
328
  messages: filtered
195
329
  };
196
330
  }
197
331
 
332
+ export function markMessageVerified(id, verified = true, httpStatus = 200) {
333
+ if (!id || !memoryInboxMessages.has(id)) return false;
334
+ const existing = memoryInboxMessages.get(id);
335
+ memoryInboxMessages.set(id, {
336
+ ...existing,
337
+ autoVerified: Boolean(verified),
338
+ httpStatus,
339
+ status: verified ? "verified" : existing.status
340
+ });
341
+ persistMessages();
342
+ return true;
343
+ }
344
+
198
345
  /**
199
- * Generates a realistic test verification email so users can verify the inbox pipeline immediately.
346
+ * Generates and sends a verification test email via Resend API (admin/authorized testing only).
200
347
  */
201
- export async function sendTestVerificationEmail(inboxAddress = "postbuild-bot@postbuild.dev", domain = "postbuild.dev") {
202
- const cleanInbox = String(inboxAddress || "postbuild-bot@postbuild.dev").trim().toLowerCase();
203
- const otp = String(Math.floor(100000 + Math.random() * 900000));
204
- const token = Buffer.from(`${domain}:${Date.now()}`).toString("base64url");
205
- const verifyUrl = `https://postbuild.dev/?verified_directory=betalist&domain=${encodeURIComponent(domain)}&token=${token}`;
348
+ export async function sendTestVerificationEmail(inboxAddress = "", domain = "postbuild.dev") {
349
+ const cleanInbox = normalizeInboxInput(inboxAddress || domain);
350
+ if (!isValidPostbuildInboxAddress(cleanInbox)) {
351
+ throw new Error("Recipient must be a valid @postbuild.dev inbox address");
352
+ }
353
+
354
+ const otp = String(crypto.randomInt(100000, 1000000));
355
+ const token = crypto.randomBytes(12).toString("base64url");
356
+ const verifyUrl = `https://www.postbuild.dev/?verified_directory=test&domain=${encodeURIComponent(domain)}&token=${token}`;
357
+
358
+ const subject = `[Verification Test] Confirm ${domain} launch listing`;
359
+ const textBody = `Hello ${domain} team,\n\nThis is a verification test message for your dedicated postbuild inbox (${cleanInbox}). Your one-time verification code is ${otp}.\n\nConfirmation link:\n${verifyUrl}\n`;
360
+
361
+ let resendMessageId = null;
362
+ const resendApiKey = (process.env.RESEND_API_KEY || "").trim();
363
+ if (resendApiKey) {
364
+ try {
365
+ const resp = await fetch("https://api.resend.com/emails", {
366
+ method: "POST",
367
+ headers: {
368
+ Authorization: `Bearer ${resendApiKey}`,
369
+ "Content-Type": "application/json"
370
+ },
371
+ body: JSON.stringify({
372
+ from: "Postbuild Verification Service <verify@postbuild.dev>",
373
+ to: [cleanInbox],
374
+ subject,
375
+ text: textBody
376
+ }),
377
+ signal: AbortSignal.timeout(4500)
378
+ });
379
+ if (resp.ok) {
380
+ const sentData = await resp.json();
381
+ resendMessageId = sentData.id || null;
382
+ }
383
+ } catch {}
384
+ }
206
385
 
207
386
  return ingestInboundEmail({
208
387
  type: "email.received",
209
388
  data: {
389
+ id: resendMessageId || undefined,
210
390
  to: [cleanInbox],
211
- from: "BetaList Launch Bot <verify@betalist.com>",
212
- subject: `[Action Required] Verify ${domain} submission on BetaList (Code: ${otp})`,
213
- text: `Hi ${domain} team,\n\nWe received your directory launch submission via postbuild. Your one-time verification code is ${otp}.\n\nOr click the instant confirmation link below to publish your listing immediately:\n${verifyUrl}\n`
391
+ from: "Postbuild Verification Service <verify@postbuild.dev>",
392
+ subject,
393
+ text: textBody
214
394
  }
215
395
  });
216
396
  }
397
+
398
+ export function clearInboxStoreForTests() {
399
+ memoryInboxMessages.clear();
400
+ }
401
+
402
+ /**
403
+ * Queries Resend Domains & Receiving API for local operator diagnostics (`scripts/email-diag.mjs`).
404
+ */
405
+ export async function getResendDomainDiagnostics(triggerVerify = false) {
406
+ const resendApiKey = (process.env.RESEND_API_KEY || "").trim();
407
+ if (!resendApiKey) {
408
+ return { configured: false, error: "RESEND_API_KEY not set" };
409
+ }
410
+
411
+ let domains = [];
412
+ let domainDetail = null;
413
+ let verifyTriggered = false;
414
+ let webhooks = [];
415
+ let webhookAutoCreated = null;
416
+ let receivingCount = 0;
417
+ let receivingSample = [];
418
+
419
+ try {
420
+ const dResp = await fetch("https://api.resend.com/domains", {
421
+ headers: { Authorization: `Bearer ${resendApiKey}` },
422
+ signal: AbortSignal.timeout(4000)
423
+ });
424
+ if (dResp.ok) {
425
+ const dJson = await dResp.json();
426
+ domains = dJson.data || [];
427
+ const pbDomain = domains.find((d) => d.name === "postbuild.dev") || domains[0];
428
+ if (pbDomain?.id) {
429
+ if (triggerVerify) {
430
+ const vResp = await fetch(`https://api.resend.com/domains/${pbDomain.id}/verify`, {
431
+ method: "POST",
432
+ headers: { Authorization: `Bearer ${resendApiKey}` },
433
+ signal: AbortSignal.timeout(4000)
434
+ });
435
+ verifyTriggered = vResp.ok;
436
+ }
437
+ const detResp = await fetch(`https://api.resend.com/domains/${pbDomain.id}`, {
438
+ headers: { Authorization: `Bearer ${resendApiKey}` },
439
+ signal: AbortSignal.timeout(4000)
440
+ });
441
+ if (detResp.ok) {
442
+ domainDetail = await detResp.json();
443
+ }
444
+ }
445
+ }
446
+ } catch {}
447
+
448
+ try {
449
+ const wResp = await fetch("https://api.resend.com/webhooks", {
450
+ headers: { Authorization: `Bearer ${resendApiKey}` },
451
+ signal: AbortSignal.timeout(4000)
452
+ });
453
+ if (wResp.ok) {
454
+ const wJson = await wResp.json();
455
+ webhooks = wJson.data || [];
456
+ const targetEndpoint = "https://www.postbuild.dev/api/inbound-email";
457
+ const hasInboundHook = webhooks.some((w) => String(w.endpoint || "").includes("/api/inbound-email"));
458
+ if (!hasInboundHook) {
459
+ const createResp = await fetch("https://api.resend.com/webhooks", {
460
+ method: "POST",
461
+ headers: {
462
+ Authorization: `Bearer ${resendApiKey}`,
463
+ "Content-Type": "application/json"
464
+ },
465
+ body: JSON.stringify({
466
+ endpoint: targetEndpoint,
467
+ events: ["email.received"]
468
+ }),
469
+ signal: AbortSignal.timeout(4000)
470
+ });
471
+ if (createResp.ok) {
472
+ webhookAutoCreated = await createResp.json();
473
+ } else {
474
+ webhookAutoCreated = { status: createResp.status, error: await createResp.text() };
475
+ }
476
+ }
477
+ }
478
+ } catch {}
479
+
480
+ try {
481
+ const rResp = await fetch("https://api.resend.com/emails/receiving?limit=10", {
482
+ headers: { Authorization: `Bearer ${resendApiKey}` },
483
+ signal: AbortSignal.timeout(4000)
484
+ });
485
+ if (rResp.ok) {
486
+ const rJson = await rResp.json();
487
+ const list = rJson.data || [];
488
+ receivingCount = list.length;
489
+ receivingSample = list.slice(0, 3);
490
+ }
491
+ } catch {}
492
+
493
+ return {
494
+ configured: true,
495
+ verifyTriggered,
496
+ domains,
497
+ domainDetail,
498
+ webhooks,
499
+ webhookAutoCreated,
500
+ receivingCount,
501
+ receivingSample
502
+ };
503
+ }