@m13v/seo-components 0.31.4 → 0.32.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 +1 -1
- package/src/lib/book-call-route.ts +46 -8
- package/src/lib/resend-inbound-route.ts +275 -0
- package/src/server.ts +6 -0
package/package.json
CHANGED
|
@@ -91,6 +91,14 @@ export interface BookCallConfig {
|
|
|
91
91
|
emailSubject?: string;
|
|
92
92
|
/** Override the email HTML. Receives the email-click URL and the submitted email. */
|
|
93
93
|
emailHtml?: (emailClickUrl: string, subscriberEmail: string) => string;
|
|
94
|
+
/**
|
|
95
|
+
* Optional: log the outbound send. Called after the Resend send call returns;
|
|
96
|
+
* receives the subscriber email and the Resend email id (or null on failure).
|
|
97
|
+
* Errors thrown here are logged and swallowed so a flaky DB doesn't break the
|
|
98
|
+
* booking flow. Required for sites that want delivery / open / click webhook
|
|
99
|
+
* events to update the right `<slug>_emails` row by `resend_id`.
|
|
100
|
+
*/
|
|
101
|
+
onSent?: (email: string, resendEmailId: string | null) => Promise<void>;
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/* ------------------------------------------------------------------ */
|
|
@@ -121,6 +129,7 @@ export function createBookCallHandler(config: BookCallConfig) {
|
|
|
121
129
|
apiKeyEnv = "RESEND_API_KEY",
|
|
122
130
|
emailSubject,
|
|
123
131
|
emailHtml,
|
|
132
|
+
onSent,
|
|
124
133
|
} = config;
|
|
125
134
|
|
|
126
135
|
return async function POST(req: NextRequest) {
|
|
@@ -175,14 +184,43 @@ export function createBookCallHandler(config: BookCallConfig) {
|
|
|
175
184
|
? emailHtml(emailClickUrl, email)
|
|
176
185
|
: defaultBookCallEmailHtml(brand, siteUrl, emailClickUrl);
|
|
177
186
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
187
|
+
if (onSent) {
|
|
188
|
+
// Sequential so the `onSent` callback receives the actual Resend id.
|
|
189
|
+
try {
|
|
190
|
+
const sendRes = await fetch("https://api.resend.com/emails", {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${resendKey}`,
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ from: fromEmail, to: email, subject, html }),
|
|
197
|
+
});
|
|
198
|
+
let resendEmailId: string | null = null;
|
|
199
|
+
if (sendRes.ok) {
|
|
200
|
+
const data = (await sendRes.json().catch(() => ({}))) as { id?: string };
|
|
201
|
+
resendEmailId = data.id || null;
|
|
202
|
+
} else {
|
|
203
|
+
const detail = await sendRes.text().catch(() => "");
|
|
204
|
+
console.error("[book-call] email send failed:", sendRes.status, detail);
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
await onSent(email, resendEmailId);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
console.error("[book-call] onSent callback error:", err);
|
|
210
|
+
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
console.error("[book-call] email send threw:", err);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
fetch("https://api.resend.com/emails", {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `Bearer ${resendKey}`,
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify({ from: fromEmail, to: email, subject, html }),
|
|
222
|
+
}).catch((err) => console.error("[book-call] email send threw:", err));
|
|
223
|
+
}
|
|
186
224
|
|
|
187
225
|
return new Response(
|
|
188
226
|
JSON.stringify({ ok: true }),
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
|
|
3
|
+
/* ------------------------------------------------------------------ */
|
|
4
|
+
/* Resend Inbound webhook handler */
|
|
5
|
+
/* ------------------------------------------------------------------ */
|
|
6
|
+
/* */
|
|
7
|
+
/* Handles BOTH inbound (`email.received`) and outbound delivery */
|
|
8
|
+
/* events (sent / delivered / opened / clicked / bounced / */
|
|
9
|
+
/* complained / delayed) on the same /api/webhooks/resend endpoint. */
|
|
10
|
+
/* */
|
|
11
|
+
/* Pattern: every client site sends from `<sender>@<domain>` (a real */
|
|
12
|
+
/* human-named address). Recipients hit Reply. Without this handler, */
|
|
13
|
+
/* those replies bounce silently and the site is deaf to its own */
|
|
14
|
+
/* customers. Pair this with apex `MX 10 inbound-smtp.us-east-1. */
|
|
15
|
+
/* amazonaws.com.` and a registered Resend webhook for */
|
|
16
|
+
/* `email.received` plus the delivery events. */
|
|
17
|
+
/* */
|
|
18
|
+
/* ------------------------------------------------------------------ */
|
|
19
|
+
|
|
20
|
+
const DEFAULT_IGNORED_SENDERS: RegExp[] = [
|
|
21
|
+
/dmarc/i,
|
|
22
|
+
/^noreply@/i,
|
|
23
|
+
/^no-reply@/i,
|
|
24
|
+
/^system@/i,
|
|
25
|
+
/^mailer-daemon@/i,
|
|
26
|
+
/^postmaster@/i,
|
|
27
|
+
/@saashub\.com$/i,
|
|
28
|
+
/^invoice.*@stripe\.com$/i,
|
|
29
|
+
/@email\.figma\.com$/i,
|
|
30
|
+
/@.*\.postmarkapp\.com$/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const DEFAULT_IGNORED_SUBJECTS = /\bDMARC\b|Report Domain:|aggregate report/i;
|
|
34
|
+
|
|
35
|
+
const DELIVERY_STATUS_MAP: Record<string, string> = {
|
|
36
|
+
"email.sent": "sent",
|
|
37
|
+
"email.delivered": "delivered",
|
|
38
|
+
"email.opened": "opened",
|
|
39
|
+
"email.clicked": "clicked",
|
|
40
|
+
"email.bounced": "bounced",
|
|
41
|
+
"email.complained": "complained",
|
|
42
|
+
"email.delivery_delayed": "delayed",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface ResendInboundPayload {
|
|
46
|
+
type: string;
|
|
47
|
+
created_at: string;
|
|
48
|
+
data: {
|
|
49
|
+
email_id: string;
|
|
50
|
+
from: string;
|
|
51
|
+
to: string[];
|
|
52
|
+
subject: string;
|
|
53
|
+
text?: string;
|
|
54
|
+
html?: string;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ResendInboundConfig {
|
|
59
|
+
/**
|
|
60
|
+
* Domain the webhook is bound to (e.g. "studyly.io"). Used to filter inbound
|
|
61
|
+
* events: any `to` address not ending in `@<domain>` is ignored. Required.
|
|
62
|
+
*/
|
|
63
|
+
domain: string;
|
|
64
|
+
/**
|
|
65
|
+
* Where to forward inbound mail so a human actually sees it
|
|
66
|
+
* (e.g. "i@m13v.com"). Required, no default.
|
|
67
|
+
*/
|
|
68
|
+
forwardTo: string;
|
|
69
|
+
/**
|
|
70
|
+
* "From" header used when forwarding to {@link forwardTo}
|
|
71
|
+
* (e.g. "Studyly Inbound <matt@studyly.io>"). Required.
|
|
72
|
+
*/
|
|
73
|
+
forwardFrom: string;
|
|
74
|
+
/**
|
|
75
|
+
* Brand label used in the "[<Brand> Inbound] <subject>" prefix on forwarded
|
|
76
|
+
* mail. Required.
|
|
77
|
+
*/
|
|
78
|
+
brand: string;
|
|
79
|
+
/** Env var name for the Resend API key (default: "RESEND_API_KEY") */
|
|
80
|
+
apiKeyEnv?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Optional: persist inbound to your DB. Called BEFORE the forward fires;
|
|
83
|
+
* a thrown error is logged and swallowed.
|
|
84
|
+
*/
|
|
85
|
+
onInbound?: (record: {
|
|
86
|
+
resendId: string;
|
|
87
|
+
fromEmail: string;
|
|
88
|
+
toEmail: string;
|
|
89
|
+
fromRaw: string;
|
|
90
|
+
toRaw: string[];
|
|
91
|
+
subject: string;
|
|
92
|
+
bodyText: string | null;
|
|
93
|
+
bodyHtml: string | null;
|
|
94
|
+
}) => Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional: handle outbound delivery events (delivered/opened/clicked/etc).
|
|
97
|
+
* Receives the event type, resend email id, and ISO timestamp. Errors are
|
|
98
|
+
* logged and swallowed so a flaky DB doesn't break the webhook.
|
|
99
|
+
*/
|
|
100
|
+
onDeliveryEvent?: (event: {
|
|
101
|
+
type: string;
|
|
102
|
+
status: string;
|
|
103
|
+
resendId: string;
|
|
104
|
+
timestamp: string;
|
|
105
|
+
}) => Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Skip forwarding (only persist + return 200). Useful for tests, or for
|
|
108
|
+
* sites where the DB log is the only consumer.
|
|
109
|
+
*/
|
|
110
|
+
skipForward?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* Override the automated-sender deny list. Defaults cover DMARC reports,
|
|
113
|
+
* mailer-daemon, Stripe invoice noise, etc.
|
|
114
|
+
*/
|
|
115
|
+
ignoredSenders?: RegExp[];
|
|
116
|
+
/** Override the automated-subject deny pattern. */
|
|
117
|
+
ignoredSubjects?: RegExp;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseEmail(raw: string): string {
|
|
121
|
+
const m = raw.match(/<([^>]+)>/) || raw.match(/^([^\s<]+@[^\s>]+)$/);
|
|
122
|
+
return m ? m[1].toLowerCase() : raw.toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fetchInboundContent(emailId: string, apiKey: string) {
|
|
126
|
+
try {
|
|
127
|
+
const res = await fetch(
|
|
128
|
+
`https://api.resend.com/emails/receiving/${emailId}`,
|
|
129
|
+
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
130
|
+
);
|
|
131
|
+
if (!res.ok) return null;
|
|
132
|
+
const data = (await res.json()) as { text?: string; html?: string };
|
|
133
|
+
return { text: data?.text, html: data?.html };
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createResendInboundHandler(config: ResendInboundConfig) {
|
|
140
|
+
const {
|
|
141
|
+
domain,
|
|
142
|
+
forwardTo,
|
|
143
|
+
forwardFrom,
|
|
144
|
+
brand,
|
|
145
|
+
apiKeyEnv = "RESEND_API_KEY",
|
|
146
|
+
onInbound,
|
|
147
|
+
onDeliveryEvent,
|
|
148
|
+
skipForward = false,
|
|
149
|
+
ignoredSenders = DEFAULT_IGNORED_SENDERS,
|
|
150
|
+
ignoredSubjects = DEFAULT_IGNORED_SUBJECTS,
|
|
151
|
+
} = config;
|
|
152
|
+
|
|
153
|
+
const domainSuffix = `@${domain.toLowerCase()}`;
|
|
154
|
+
const tag = `[${brand} Webhook]`;
|
|
155
|
+
|
|
156
|
+
async function handleInbound(payload: ResendInboundPayload, apiKey: string) {
|
|
157
|
+
const { data } = payload;
|
|
158
|
+
const isForUs = data.to.some((addr) =>
|
|
159
|
+
addr.toLowerCase().endsWith(domainSuffix),
|
|
160
|
+
);
|
|
161
|
+
if (!isForUs) {
|
|
162
|
+
console.log(`${tag} ignoring, not addressed to ${domainSuffix}:`, data.to);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const fromEmail = parseEmail(data.from);
|
|
167
|
+
const automated =
|
|
168
|
+
ignoredSenders.some((p) => p.test(fromEmail)) ||
|
|
169
|
+
(data.subject && ignoredSubjects.test(data.subject));
|
|
170
|
+
if (automated) {
|
|
171
|
+
console.log(
|
|
172
|
+
`${tag} ignoring automated email from`,
|
|
173
|
+
fromEmail,
|
|
174
|
+
"subject:",
|
|
175
|
+
data.subject,
|
|
176
|
+
);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const content = await fetchInboundContent(data.email_id, apiKey);
|
|
181
|
+
const bodyText = content?.text || data.text || null;
|
|
182
|
+
const bodyHtml = content?.html || data.html || null;
|
|
183
|
+
const toEmail = data.to[0] || "";
|
|
184
|
+
|
|
185
|
+
if (onInbound) {
|
|
186
|
+
try {
|
|
187
|
+
await onInbound({
|
|
188
|
+
resendId: data.email_id,
|
|
189
|
+
fromEmail,
|
|
190
|
+
toEmail,
|
|
191
|
+
fromRaw: data.from,
|
|
192
|
+
toRaw: data.to,
|
|
193
|
+
subject: data.subject || "",
|
|
194
|
+
bodyText,
|
|
195
|
+
bodyHtml,
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(`${tag} onInbound error`, err);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (skipForward) return;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await fetch("https://api.resend.com/emails", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: {
|
|
208
|
+
Authorization: `Bearer ${apiKey}`,
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({
|
|
212
|
+
from: forwardFrom,
|
|
213
|
+
to: forwardTo,
|
|
214
|
+
subject: `[${brand} Inbound] ${data.subject || "(no subject)"}`,
|
|
215
|
+
text: `From: ${data.from}\nTo: ${data.to.join(", ")}\n\n${bodyText || "(no body)"}`,
|
|
216
|
+
}),
|
|
217
|
+
});
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`${tag} forward send error`, err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function handleDelivery(payload: ResendInboundPayload) {
|
|
224
|
+
const status = DELIVERY_STATUS_MAP[payload.type];
|
|
225
|
+
if (!status || !onDeliveryEvent) return;
|
|
226
|
+
try {
|
|
227
|
+
await onDeliveryEvent({
|
|
228
|
+
type: payload.type,
|
|
229
|
+
status,
|
|
230
|
+
resendId: payload.data.email_id,
|
|
231
|
+
timestamp: payload.created_at,
|
|
232
|
+
});
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error(`${tag} onDeliveryEvent error`, err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return async function POST(req: NextRequest) {
|
|
239
|
+
const apiKey = process.env[apiKeyEnv];
|
|
240
|
+
if (!apiKey) {
|
|
241
|
+
console.error(`${tag} ${apiKeyEnv} missing`);
|
|
242
|
+
return new Response(JSON.stringify({ error: "no_api_key" }), {
|
|
243
|
+
status: 500,
|
|
244
|
+
headers: { "content-type": "application/json" },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let payload: ResendInboundPayload;
|
|
249
|
+
try {
|
|
250
|
+
payload = (await req.json()) as ResendInboundPayload;
|
|
251
|
+
} catch {
|
|
252
|
+
return new Response(JSON.stringify({ error: "invalid_json" }), {
|
|
253
|
+
status: 400,
|
|
254
|
+
headers: { "content-type": "application/json" },
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log(`${tag}`, payload.type, payload.data?.email_id);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
if (payload.type === "email.received") {
|
|
262
|
+
await handleInbound(payload, apiKey);
|
|
263
|
+
} else if (DELIVERY_STATUS_MAP[payload.type]) {
|
|
264
|
+
await handleDelivery(payload);
|
|
265
|
+
}
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error(`${tag} handler error`, err);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
271
|
+
status: 200,
|
|
272
|
+
headers: { "content-type": "application/json" },
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -46,6 +46,12 @@ export type { BookCallRedirectConfig } from "./lib/book-call-redirect";
|
|
|
46
46
|
export { createDmShortLinkRedirectHandler } from "./lib/dm-short-link-redirect";
|
|
47
47
|
export type { DmShortLinkRedirectConfig } from "./lib/dm-short-link-redirect";
|
|
48
48
|
|
|
49
|
+
export { createResendInboundHandler } from "./lib/resend-inbound-route";
|
|
50
|
+
export type {
|
|
51
|
+
ResendInboundConfig,
|
|
52
|
+
ResendInboundPayload,
|
|
53
|
+
} from "./lib/resend-inbound-route";
|
|
54
|
+
|
|
49
55
|
// SeoComponentsStyles was removed in v0.23.0. It injected a prebuilt Tailwind
|
|
50
56
|
// bundle wrapped in `@layer seo-components`, which collided with the
|
|
51
57
|
// consumer's own `@layer utilities` and forced GuideChatPanel / SitemapSidebar
|