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
27 changes: 24 additions & 3 deletions packages/cloudflare/docs/blob-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,34 @@ Email bodies and attachments are large (kilobytes to megabytes). Storing them in

```typescript
interface BlobStorage {
put(key: string, content: string | ArrayBuffer): Promise<void>;
get(key: string): Promise<string | undefined>;
put(key: string, content: string | ArrayBuffer, options?: BlobPutOptions): Promise<void>;
get(key: string, options?: BlobGetOptions): Promise<BlobObject | null>;
delete(key: string): Promise<void>;
generateKey(contentHash: string, extension: string): string;
}

interface BlobObject {
text(): Promise<string>;
arrayBuffer(): Promise<ArrayBuffer>;
httpMetadata?: Record<string, string>;
customMetadata?: Record<string, string>;
}

interface BlobPutOptions {
httpMetadata?: Record<string, string>;
customMetadata?: Record<string, string>;
}

interface BlobGetOptions {
range?: { offset: number; length: number };
}
```

Any storage backend that supports key-value blob operations works: object storage (S3-compatible), file system, KV stores.
`get` returns a lazy `BlobObject` (not a string) so consumers choose whether to decode as text or binary via `.text()` / `.arrayBuffer()`. Partial reads are supported through `BlobGetOptions.range`, which IMAP `FETCH BODY[]<offset.length>` maps directly onto.

`generateKey(contentHash, extension)` centralizes the key schema so applications do not hand-roll paths. The Cloudflare implementation builds keys of the form `emails/{year}/{month}/{contentHash}.{extension}`.

Any storage backend that supports key-value blob operations works: object storage (S3-compatible), file system, KV stores. Ship-ready adapters in the repo: `createR2Storage` from `@rafters/mail-cloudflare/storage`.

---

Expand Down
87 changes: 58 additions & 29 deletions packages/cloudflare/docs/inbound.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,51 @@ The `InboundAdapter` interface handles incoming email. It receives a raw email,

```typescript
interface InboundAdapter {
handleIncoming(email: InboundEmail): Promise<void>;
handleIncoming(email: InboundEmail): Promise<{
messageId: string;
threadId: string;
}>;
}

interface InboundEmail {
raw: ArrayBuffer; // the complete RFC 5322 message as bytes
from: string; // parsed From header, for routing
to: string; // parsed To header, for mailbox resolution
headers: Record<string, string>; // all headers as key-value pairs
}
```

The adapter is responsible for:

1. Parsing the raw RFC 5322 email into structured fields
2. Finding or creating a thread (via In-Reply-To/References headers)
1. Parsing the raw RFC 5322 message from `raw` into structured fields (subject, date, CC, BCC, In-Reply-To, References, body parts, attachments)
2. Finding or creating a thread using the `In-Reply-To` and `References` headers
3. Storing the raw email and parsed content in blob storage
4. Creating the message record in the database
5. Updating thread metadata (messageCount, lastMessageAt, snippet)
5. Updating thread metadata (`unreadCount`, `lastMessageAt`, snippet)
6. Returning the resulting `messageId` and `threadId` for caller correlation

---

## Inbound email structure

When an email arrives, the adapter receives:

| Field | Source |
| ----------- | ---------------------------------- |
| from | From header (email + display name) |
| to | To header |
| cc, bcc | CC/BCC headers |
| subject | Subject header |
| messageId | Message-ID header |
| inReplyTo | In-Reply-To header |
| references | References header |
| date | Date header |
| textBody | Plain text content |
| htmlBody | HTML content |
| rawEmail | The complete RFC 5322 message |
| attachments | MIME attachments with metadata |
| headers | All headers as key-value pairs |
## Input vs parsed form

The `InboundEmail` type received by `handleIncoming` is minimal on purpose: `raw`, `from`, `to`, and `headers`. The adapter does the parsing, not the caller.

After parsing, the message row in the database carries the richer shape:

