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/README.md +22 -34
- package/bin/git-paid.js +127 -52
- package/lib/auth.js +866 -208
- package/lib/directories.js +123 -123
- package/lib/engine.js +228 -112
- package/lib/fixer.js +58 -0
- package/lib/inbox.js +359 -72
- package/lib/mcp.js +220 -87
- package/lib/pseo.js +290 -106
- package/lib/safe-fetch.js +239 -0
- package/lib/stateful-mcp.js +16 -4
- package/lib/submitter.js +89 -28
- package/package.json +53 -44
- package/brand/README.md +0 -54
- package/brand/postbuild-icon.png +0 -0
- package/brand/postbuild-icon.svg +0 -14
- package/brand/postbuild-logo.png +0 -0
- package/brand/postbuild-logo.svg +0 -17
- package/lib/stripe.js +0 -554
- package/server.js +0 -252
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
|
|
5
|
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
6
|
+
const memoryInboxMessages = new Map(); // id -> msg
|
|
6
7
|
|
|
7
|
-
function
|
|
8
|
+
function getLocalStorePath() {
|
|
8
9
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
15
|
+
return null;
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
function
|
|
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
|
-
|
|
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
|
|
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()}_${
|
|
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:
|
|
213
|
+
receivedAt: new Date(receivedMs).toISOString(),
|
|
214
|
+
deleteAfter: new Date(deleteAfterMs).toISOString(),
|
|
215
|
+
deleteAfterMs
|
|
103
216
|
};
|
|
104
217
|
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
120
|
-
*
|
|
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
|
|
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:
|
|
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,
|
|
244
|
+
for (const item of items.slice(0, 15)) {
|
|
143
245
|
const id = item.id || item.email_id;
|
|
144
|
-
if (!id ||
|
|
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
|
-
|
|
147
|
-
if (
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
-
{
|
|
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)
|
|
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 (
|
|
304
|
+
if (rm && rm.id && rm.to === normalized) {
|
|
305
|
+
memoryInboxMessages.set(rm.id, rm);
|
|
306
|
+
}
|
|
179
307
|
}
|
|
180
|
-
|
|
308
|
+
persistMessages();
|
|
181
309
|
}
|
|
182
310
|
}
|
|
183
311
|
} catch {}
|
|
184
312
|
}
|
|
185
313
|
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
346
|
+
* Generates and sends a verification test email via Resend API (admin/authorized testing only).
|
|
200
347
|
*/
|
|
201
|
-
export async function sendTestVerificationEmail(inboxAddress = "
|
|
202
|
-
const cleanInbox =
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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: "
|
|
212
|
-
subject
|
|
213
|
-
text:
|
|
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
|
+
}
|