@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m13v/seo-components",
3
- "version": "0.31.4",
3
+ "version": "0.32.1",
4
4
  "scripts": {
5
5
  "build:css": "tailwind -i src/_build.css -o dist/styles.css --minify",
6
6
  "lint:mobile-spans": "node scripts/lint-mobile-spans.mjs",
@@ -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
- fetch("https://api.resend.com/emails", {
179
- method: "POST",
180
- headers: {
181
- Authorization: `Bearer ${resendKey}`,
182
- "Content-Type": "application/json",
183
- },
184
- body: JSON.stringify({ from: fromEmail, to: email, subject, html }),
185
- }).catch((err) => console.error("[book-call] email send threw:", err));
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