| Field | Source |
| ---------------------------- | -------------------------- |
| `fromEmail`, `fromName` | From header |
| `toEmail`, `toName` | To header |
| `subject` | Subject header |
| `messageIdHeader` | Message-ID header |
| `inReplyTo` | In-Reply-To header |
| `references` | References header |
| `sentAt` | Date header |
| `receivedAt` | When ingestion happened |
| `blobKeyRaw` | Key for the raw .eml blob |
| `blobKeyHtml`, `blobKeyText` | Keys for parsed body blobs |
| `sizeBytes` | Raw blob size |
| `attachmentCount` | Count of MIME attachments |

---

Expand Down Expand Up @@ -98,15 +110,32 @@ _dmarc.yourdomain.com TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain

## Classification

After storage, the inbound adapter can optionally classify the message using an AI classifier. The classifier assigns:
After storage, the inbound adapter can optionally classify the message using an AI classifier. The classifier interface is `EmailClassifier` from `@rafters/mail/interfaces`:

- **category**: support, feedback, abuse, partnership, spam, billing, legal, other
- **confidence**: 0-100 score
- **summary**: one-line description of the message content
```typescript
interface EmailClassifier {
classify(from: string, subject: string, body: string): Promise<EmailClassification>;
}

interface EmailClassification {
category:
| "support"
| "feedback"
| "abuse"
| "partnership"
| "spam"
| "billing"
| "legal"
| "other";
confidence: number; // 0..100
tags: string[]; // configurable pattern-matched tags
priority: "urgent" | "high" | "normal" | "low";
}
```

Classification results are stored on the message record. They drive folder assignment, priority, and dashboard filtering.
Classification results are stored on the message record (`aiCategory`, `aiConfidence`, priority, tags via label join). They drive folder assignment, thread priority, and dashboard filtering.

The classifier is a separate adapter (`ClassificationAdapter`). The default implementation uses zero-shot classification with a language model, but any classification strategy can be plugged in.
The default implementation in `@rafters/mail-workers-ai` uses DeBERTa-v3 zero-shot classification through Cloudflare Workers AI, but any classification strategy can be plugged in by implementing `EmailClassifier`.

---

