jdc-react-mailer 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-02-17
11
+
12
+ ### Added
13
+
14
+ - `ContactForm` React component with configurable fields (`name`, `email`, `subject`, `message`), labels, placeholders, and theme overrides.
15
+ - `useContactForm` hook for form state, validation, honeypot, and POST submission.
16
+ - Theming via CSS custom properties (`--jdcm-*`) and optional inline `theme` prop.
17
+ - Next.js App Router handler: `createMailerHandler()` from `jdc-react-mailer/handler` with Nodemailer SMTP, optional rate limit, CORS, and custom email template.
18
+ - Honeypot (`website`) field for spam protection.
19
+ - Accessible form: ARIA attributes, error messages, loading/success/error states, keyboard navigation, reduced-motion support.
20
+ - Package exports: main entry (component + types), `jdc-react-mailer/handler`, `jdc-react-mailer/style.css`.
21
+ - Build with tsup (ESM + CJS, declarations).
22
+ - Vitest tests for validation and hook behavior.
23
+ - README with install, quick start, API reference, and theming guide.
24
+
25
+ [Unreleased]: https://github.com/your-org/jdc-react-mailer/compare/v0.1.0...HEAD
26
+ [0.1.0]: https://github.com/your-org/jdc-react-mailer/releases/tag/v0.1.0
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # jdc-react-mailer
2
+
3
+ A reusable, themeable React contact form component with a Next.js App Router API route handler powered by Nodemailer. Capture form submissions and send them to a designated email address.
4
+
5
+ - **React 18+** and **TypeScript**
6
+ - **Themeable** via CSS custom properties or inline `theme` prop
7
+ - **Next.js-first**: ships a `<ContactForm>` component and `createMailerHandler()` for App Router
8
+ - **Nodemailer** for SMTP (Gmail, SendGrid, SES, or any SMTP provider)
9
+ - Accessible, validated, honeypot spam protection, loading/success/error UX
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pnpm add jdc-react-mailer
15
+ # or
16
+ npm i jdc-react-mailer
17
+ ```
18
+
19
+ Peer dependencies: `react` and `react-dom` (>=18). For the Next.js handler you need `next` (>=14) in your app.
20
+
21
+ ## Quick start
22
+
23
+ ### 1. Add the API route (Next.js App Router)
24
+
25
+ Create `app/api/contact/route.ts`:
26
+
27
+ ```ts
28
+ import { createMailerHandler } from 'jdc-react-mailer/handler';
29
+
30
+ export const { POST } = createMailerHandler({
31
+ smtp: {
32
+ host: process.env.SMTP_HOST!,
33
+ port: Number(process.env.SMTP_PORT ?? 587),
34
+ secure: false,
35
+ auth: {
36
+ user: process.env.SMTP_USER!,
37
+ pass: process.env.SMTP_PASS!,
38
+ },
39
+ },
40
+ to: 'you@example.com',
41
+ from: 'noreply@yoursite.com',
42
+ });
43
+ ```
44
+
45
+ Set `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, and `SMTP_PASS` in your environment (e.g. `.env.local`).
46
+
47
+ ### 2. Use the form in a page
48
+
49
+ ```tsx
50
+ import { ContactForm } from 'jdc-react-mailer';
51
+ import 'jdc-react-mailer/style.css';
52
+
53
+ export default function ContactPage() {
54
+ return (
55
+ <ContactForm
56
+ endpoint="/api/contact"
57
+ successMessage="Thanks! I'll be in touch."
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ Import the stylesheet once (e.g. in your layout or this page). The form POSTs to `endpoint` with a JSON body; the handler validates it, checks the honeypot, and sends email via Nodemailer.
64
+
65
+ ## Component API
66
+
67
+ | Prop | Type | Default | Description |
68
+ |------|------|---------|-------------|
69
+ | `endpoint` | `string` | required | POST URL (e.g. `/api/contact`) |
70
+ | `fields` | `('name' \| 'email' \| 'subject' \| 'message')[]` | `['name','email','subject','message']` | Field order and inclusion |
71
+ | `labels` | `Partial<Record<FormFieldName, string>>` | — | Override labels per field |
72
+ | `placeholders` | `Partial<Record<FormFieldName, string>>` | — | Override placeholders |
73
+ | `theme` | `ThemeOverrides` | — | Inline CSS variable overrides (see Theming) |
74
+ | `successMessage` | `string` | `"Thanks! I'll be in touch."` | Message shown after successful submit |
75
+ | `submitLabel` | `string` | `"Send message"` | Submit button text |
76
+ | `onSuccess` | `(data: ContactFormPayload) => void` | — | Called when submit succeeds |
77
+ | `onError` | `(error: Error) => void` | — | Called when submit fails |
78
+ | `className` | `string` | — | Extra class on the form root |
79
+
80
+ Example with overrides:
81
+
82
+ ```tsx
83
+ <ContactForm
84
+ endpoint="/api/contact"
85
+ fields={['name', 'email', 'message']}
86
+ labels={{ name: 'Full Name', message: 'Your message' }}
87
+ placeholders={{ message: 'Say hi...' }}
88
+ theme={{ primary: '#0070f3', radius: '10px' }}
89
+ successMessage="Got it, thanks!"
90
+ onSuccess={(data) => console.log('Sent', data)}
91
+ onError={(err) => console.error(err)}
92
+ className="my-contact-form"
93
+ />
94
+ ```
95
+
96
+ ## Theming
97
+
98
+ Styles are driven by CSS custom properties. Import the default stylesheet and override variables where you need:
99
+
100
+ ```css
101
+ /* Override in your app (e.g. global.css or a wrapper) */
102
+ :root {
103
+ --jdcm-primary: #0070f3;
104
+ --jdcm-primary-hover: #005bb5;
105
+ --jdcm-bg: #ffffff;
106
+ --jdcm-surface: #f9fafb;
107
+ --jdcm-border: #e2e8f0;
108
+ --jdcm-text: #1a202c;
109
+ --jdcm-muted: #718096;
110
+ --jdcm-error: #e53e3e;
111
+ --jdcm-success: #38a169;
112
+ --jdcm-radius: 8px;
113
+ --jdcm-spacing: 1rem;
114
+ --jdcm-font: inherit;
115
+ }
116
+ ```
117
+
118
+ Or pass a `theme` prop for inline overrides (no global CSS needed):
119
+
120
+ ```tsx
121
+ <ContactForm
122
+ endpoint="/api/contact"
123
+ theme={{
124
+ primary: '#0070f3',
125
+ primaryHover: '#005bb5',
126
+ radius: '10px',
127
+ fontFamily: 'var(--font-sans)',
128
+ }}
129
+ />
130
+ ```
131
+
132
+ When `theme` is set, the root gets `data-theme-set` so the built-in dark-mode media query does not override your variables.
133
+
134
+ ## Handler options
135
+
136
+ `createMailerHandler(config)` accepts:
137
+
138
+ | Option | Type | Description |
139
+ |--------|------|-------------|
140
+ | `smtp` | `SmtpConfig` | Nodemailer transport options (`host`, `port`, `secure`, `auth`) |
141
+ | `to` | `string` | Recipient email |
142
+ | `from` | `string` | Sender (e.g. `"Site <noreply@yoursite.com>"`) |
143
+ | `rateLimit` | `number` | Max requests per minute per IP (optional) |
144
+ | `allowedOrigins` | `string[]` | CORS allowed origins (optional) |
145
+ | `emailTemplate` | `(payload: ContactPayload) => string` | Custom HTML body (optional) |
146
+
147
+ Example with rate limit and custom template:
148
+
149
+ ```ts
150
+ export const { POST } = createMailerHandler({
151
+ smtp: { ... },
152
+ to: 'you@example.com',
153
+ from: 'noreply@yoursite.com',
154
+ rateLimit: 10,
155
+ allowedOrigins: ['https://yoursite.com'],
156
+ emailTemplate: (payload) => `
157
+ <h2>New message from ${payload.name ?? payload.email}</h2>
158
+ <p><strong>Email:</strong> ${payload.email}</p>
159
+ <p>${payload.message.replace(/\n/g, '<br>')}</p>
160
+ `,
161
+ });
162
+ ```
163
+
164
+ ## Exports
165
+
166
+ - **Default (client):** `ContactForm`, and types `ContactFormProps`, `FormFieldName`, `FormFieldsConfig`, `ThemeOverrides`, `ContactFormPayload`, `SubmitState`.
167
+ - **`jdc-react-mailer/handler`:** `createMailerHandler`, and types `MailerHandlerConfig`, `ContactPayload`, `SmtpConfig`.
168
+ - **`jdc-react-mailer/style.css`:** Default styles (import once in your app).
169
+
170
+ ## Scripts
171
+
172
+ - `pnpm build` — build ESM + CJS and copy `style.css` to `dist`
173
+ - `pnpm dev` — watch build
174
+ - `pnpm test` — run Vitest
175
+ - `pnpm lint` — ESLint
176
+ - `pnpm format` — Prettier
177
+
178
+ ## Changelog
179
+
180
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -0,0 +1,197 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/handler/index.ts
31
+ var handler_exports = {};
32
+ __export(handler_exports, {
33
+ createMailerHandler: () => createMailerHandler
34
+ });
35
+ module.exports = __toCommonJS(handler_exports);
36
+
37
+ // src/handler/mailer.ts
38
+ var import_nodemailer = __toESM(require("nodemailer"), 1);
39
+ function defaultEmailTemplate(payload) {
40
+ const lines = [
41
+ "<h2>New contact form submission</h2>",
42
+ '<table style="border-collapse: collapse; font-family: sans-serif;">'
43
+ ];
44
+ if (payload.name) {
45
+ lines.push(`<tr><td style="padding: 6px 12px 6px 0; font-weight: bold;">Name</td><td style="padding: 6px 0;">${escapeHtml(payload.name)}</td></tr>`);
46
+ }
47
+ lines.push(`<tr><td style="padding: 6px 12px 6px 0; font-weight: bold;">Email</td><td style="padding: 6px 0;">${escapeHtml(payload.email)}</td></tr>`);
48
+ if (payload.phone) {
49
+ lines.push(`<tr><td style="padding: 6px 12px 6px 0; font-weight: bold;">Phone</td><td style="padding: 6px 0;">${escapeHtml(payload.phone)}</td></tr>`);
50
+ }
51
+ if (payload.subject) {
52
+ lines.push(`<tr><td style="padding: 6px 12px 6px 0; font-weight: bold;">Subject</td><td style="padding: 6px 0;">${escapeHtml(payload.subject)}</td></tr>`);
53
+ }
54
+ lines.push(`<tr><td style="padding: 6px 12px 6px 0; font-weight: bold; vertical-align: top;">Message</td><td style="padding: 6px 0;">${escapeHtml(payload.message).replace(/\n/g, "<br>")}</td></tr>`);
55
+ lines.push("</table>");
56
+ return lines.join("\n");
57
+ }
58
+ function escapeHtml(s) {
59
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
60
+ }
61
+ function createTransport(smtp) {
62
+ return import_nodemailer.default.createTransport(smtp);
63
+ }
64
+ function sendMail(transport, config, payload, htmlBody) {
65
+ const subject = payload.subject || "Contact form submission";
66
+ return transport.sendMail({
67
+ from: config.from,
68
+ to: config.to,
69
+ replyTo: payload.email,
70
+ subject,
71
+ html: htmlBody,
72
+ text: `Name: ${payload.name ?? "\u2014"}
73
+ Email: ${payload.email}
74
+ Phone: ${payload.phone ?? "\u2014"}
75
+ Subject: ${payload.subject ?? "\u2014"}
76
+
77
+ Message:
78
+ ${payload.message}`
79
+ });
80
+ }
81
+ function defaultTemplate(payload) {
82
+ return defaultEmailTemplate(payload);
83
+ }
84
+
85
+ // src/handler/validate.ts
86
+ var EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
87
+ function parseBody(body) {
88
+ try {
89
+ return JSON.parse(body);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+ function validatePayload(raw) {
95
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
96
+ return { ok: false, message: "Invalid payload" };
97
+ }
98
+ const o = raw;
99
+ const email = typeof o.email === "string" ? o.email.trim() : "";
100
+ const message = typeof o.message === "string" ? o.message.trim() : "";
101
+ const name = typeof o.name === "string" ? o.name.trim() : void 0;
102
+ const subject = typeof o.subject === "string" ? o.subject.trim() : void 0;
103
+ const website = typeof o.website === "string" ? o.website.trim() : "";
104
+ if (!email) return { ok: false, message: "Email is required" };
105
+ if (!EMAIL_REGEX.test(email)) return { ok: false, message: "Invalid email address" };
106
+ if (!message) return { ok: false, message: "Message is required" };
107
+ if (website) return { ok: false, message: "Invalid submission" };
108
+ return {
109
+ ok: true,
110
+ payload: { email, message, name, subject }
111
+ };
112
+ }
113
+
114
+ // src/handler/index.ts
115
+ function getClientIp(request) {
116
+ const forwarded = request.headers.get("x-forwarded-for");
117
+ if (forwarded) return forwarded.split(",")[0]?.trim() ?? "unknown";
118
+ const realIp = request.headers.get("x-real-ip");
119
+ if (realIp) return realIp;
120
+ return "unknown";
121
+ }
122
+ function createMailerHandler(config) {
123
+ const transport = createTransport(config.smtp);
124
+ const template = config.emailTemplate ?? defaultTemplate;
125
+ const rateLimitMap = /* @__PURE__ */ new Map();
126
+ const rateLimit = config.rateLimit ?? 0;
127
+ const windowMs = 6e4;
128
+ function checkRateLimit(ip) {
129
+ if (rateLimit <= 0) return true;
130
+ const now = Date.now();
131
+ const entry = rateLimitMap.get(ip);
132
+ if (!entry) {
133
+ rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
134
+ return true;
135
+ }
136
+ if (now > entry.resetAt) {
137
+ rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });
138
+ return true;
139
+ }
140
+ entry.count += 1;
141
+ return entry.count <= rateLimit;
142
+ }
143
+ async function POST(request) {
144
+ const origin = request.headers.get("origin") ?? "";
145
+ const allowedOrigins = config.allowedOrigins;
146
+ if (allowedOrigins && allowedOrigins.length > 0 && origin && !allowedOrigins.includes(origin)) {
147
+ return new Response(
148
+ JSON.stringify({ success: false, message: "Forbidden" }),
149
+ { status: 403, headers: { "Content-Type": "application/json" } }
150
+ );
151
+ }
152
+ const ip = getClientIp(request);
153
+ if (!checkRateLimit(ip)) {
154
+ return new Response(
155
+ JSON.stringify({ success: false, message: "Too many requests" }),
156
+ { status: 429, headers: { "Content-Type": "application/json" } }
157
+ );
158
+ }
159
+ let body;
160
+ try {
161
+ body = await request.text();
162
+ } catch {
163
+ return new Response(
164
+ JSON.stringify({ success: false, message: "Bad request" }),
165
+ { status: 400, headers: { "Content-Type": "application/json" } }
166
+ );
167
+ }
168
+ const raw = parseBody(body);
169
+ const validated = validatePayload(raw);
170
+ if (!validated.ok) {
171
+ return new Response(
172
+ JSON.stringify({ success: false, message: validated.message }),
173
+ { status: 400, headers: { "Content-Type": "application/json" } }
174
+ );
175
+ }
176
+ try {
177
+ const html = template(validated.payload);
178
+ await sendMail(transport, { from: config.from, to: config.to }, validated.payload, html);
179
+ return new Response(
180
+ JSON.stringify({ success: true }),
181
+ { status: 200, headers: { "Content-Type": "application/json" } }
182
+ );
183
+ } catch (err) {
184
+ const message = err instanceof Error ? err.message : "Failed to send email";
185
+ return new Response(
186
+ JSON.stringify({ success: false, message }),
187
+ { status: 500, headers: { "Content-Type": "application/json" } }
188
+ );
189
+ }
190
+ }
191
+ return { POST };
192
+ }
193
+ // Annotate the CommonJS export names for ESM import in node:
194
+ 0 && (module.exports = {
195
+ createMailerHandler
196
+ });
197
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/handler/index.ts","../../src/handler/mailer.ts","../../src/handler/validate.ts"],"sourcesContent":["import type { NextRequest } from 'next/server';\nimport type { MailerHandlerConfig } from './types';\nimport { createTransport, sendMail, defaultTemplate } from './mailer';\nimport { parseBody, validatePayload } from './validate';\n\nfunction getClientIp(request: NextRequest): string {\n const forwarded = request.headers.get('x-forwarded-for');\n if (forwarded) return forwarded.split(',')[0]?.trim() ?? 'unknown';\n const realIp = request.headers.get('x-real-ip');\n if (realIp) return realIp;\n return 'unknown';\n}\n\nexport function createMailerHandler(config: MailerHandlerConfig) {\n const transport = createTransport(config.smtp);\n const template = config.emailTemplate ?? defaultTemplate;\n\n const rateLimitMap = new Map<string, { count: number; resetAt: number }>();\n const rateLimit = config.rateLimit ?? 0;\n const windowMs = 60_000;\n\n function checkRateLimit(ip: string): boolean {\n if (rateLimit <= 0) return true;\n const now = Date.now();\n const entry = rateLimitMap.get(ip);\n if (!entry) {\n rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });\n return true;\n }\n if (now > entry.resetAt) {\n rateLimitMap.set(ip, { count: 1, resetAt: now + windowMs });\n return true;\n }\n entry.count += 1;\n return entry.count <= rateLimit;\n }\n\n async function POST(request: NextRequest) {\n const origin = request.headers.get('origin') ?? '';\n const allowedOrigins = config.allowedOrigins;\n if (allowedOrigins && allowedOrigins.length > 0 && origin && !allowedOrigins.includes(origin)) {\n return new Response(\n JSON.stringify({ success: false, message: 'Forbidden' }),\n { status: 403, headers: { 'Content-Type': 'application/json' } }\n );\n }\n\n const ip = getClientIp(request);\n if (!checkRateLimit(ip)) {\n return new Response(\n JSON.stringify({ success: false, message: 'Too many requests' }),\n { status: 429, headers: { 'Content-Type': 'application/json' } }\n );\n }\n\n let body: string;\n try {\n body = await request.text();\n } catch {\n return new Response(\n JSON.stringify({ success: false, message: 'Bad request' }),\n { status: 400, headers: { 'Content-Type': 'application/json' } }\n );\n }\n\n const raw = parseBody(body);\n const validated = validatePayload(raw);\n if (!validated.ok) {\n return new Response(\n JSON.stringify({ success: false, message: validated.message }),\n { status: 400, headers: { 'Content-Type': 'application/json' } }\n );\n }\n\n try {\n const html = template(validated.payload);\n await sendMail(transport, { from: config.from, to: config.to }, validated.payload, html);\n return new Response(\n JSON.stringify({ success: true }),\n { status: 200, headers: { 'Content-Type': 'application/json' } }\n );\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Failed to send email';\n return new Response(\n JSON.stringify({ success: false, message }),\n { status: 500, headers: { 'Content-Type': 'application/json' } }\n );\n }\n }\n\n return { POST };\n}\n\nexport type { MailerHandlerConfig, ContactPayload, SmtpConfig } from './types';\n","import nodemailer from 'nodemailer';\nimport type { Transporter } from 'nodemailer';\nimport type { SmtpConfig, ContactPayload } from './types';\n\nfunction defaultEmailTemplate(payload: ContactPayload): string {\n const lines: string[] = [\n '<h2>New contact form submission</h2>',\n '<table style=\"border-collapse: collapse; font-family: sans-serif;\">',\n ];\n if (payload.name) {\n lines.push(`<tr><td style=\"padding: 6px 12px 6px 0; font-weight: bold;\">Name</td><td style=\"padding: 6px 0;\">${escapeHtml(payload.name)}</td></tr>`);\n }\n lines.push(`<tr><td style=\"padding: 6px 12px 6px 0; font-weight: bold;\">Email</td><td style=\"padding: 6px 0;\">${escapeHtml(payload.email)}</td></tr>`);\n if (payload.phone) {\n lines.push(`<tr><td style=\"padding: 6px 12px 6px 0; font-weight: bold;\">Phone</td><td style=\"padding: 6px 0;\">${escapeHtml(payload.phone)}</td></tr>`);\n }\n if (payload.subject) {\n lines.push(`<tr><td style=\"padding: 6px 12px 6px 0; font-weight: bold;\">Subject</td><td style=\"padding: 6px 0;\">${escapeHtml(payload.subject)}</td></tr>`);\n }\n lines.push(`<tr><td style=\"padding: 6px 12px 6px 0; font-weight: bold; vertical-align: top;\">Message</td><td style=\"padding: 6px 0;\">${escapeHtml(payload.message).replace(/\\n/g, '<br>')}</td></tr>`);\n lines.push('</table>');\n return lines.join('\\n');\n}\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n}\n\nexport function createTransport(smtp: SmtpConfig): Transporter {\n return nodemailer.createTransport(smtp);\n}\n\nexport function sendMail(\n transport: Transporter,\n config: { from: string; to: string },\n payload: ContactPayload,\n htmlBody: string\n): Promise<void> {\n const subject = payload.subject || 'Contact form submission';\n return transport.sendMail({\n from: config.from,\n to: config.to,\n replyTo: payload.email,\n subject,\n html: htmlBody,\n text: `Name: ${payload.name ?? '—'}\\nEmail: ${payload.email}\\nPhone: ${payload.phone ?? '—'}\\nSubject: ${payload.subject ?? '—'}\\n\\nMessage:\\n${payload.message}`,\n }) as Promise<void>;\n}\n\nexport function defaultTemplate(payload: ContactPayload): string {\n return defaultEmailTemplate(payload);\n}\n","import type { ContactPayload } from './types';\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nexport function parseBody(body: string): unknown {\n try {\n return JSON.parse(body) as unknown;\n } catch {\n return null;\n }\n}\n\nexport function validatePayload(\n raw: unknown\n): { ok: true; payload: ContactPayload } | { ok: false; message: string } {\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {\n return { ok: false, message: 'Invalid payload' };\n }\n const o = raw as Record<string, unknown>;\n const email = typeof o.email === 'string' ? o.email.trim() : '';\n const message = typeof o.message === 'string' ? o.message.trim() : '';\n const name = typeof o.name === 'string' ? o.name.trim() : undefined;\n const subject = typeof o.subject === 'string' ? o.subject.trim() : undefined;\n const website = typeof o.website === 'string' ? o.website.trim() : '';\n\n if (!email) return { ok: false, message: 'Email is required' };\n if (!EMAIL_REGEX.test(email)) return { ok: false, message: 'Invalid email address' };\n if (!message) return { ok: false, message: 'Message is required' };\n if (website) return { ok: false, message: 'Invalid submission' };\n\n return {\n ok: true,\n payload: { email, message, name, subject },\n };\n}\n"],"mappings":"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","names":["nodemailer"]}
@@ -0,0 +1,48 @@
1
+ import { NextRequest } from 'next/server';
2
+
3
+ /**
4
+ * SMTP options passed to Nodemailer createTransport().
5
+ */
6
+ interface SmtpConfig {
7
+ host: string;
8
+ port: number;
9
+ secure?: boolean;
10
+ auth: {
11
+ user: string;
12
+ pass: string;
13
+ };
14
+ }
15
+ /**
16
+ * Configuration for the mailer route handler.
17
+ */
18
+ interface MailerHandlerConfig {
19
+ /** Nodemailer-compatible SMTP config */
20
+ smtp: SmtpConfig;
21
+ /** Recipient email address */
22
+ to: string;
23
+ /** From address (and optional name, e.g. "Site <noreply@site.com>") */
24
+ from: string;
25
+ /** Optional rate limit: max requests per minute per IP */
26
+ rateLimit?: number;
27
+ /** Optional allowed origins for CORS (default: same origin) */
28
+ allowedOrigins?: string[];
29
+ /** Optional custom email body template. Receives payload and returns HTML string */
30
+ emailTemplate?: (payload: ContactPayload) => string;
31
+ }
32
+ /**
33
+ * Validated payload from the contact form (handler expectation).
34
+ */
35
+ interface ContactPayload {
36
+ name?: string;
37
+ email: string;
38
+ phone?: string;
39
+ subject?: string;
40
+ message: string;
41
+ website?: string;
42
+ }
43
+
44
+ declare function createMailerHandler(config: MailerHandlerConfig): {
45
+ POST: (request: NextRequest) => Promise<Response>;
46
+ };
47
+
48
+ export { type ContactPayload, type MailerHandlerConfig, type SmtpConfig, createMailerHandler };
@@ -0,0 +1,48 @@
1
+ import { NextRequest } from 'next/server';
2
+
3
+ /**
4
+ * SMTP options passed to Nodemailer createTransport().
5
+ */
6
+ interface SmtpConfig {
7
+ host: string;
8
+ port: number;
9
+ secure?: boolean;
10
+ auth: {
11
+ user: string;
12
+ pass: string;
13
+ };
14
+ }
15
+ /**
16
+ * Configuration for the mailer route handler.
17
+ */
18
+ interface MailerHandlerConfig {
19
+ /** Nodemailer-compatible SMTP config */
20
+ smtp: SmtpConfig;
21
+ /** Recipient email address */
22
+ to: string;
23
+ /** From address (and optional name, e.g. "Site <noreply@site.com>") */
24
+ from: string;
25
+ /** Optional rate limit: max requests per minute per IP */
26
+ rateLimit?: number;
27
+ /** Optional allowed origins for CORS (default: same origin) */
28
+ allowedOrigins?: string[];
29
+ /** Optional custom email body template. Receives payload and returns HTML string */
30
+ emailTemplate?: (payload: ContactPayload) => string;
31
+ }
32
+ /**
33
+ * Validated payload from the contact form (handler expectation).
34
+ */
35
+ interface ContactPayload {
36
+ name?: string;
37
+ email: string;
38
+ phone?: string;
39
+ subject?: string;
40
+ message: string;
41
+ website?: string;
42
+ }
43
+
44
+ declare function createMailerHandler(config: MailerHandlerConfig): {
45
+ POST: (request: NextRequest) => Promise<Response>;
46
+ };
47
+
48
+ export { type ContactPayload, type MailerHandlerConfig, type SmtpConfig, createMailerHandler };