npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

comunicativi-contact-module

v0.1.6

Published

Plug-and-play contact form utilities: validate with Zod, send email via Mailchimp Transactional, save to MongoDB.

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 mongoose

Quick 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-react

The 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 payload passed to saveContact. 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