comunicativi-contact-module
v0.1.6
Published
Plug-and-play contact form utilities: validate with Zod, send email via Mailchimp Transactional, save to MongoDB.
Maintainers
Readme
comunicativi-contact-module
Plug-and-play contact form utilities: validate with Zod, send email via Mailchimp Transactional, save to MongoDB.
Compatible with Zod v3 and v4.
Install
npm install comunicativi-contact-module zod @mailchimp/mailchimp_transactional mongooseQuick start
1. Define a shared Zod schema
Create the schema once and reuse it on both the server (API route validation) and the client (form validation).
// schemas/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
fullName: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
phone: z.string().min(1, 'Phone is required'),
message: z.string().optional(),
privacyAccepted: z.literal(true),
});
export type ContactPayload = z.infer<typeof contactSchema>;2. API route
Parse the FormData body, validate the text fields, convert any uploaded files to base64, then send the email with attachments and save only the text fields to the database.
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import {
validatePayload,
sendEmail,
saveContact,
ContactValidationError,
type EmailAttachment,
} from 'comunicativi-contact-module';
import { contactSchema } from '@/schemas/contact';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
// Extract and validate text fields
const fields = {
fullName: formData.get('fullName'),
email: formData.get('email'),
phone: formData.get('phone'),
message: formData.get('message') || undefined,
privacyAccepted: formData.get('privacyAccepted') === 'true' ? true : formData.get('privacyAccepted'),
};
const data = validatePayload(contactSchema, fields);
// Convert uploaded files to base64 attachments
const attachments: EmailAttachment[] = [];
for (const entry of formData.getAll('attachments')) {
if (!(entry instanceof File) || entry.size === 0) continue;
const buffer = await entry.arrayBuffer();
attachments.push({
name: entry.name,
type: entry.type || 'application/octet-stream',
content: Buffer.from(buffer).toString('base64'),
});
}
// Send email - attachments are included here...
await sendEmail(
{
apiKey: process.env.MAILCHIMP_API_KEY!,
mailFrom: process.env.MAIL_FROM!,
mailFromName: process.env.MAIL_FROM_NAME,
mailTo: process.env.MAIL_TO!,
mailBcc: process.env.MAIL_BCC,
subjectTemplate: 'New contact from {{fullName}}',
attachments,
},
data
);
// ...but NOT saved to the database
await saveContact(
{ connectionUri: process.env.MONGODB_URI!, formId: 'contact_form' },
data
);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof ContactValidationError) {
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
console.error(error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}3. Frontend form (shadcn/ui + React Hook Form)
Install the required shadcn components and dependencies:
npx shadcn@latest add form input textarea button checkbox label
npm install react-hook-form @hookform/resolvers lucide-reactThe form manages its own file list in local state, separate from the React Hook Form schema (files are not part of the Zod schema - they go to the email only). On submit, all values are packed into a FormData object and sent without a Content-Type header so the browser sets the multipart boundary automatically.
// components/contact-form.tsx
'use client';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Paperclip, X } from 'lucide-react';
import { contactSchema, type ContactPayload } from '@/schemas/contact';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
type FormStatus = 'idle' | 'loading' | 'success' | 'error';
export function ContactForm() {
const [status, setStatus] = useState<FormStatus>('idle');
const [serverError, setServerError] = useState<string | null>(null);
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const form = useForm<ContactPayload>({
resolver: zodResolver(contactSchema),
defaultValues: { fullName: '', email: '', phone: '', message: '' },
});
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
setFiles((prev) => {
const existingNames = new Set(prev.map((f) => f.name));
return [...prev, ...selected.filter((f) => !existingNames.has(f.name))];
});
if (fileInputRef.current) fileInputRef.current.value = '';
}
function removeFile(name: string) {
setFiles((prev) => prev.filter((f) => f.name !== name));
}
async function onSubmit(data: ContactPayload) {
setStatus('loading');
setServerError(null);
try {
const formData = new FormData();
formData.append('fullName', data.fullName);
formData.append('email', data.email);
formData.append('phone', data.phone);
if (data.message) formData.append('message', data.message);
formData.append('privacyAccepted', String(data.privacyAccepted));
// Append each file under the same key - the API reads them with getAll('attachments')
files.forEach((file) => formData.append('attachments', file));
const res = await fetch('/api/contact', { method: 'POST', body: formData });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
setServerError(body?.error ?? body?.errors?.[0]?.message ?? 'Something went wrong.');
setStatus('error');
return;
}
setStatus('success');
form.reset();
setFiles([]);
} catch {
setServerError('Network error. Please check your connection and try again.');
setStatus('error');
}
}
if (status === 'success') {
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<p className="text-lg font-medium text-green-800">Message sent successfully!</p>
<p className="mt-1 text-sm text-green-700">We will get back to you as soon as possible.</p>
<Button variant="outline" className="mt-4" onClick={() => setStatus('idle')}>
Send another message
</Button>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
<FormField
control={form.control}
name="fullName"
render={({ field }) => (
<FormItem>
<FormLabel>Full name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input type="tel" placeholder="+1 555 000 0000" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>
Message <span className="text-muted-foreground font-normal">(optional)</span>
</FormLabel>
<FormControl>
<Textarea
placeholder="Write your message here..."
className="min-h-[120px] resize-y"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* File attachments - managed outside React Hook Form */}
<div className="space-y-2">
<p className="text-sm font-medium leading-none">
Attachments <span className="text-muted-foreground font-normal">(optional)</span>
</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-md border border-dashed border-zinc-300 px-4 py-3 text-sm text-zinc-500 transition-colors hover:border-zinc-400 hover:text-zinc-700"
>
<Paperclip className="h-4 w-4" />
Select files to attach
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
{files.length > 0 && (
<ul className="space-y-1">
{files.map((file) => (
<li
key={file.name}
className="flex items-center justify-between rounded-md bg-zinc-50 px-3 py-2 text-sm"
>
<span className="truncate text-zinc-700">
{file.name}
<span className="ml-2 text-xs text-zinc-400">
({(file.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => removeFile(file.name)}
className="ml-2 shrink-0 text-zinc-400 hover:text-destructive"
aria-label={`Remove ${file.name}`}
>
<X className="h-4 w-4" />
</button>
</li>
))}
</ul>
)}
</div>
<FormField
control={form.control}
name="privacyAccepted"
render={({ field }) => (
<FormItem className="flex flex-row items-start gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value === true}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="cursor-pointer font-normal">
I have read and accept the{' '}
<a href="/privacy" className="underline underline-offset-4 hover:text-foreground">
privacy policy
</a>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
{serverError && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{serverError}
</p>
)}
<Button type="submit" className="w-full" disabled={status === 'loading'}>
{status === 'loading' ? 'Sending...' : 'Send message'}
</Button>
</form>
</Form>
);
}Environment variables
| Variable | Required | Description |
|---|---|---|
| MAILCHIMP_API_KEY | yes | Mailchimp Transactional (Mandrill) API key |
| MAIL_FROM | yes | Sender email address |
| MAIL_FROM_NAME | no | Sender display name |
| MAIL_TO | yes | Recipient email address |
| MAIL_BCC | no | BCC email address |
| MONGODB_URI | yes | MongoDB connection string |
API reference
validatePayload<T>(schema, data): T
import { validatePayload, ContactValidationError } from 'comunicativi-contact-module';
const data = validatePayload(contactSchema, rawInput);Validates data against any Zod schema (v3 or v4). Returns the typed, parsed value on success. Throws a ContactValidationError on failure.
Compatible with any object that implements safeParse(data: unknown) - not locked to a specific Zod version.
sendEmail(config, payload): Promise<void>
await sendEmail(config, data);Sends an email via Mailchimp Transactional. The HTML and plain-text body are generated automatically from all key-value pairs in payload - no hardcoded field names, any form shape works.
EmailConfig
| Field | Type | Required | Description |
|---|---|---|---|
| apiKey | string | yes | Mailchimp Transactional API key |
| mailFrom | string | yes | Sender email address |
| mailFromName | string | no | Sender display name |
| mailTo | string | yes | Recipient email address |
| mailBcc | string | no | BCC email address |
| subjectTemplate | string | yes | Subject line - use {{fieldName}} to interpolate payload values |
| attachments | EmailAttachment[] | no | Files to attach to the email |
EmailAttachment
| Field | Type | Description |
|---|---|---|
| name | string | File name, e.g. "cv.pdf" |
| type | string | MIME type, e.g. "application/pdf" |
| content | string | File contents as a base64-encoded string |
saveContact(options, payload): Promise<void>
await saveContact({ connectionUri: process.env.MONGODB_URI!, formId: 'contact_form' }, data);Saves payload to a contacts MongoDB collection under a fields key. The Mongoose connection is cached per URI - safe to call on every request in a serverless environment.
SaveContactOptions
| Field | Type | Required | Description |
|---|---|---|---|
| connectionUri | string | yes | MongoDB connection string |
| formId | string | yes | Identifies which form produced the record |
Each saved document has the shape:
{
formId: string;
createdAt: Date;
fields: Record<string, unknown>; // the validated payload
}File attachments should not be included in the
payloadpassed tosaveContact. Pass only text fields to keep the database lean.
ContactValidationError
Thrown by validatePayload when the schema check fails.
try {
const data = validatePayload(schema, input);
} catch (error) {
if (error instanceof ContactValidationError) {
// error.errors: Array<{ field: string; message: string }>
return NextResponse.json({ errors: error.errors }, { status: 400 });
}
}License
MIT
