Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ permissions:

jobs:
ci:
name: Lint, Build & Test
name: Typecheck, Lint, Build & Test
runs-on: ubuntu-latest

steps:
Expand All @@ -21,8 +21,6 @@ jobs:
node-version: 20

- uses: pnpm/action-setup@v4
with:
version: 9

- name: Get pnpm store directory
id: pnpm-cache
Expand All @@ -38,9 +36,15 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm -r --if-present run typecheck

- name: Lint
run: pnpm lint

- name: Format check
run: pnpm format:check

- name: Build client
run: pnpm --filter @contactkit/client build

Expand Down
192 changes: 130 additions & 62 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,104 +1,172 @@
# contactkit

Self-hostable contact form backend with a zero-dependency TypeScript SDK. Resend by default, SMTP optional, one-click Railway deploy.
Self-hostable contact form backend with a zero-dependency TypeScript SDK.
Default email provider is Resend, SMTP is supported, and Railway deployment steps are included below.

[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/template/contactkit)
[![CI](https://github.com/dimeloper/contactkit/actions/workflows/ci.yml/badge.svg)](https://github.com/dimeloper/contactkit/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

## Overview
## Quickstart (Run this repo locally)

**contactkit** is a TypeScript pnpm monorepo containing two packages:
This gets the API running locally and verifies it end-to-end.

| Package | Description |
|---|---|
| [`@contactkit/server`](packages/server) | Fastify backend that accepts contact-form submissions and sends email via [Resend](https://resend.com) (default) or SMTP |
| [`@contactkit/client`](packages/client) | Tiny, framework-agnostic TypeScript SDK that posts to the server |
### 1. Prerequisites

## Quick start
- Node.js 20+
- pnpm 9+

### 1. Deploy the server to Railway
```bash
corepack enable
corepack prepare pnpm@9.15.4 --activate
```

### 2. Install dependencies

```bash
pnpm install
```

### 3. Configure environment variables

```bash
cp packages/server/.env.example packages/server/.env
```

Click the button above, or follow the [Railway deploy guide](https://docs.railway.app).
Set the minimum required variables in `packages/server/.env`:

Set the required environment variables (see [`packages/server/.env.example`](packages/server/.env.example)):
Resend example:

```env
EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
MAIL_TO=you@example.com
MAIL_FROM=noreply@yourdomain.com
```
MAILER=resend # or smtp
RESEND_API_KEY=re_... # required when MAILER=resend
TO_EMAIL=you@example.com # where contact submissions land
FROM_EMAIL=noreply@... # verified sender address

SMTP (Mailhog/local) example:

```env
EMAIL_PROVIDER=smtp
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE=false
MAIL_TO=you@example.com
MAIL_FROM=noreply@example.com
```

### 2. Install the client SDK
### 4. Start the server

```bash
npm install @contactkit/client
# or
pnpm add @contactkit/client
pnpm --filter @contactkit/server dev
```

### 3. Use the client
By default, the server starts at `http://localhost:3000`.

```ts
import { ContactKitClient } from '@contactkit/client';
### 5. Smoke test

const client = new ContactKitClient({ baseUrl: 'https://your-contactkit.railway.app' });
Health endpoint:

await client.submit({
name: 'Alice',
email: 'alice@example.com',
message: 'Hello from the contact form!',
});
```bash
curl -s http://localhost:3000/health
```

## Monorepo layout
Contact endpoint (works as-is when `TURNSTILE_SECRET` is not set):

```bash
curl -s -X POST http://localhost:3000/contact \
-H 'Content-Type: application/json' \
-d '{
"name": "Jane",
"email": "jane@example.com",
"message": "Hello from curl"
}'
```
contactkit/
├── packages/
│ ├── server/ # @contactkit/server — Fastify backend
│ └── client/ # @contactkit/client — TypeScript SDK
├── .github/workflows/ci.yml
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── eslint.config.js
└── prettier.config.js
```

## Development
## Deploy on Railway

### Option A: Deploy button

Use the button at the top of this README if the public template is available.
If it is unavailable, use Option B.

### Option B: Manual deploy

1. Create a new project in Railway and connect this repository.
2. Set the root directory to `packages/server`.
3. Add environment variables in Railway:
- Required: `MAIL_TO`, `MAIL_FROM`
- Provider-specific:
- Resend: `EMAIL_PROVIDER=resend`, `RESEND_API_KEY=...`
- SMTP: `EMAIL_PROVIDER=smtp`, `SMTP_HOST`, `SMTP_PORT`, optional `SMTP_USER`, `SMTP_PASS`, `SMTP_SECURE`
- Recommended: `ALLOWED_ORIGINS=https://your-frontend-domain.com`
4. Deploy the service.
5. Verify the deployment:

```bash
# Install dependencies
pnpm install
curl -s https://your-app.up.railway.app/health
```

# Run the server in dev mode
pnpm --filter @contactkit/server dev
## SDK Quickstart

# Run tests
pnpm test
### Browser

# Build all packages
pnpm build
```html
<script type="module">
import { ContactClient } from 'https://cdn.jsdelivr.net/npm/@contactkit/client/dist/index.js';

# Lint
pnpm lint
const client = new ContactClient({ baseUrl: 'https://contact.example.com' });

await client.send({
name: 'Jane',
email: 'jane@example.com',
message: 'Hello!',
subject: 'Inquiry', // optional
turnstileToken: '...', // optional
});
</script>
```

## Environment variables
### Node / Edge

See [`packages/server/.env.example`](packages/server/.env.example) for the full list.
```ts
import { ContactClient, ContactError } from '@contactkit/client';

const client = new ContactClient({
baseUrl: 'https://contact.example.com',
timeoutMs: 10_000, // optional, default 10 s
});

| Variable | Required | Default | Description |
|---|---|---|---|
| `MAILER` | – | `resend` | `resend` or `smtp` |
| `RESEND_API_KEY` | When `MAILER=resend` | – | Resend API key |
| `SMTP_HOST` | When `MAILER=smtp` | – | SMTP server hostname |
| `SMTP_PORT` | When `MAILER=smtp` | – | SMTP server port |
| `TO_EMAIL` | ✓ | – | Recipient email address |
| `FROM_EMAIL` | – | `noreply@example.com` | Sender email address |
| `TURNSTILE_SECRET` | – | – | Cloudflare Turnstile secret (omit to disable) |
| `CORS_ORIGIN` | – | `*` | Comma-separated allowed origins |
try {
const { id } = await client.send({
name: 'Jane',
email: 'jane@example.com',
message: 'Hello from Node!',
});
console.log('Sent, message id:', id);
} catch (err) {
if (err instanceof ContactError) {
console.error(err.code, err.status); // e.g. "validation" 400
}
}
```

## Configuration at a glance

- Full env reference: [packages/server/.env.example](packages/server/.env.example)
- Required in all environments: `MAIL_TO`, `MAIL_FROM`
- Provider selection: `EMAIL_PROVIDER=resend` (default) or `EMAIL_PROVIDER=smtp`
- Resend requires: `RESEND_API_KEY`
- SMTP requires: `SMTP_HOST`, `SMTP_PORT` (plus optional auth and TLS flags)
- Security/ops knobs: `ALLOWED_ORIGINS`, `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW`, optional `TURNSTILE_SECRET`

## Useful commands

```bash
pnpm test
pnpm build
pnpm lint
```

## License

Expand Down
5 changes: 4 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@
"require": "./dist/index.cjs"
}
},
"files": ["dist"],
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
Expand Down
70 changes: 53 additions & 17 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,85 @@
import { ContactKitError, NetworkError } from './errors.js';
import { ContactError, NetworkError } from './errors.js';
import type { ContactErrorCode } from './errors.js';

export interface ContactPayload {
export interface ContactInput {
name: string;
email: string;
message: string;
/** Optional Cloudflare Turnstile token */
subject?: string;
turnstileToken?: string;
}

export interface ContactKitOptions {
/** Base URL of the @contactkit/server instance, e.g. "https://contactkit.example.com" */
export interface ContactResponse {
ok: boolean;
id: string;
}

export interface ContactClientOptions {
/** Base URL of the @contactkit/server instance, e.g. "https://contact.example.com" */
baseUrl: string;
/** Optional fetch implementation for environments without native fetch (e.g. Node <18) */
fetchFn?: typeof fetch;
/** Optional fetch implementation; defaults to global fetch */
fetch?: typeof globalThis.fetch;
/** Request timeout in milliseconds; defaults to 10000 */
timeoutMs?: number;
}

function statusToCode(status: number): ContactErrorCode {
if (status === 400) return 'validation';
if (status === 403) return 'captcha_failed';
if (status === 429) return 'rate_limited';
return 'server';
}

export class ContactKitClient {
export class ContactClient {
private readonly baseUrl: string;
private readonly fetchFn: typeof fetch;
private readonly fetchFn: typeof globalThis.fetch;
private readonly timeoutMs: number;

constructor(options: ContactKitOptions) {
constructor(options: ContactClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchFn = options.fetchFn ?? fetch;
this.fetchFn = options.fetch ?? globalThis.fetch;
this.timeoutMs = options.timeoutMs ?? 10_000;
}

async submit(payload: ContactPayload): Promise<void> {
let response: Response;
async send(input: ContactInput): Promise<ContactResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);

let response: Response;
try {
response = await this.fetchFn(`${this.baseUrl}/contact`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify(input),
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
const isAbort =
err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError');
if (isAbort) {
throw new ContactError('Request timed out', 0, 'network');
}
throw new NetworkError('Failed to reach the ContactKit server', err);
} finally {
clearTimeout(timer);
}

if (!response.ok) {
const body = await response.json().catch(() => undefined);
throw new ContactKitError(
let code: ContactErrorCode = statusToCode(response.status);
try {
const body = (await response.json()) as { code?: ContactErrorCode };
if (body.code) code = body.code;
} catch {
// ignore parse errors
}
throw new ContactError(
`ContactKit server responded with ${response.status}`,
response.status,
body,
code,
);
}

return response.json() as Promise<ContactResponse>;
}
}
Loading
Loading