Expand Down
42 changes: 27 additions & 15 deletions packages/cloudflare/docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,23 +70,30 @@ wrangler secret put RESEND_API_KEY
```typescript
// src/index.ts
import { createResendProvider } from "@rafters/mail-resend";
import { createR2BlobStorage, parseInboundEmail } from "@rafters/mail-cloudflare";
import { createR2Storage } from "@rafters/mail-cloudflare/storage";
import { parseEmailHeaders, hashContent } from "@rafters/mail-cloudflare/parsing";

export default {
// Handle inbound email from Cloudflare Email Routing
async email(message, env) {
const parsed = await parseInboundEmail(message);
const blobStorage = createR2BlobStorage(env.BLOB_STORAGE);
async email(message: ForwardableEmailMessage, env: Env) {
// Read the raw message bytes from the ReadableStream
const raw = await new Response(message.raw).arrayBuffer();

// Store raw email in blob storage
const blobKey = await blobStorage.put(parsed.rawEmail);
// Parse RFC 5322 headers and hash the content for dedupe
const headers = parseEmailHeaders(Object.fromEntries(message.headers.entries()));
const contentHash = await hashContent(raw);

// Store message record in D1
// Store the raw email in R2 via the BlobStorage adapter
const storage = createR2Storage({ bucket: env.BLOB_STORAGE });
const blobKey = storage.generateKey(contentHash, "eml");
await storage.put(blobKey, raw);

// Insert message row in D1, update thread, dispatch to classifier queue
// (wire up your service layer here)
},

// Handle HTTP requests (webhooks, API)
async fetch(request, env) {
async fetch(request: Request, env: Env) {
return new Response("Mail worker running");
},
};
Expand Down Expand Up @@ -151,7 +158,7 @@ Send an outbound email:
const provider = createResendProvider({ apiKey: env.RESEND_API_KEY });
await provider.sendEmail({
from: "you@yourdomain.com",
to: ["recipient@example.com"],
to: "recipient@example.com", // single recipient per sendEmail call
subject: "Hello from the edge",
text: "Sent via @rafters/mail on Cloudflare Workers.",
});
Expand All @@ -161,7 +168,10 @@ await provider.sendEmail({

## 10. Add IMAP (optional)

To access your mailbox from Apple Mail, Thunderbird, or Outlook, add the IMAP server. See the [IMAP Quickstart](./imap-quickstart.md) for Cloudflare DO (WebSocket) or Node TCP (Fly.io) deployment options.
To access your mailbox from Apple Mail, Thunderbird, or Outlook, add the IMAP server. See the docs shipped with the IMAP runtime packages:

- [`@rafters/mail-imap-server`](https://www.npmjs.com/package/@rafters/mail-imap-server) -- Node TCP for Fly.io / Railway / Fargate / VPS
- [`@rafters/mail-imap-cloudflare`](https://www.npmjs.com/package/@rafters/mail-imap-cloudflare) -- Durable Object + WebSocket for serverless

---

Expand All @@ -178,8 +188,10 @@ To access your mailbox from Apple Mail, Thunderbird, or Outlook, add the IMAP se

## Next steps

- [Classification](./classification.md) -- auto-categorize incoming email
- [IMAP](./imap-quickstart.md) -- connect email clients
- [Newsletters](./newsletters.md) -- send to subscriber lists
- [App Passwords](./app-passwords.md) -- set up IMAP authentication
- [Deployment Guide](./imap-deployment.md) -- deploy IMAP on Fly, Railway, or Docker
Per-package docs ship with each npm package:

- **Classification** -- see [`@rafters/mail-workers-ai`](https://www.npmjs.com/package/@rafters/mail-workers-ai) docs for auto-categorization
- **IMAP connect** -- see [`@rafters/mail-imap-server`](https://www.npmjs.com/package/@rafters/mail-imap-server) quickstart + deployment
- **IMAP auth** -- see [`@rafters/mail-imap`](https://www.npmjs.com/package/@rafters/mail-imap) `authentication.md` for the `AuthAdapter` contract
- **Newsletters** -- see [`@rafters/mail`](https://www.npmjs.com/package/@rafters/mail) `newsletters.md` for mailing lists, subscribers, campaigns
- **Inbound detail** -- see [`inbound.md`](./inbound.md) in this package
80 changes: 41 additions & 39 deletions packages/core/docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,59 +14,61 @@ This separation means the framework works with any SQLite-compatible database wi

## Schema

The core schema has 13 tables across two domains:

### Inbox tables (10)

| Table | Purpose |
| ------------------- | --------------------------------------------------------------- |
| mailbox | Email addresses that send and receive |
| inbox_folder | Folders per mailbox (inbox, sent, drafts, spam, trash, archive) |
| inbox_label | Labels for categorization (system and custom) |
| inbox_thread | Conversation threads |
| inbox_message | Individual email messages |
| inbox_message_label | Message-to-label associations |
| inbox_thread_label | Thread-to-label associations |
| inbox_attachment | File attachments |
| thread_assignment | Team member assignments to threads |
| thread_note | Internal notes on threads |

### Newsletter tables (3)

| Table | Purpose |
| ------------ | -------------------------------- |
| mailing_list | Named subscriber lists |
| subscriber | People on mailing lists |
| campaign | Broadcast messages sent to lists |
The core schema currently ships **10 inbox tables** in the exported migration SQL. An additional 3 newsletter tables are defined in the Drizzle schema but are not yet part of the migration SQL and are not written to by any shipped service -- they are reserved for future platform-side broadcast tracking and can be ignored at install time.

### Inbox tables (10) -- in `migrationSQL`

| Table | Purpose |
| --------------------- | --------------------------------------------------------------- |
| `mailbox` | Email addresses that send and receive |
| `inbox_folder` | Folders per mailbox (inbox, sent, drafts, spam, trash, archive) |
| `inbox_label` | Labels for categorization (system and custom) |
| `inbox_thread` | Conversation threads |
| `inbox_message` | Individual email messages |
| `inbox_message_label` | Message-to-label associations |
| `inbox_thread_label` | Thread-to-label associations |
| `inbox_attachment` | File attachments |
| `thread_assignment` | Team member assignments to threads |
| `thread_note` | Internal notes on threads |

### Newsletter tables (3) -- schema-only, NOT in `migrationSQL`

| Table | Purpose |
| --------------------- | --------------------------------------------------- |
| `platform_audience` | Platform-side mirror of the provider's mailing list |
| `platform_subscriber` | Platform-side mirror of a subscriber on an audience |
| `broadcast_audit` | Audit trail for campaign sends |

These are available as Drizzle schema exports (`platformAudience`, `platformSubscriber`, `broadcastAudit` from `@rafters/mail/schema`) if you want to include them in your own migrations, but the shipped `migrationSQL` string does not create them. The `EmailProvider` mailing list / subscriber / campaign methods talk to the provider's API (Resend, etc.), not to these tables.

---

## System folders

When a mailbox is created, these folders are initialized automatically:
System folders are not created automatically at migration time. Apply the migration, create a mailbox, then call `FolderService.initSystemFolders(mailboxId)` to populate the following folders for that mailbox:

| Folder | Slug | Purpose |
| ------- | ------- | ---------------------------------- |
| Inbox | inbox | Default landing for incoming email |
| Sent | sent | Outbound messages |
| Drafts | drafts | Unsent compositions |
| Spam | spam | Spam-classified messages |
| Trash | trash | Soft-deleted messages |
| Archive | archive | Archived threads |
| Folder | Slug | Purpose |
| ------- | --------- | ---------------------------------- |
| Inbox | `inbox` | Default landing for incoming email |
| Sent | `sent` | Outbound messages |
| Drafts | `drafts` | Unsent compositions |
| Spam | `spam` | Spam-classified messages |
| Trash | `trash` | Soft-deleted messages |
| Archive | `archive` | Archived threads |

System folders cannot be deleted. Custom folders can be created by the user.
System folders have `isSystem = true` and cannot be deleted via `FolderService.delete`. Custom folders can be created with `FolderService.create`.

---

## Conventions

All tables follow these conventions:

- **IDs**: UUIDv7 via default function. Text primary keys. Timestamp-ordered.
- **Timestamps**: integer milliseconds.
- **Soft delete**: every table has deletedAt. Null means active.
- **JSON columns**: SQLite text with JSON mode. Parsed at read time.
- **User references**: plain text columns (ownerId, assigneeId, etc.). No foreign keys to external auth tables. The auth adapter resolves identity at runtime.
- **IDs**: UUIDv7 via default function. Text primary keys. Timestamp-ordered, so sort-by-id is chronological.
- **Timestamps**: `integer` with `mode: 'timestamp_ms'`, default `unixepoch('subsecond') * 1000`. Millisecond precision.
- **Soft delete**: all tables have `deletedAt` except the two label join tables (`inbox_message_label`, `inbox_thread_label`), where deletion is a direct row removal because there is nothing semantic to "soft delete" -- a label is either applied or not.
- **JSON columns**: SQLite `text` with `mode: 'json'`. Stored as serialized JSON strings, parsed at read time.
- **User references**: plain `text` columns (`ownerId`, `assigneeId`, `authorId`, `appliedBy`, etc.). No foreign keys to external auth tables. The `AuthAdapter` resolves identity at runtime.

---

Expand Down
Loading
Loading