Rafters Mail

@rafters/mail Core Reference

Version: 0.1.0 Package: @rafters/mail Runtime: Edge-native (Cloudflare Workers, Deno Deploy, Vercel Edge, any V8 isolate) Database: SQLite (D1, Turso, libSQL) ORM: ORM-neutral. Default adapter @rafters/mail-drizzle; Kysely / Prisma adapters can slot in identically. Dependencies: uuidv7 and zod. All external concerns — including the ORM — are adapter packages.


Table of Contents

  1. Schema Reference
  2. Service Interfaces
  3. Adapter Interfaces
  4. Threading
  5. Folders
  6. Labels
  7. Assignments
  8. Notes
  9. Newsletter Tables
  10. Zod Schemas and Enums
  11. Migrations
  12. Design Decisions

Schema Reference

10 inbox tables. 3 newsletter tables. All tables follow these conventions:

  • IDs: UUIDv7 via $defaultFn. Primary keys are text type.
  • Timestamps: integer with mode: 'timestamp_ms'. Default: unixepoch('subsecond') * 1000. Millisecond precision.
  • Soft delete: Every table has a deletedAt column. Null means active. Populated means soft-deleted.
  • JSON columns: SQLite text with mode: 'json'. Stored as serialized JSON strings, parsed at read time.
  • User references: Plain text columns (ownerId, assigneeId, assignedBy, authorId, appliedBy). No foreign key constraints to external auth tables. The AuthAdapter resolves identity at runtime.

mailbox

Email addresses that can send and receive. Each mailbox is either personal (one owner) or shared (team-operated).

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
emailAddresstextNoThe email address. Unique. Column: email_address.
displayNametextYesDisplay name for the mailbox. Column: display_name.
typetext’personal’Nopersonal or shared. See mailboxTypeSchema.
ownerIdtextYesUser who owns this mailbox. Plain text, no FK. Nullable (shared mailboxes may have no single owner).
organizationIdtextNoOrganization this mailbox belongs to. Plain text, no FK. Required.
isActiveinteger1NoWhether the mailbox can send/receive.
autoReplyEnabledinteger0NoWhether auto-reply is enabled.
autoReplySubjecttextYesSubject line for auto-reply messages.
autoReplyBodytextYesBody content for auto-reply messages.
forwardToEmailtextYesEmail address to forward incoming messages to.
forwardEnabledinteger0NoWhether forwarding is enabled.
signaturetextYesDefault email signature for this mailbox.
descriptiontextYesDescription of the mailbox purpose.
icontextYesIcon identifier for UI rendering.
colortextYesHex color for UI rendering.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
updatedAtintegerunixepoch(‘subsecond’) * 1000NoLast update timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: unique on emailAddress.

Notes: A personal mailbox has one owner. A shared mailbox has one owner (typically an admin) and additional access is granted through the AuthAdapter. The organizationId column is required for multi-tenant deployments. The ownerId is nullable because shared mailboxes may not have a single owner.


inbox_folder

System and custom folders. Per-mailbox. System folders are created via FolderService.initSystemFolders() and cannot be renamed or deleted.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
mailboxIdtext (FK -> mailbox.id)NoParent mailbox.
nametextNoDisplay name.
slugtextNoURL-safe identifier. System folders use fixed slugs.
isSysteminteger0No1 for system folders, 0 for custom.
sortOrderinteger0NoDisplay order. System folders sort first.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: unique on (mailboxId, slug).

System folder slugs: inbox, sent, drafts, spam, trash, archive. See Folders for details.


inbox_label

Labels for categorizing messages and threads. Three types: system, AI-generated, and user-created. Per-mailbox.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
mailboxIdtext (FK -> mailbox.id)YesParent mailbox. Nullable for global system labels shared across mailboxes.
nametextNoDisplay name.
slugtextNoURL-safe identifier.
colortextYesHex color for UI rendering.
isSysteminteger0No1 for system labels (important, starred, unread).
isAiGeneratedinteger0No1 for labels created by the classifier.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: unique on (mailboxId, slug).

Notes: See Labels for the three label types and how they interact with messages and threads.


inbox_thread

Conversation grouping. One thread contains one or more messages. Threads track aggregate state: latest snippet, all participants, current folder, status, and priority.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
mailboxIdtext (FK -> mailbox.id)NoParent mailbox.
folderIdtext (FK -> inbox_folder.id)YesCurrent folder. Nullable (onDelete: “set null”).
subjecttextNoThread subject. Set from the first message.
snippettextYesPreview text. First 200 chars of the latest message’s plain text body.
participantstext (JSON)’[]‘NoJSON array of email addresses that have participated.
messageCountinteger0NoTotal messages in the thread.
unreadCountinteger0NoNumber of unread messages in the thread.
statustext’open’Noopen, pending, resolved, closed. See threadStatusSchema.
prioritytext’normal’Nolow, normal, high, urgent. See threadPrioritySchema.
lastMessageAtintegerNoTimestamp of the most recent message. Used for sort. Required.
startedAtintegerNoTimestamp when the thread was created/started.
updatedAtintegerunixepoch(‘subsecond’) * 1000NoLast update timestamp in ms.
archivedAtintegerYesTimestamp when the thread was archived.
deletedAtintegerYesSoft delete timestamp.

Indexes: on (mailboxId, folderId), on (mailboxId, status), on lastMessageAt.


inbox_message

Individual email messages. Stores RFC 5322 header data, envelope information, AI classification fields, and blob storage keys pointing to the raw email and parsed bodies.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
threadIdtext (FK -> inbox_thread.id)NoParent thread.
mailboxIdtext (FK -> mailbox.id)NoParent mailbox.
messageIdtextNoRFC 5322 Message-ID header value. Format: <uuidv7@domain> for outbound.
inReplyTotextYesRFC 5322 In-Reply-To header. References the parent message’s Message-ID.
referencestextYesRFC 5322 References header. Space-separated list of Message-IDs in the thread chain.
fromEmailtextNoSender email address.
fromNametextYesSender display name.
toEmailtextNoPrimary recipient email address.
toNametextYesPrimary recipient display name.
replyToEmailtextYesReply-To email address (if different from fromEmail).
ccEmailstext (JSON)’[]‘NoJSON array of CC recipient email addresses.
bccEmailstext (JSON)’[]‘NoJSON array of BCC recipient email addresses.
subjecttextNoMessage subject line.
blobKeyRawtextNoBlob storage key for the raw .eml file. Required.
blobKeyHtmltextYesBlob storage key for the parsed HTML body.
blobKeyTexttextYesBlob storage key for the parsed plain text body.
isOutboundinteger0NoWhether this is an outbound message. 0 = inbound, 1 = outbound.
isReadinteger0NoRead status.
isStarredinteger0NoStar status.
aiCategorytextYesAI-assigned category. See aiCategorySchema.
aiConfidenceintegerYesAI classification confidence score. 0-100.
aiSummarytextYesAI-generated summary of the message content.
isSpaminteger0NoWhether the message is classified as spam.
spamScoreintegerYesSpam score from classifier. 0-100.
sizeBytesintegerYesTotal size of the message in bytes.
attachmentCountinteger0NoNumber of attachments on the message.
sentAtintegerYesWhen the message was sent (from Date header or send time).
receivedAtintegerYesWhen the message was received by the system.
deletedAtintegerYesSoft delete timestamp.

Indexes: unique on messageId, on (threadId), on (mailboxId, receivedAt), on fromEmail, on receivedAt, on aiCategory.

Notes: This table has no createdAt or updatedAt columns. Temporal data is tracked via receivedAt, sentAt, and deletedAt. Content is blob-only: the bodyPlainText and bodyHtml inline columns do not exist. All message content is stored in blob storage and referenced by blobKeyRaw (required), blobKeyHtml, and blobKeyText.

Blob storage pattern: Raw email is the source of truth. D1 stores parsed metadata for fast queries. If metadata is wrong, re-derive from the raw email in blob storage. Key format: emails/{year}/{month}/{sha256-first-16-chars}.{eml|html|txt} (month is zero-padded).


inbox_message_label

Join table. Many-to-many between messages and labels. Tracks who or what applied the label.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
messageIdtext (FK -> inbox_message.id)NoThe message.
labelIdtext (FK -> inbox_label.id)NoThe label.
appliedBytextYesUser ID of who applied the label. Null means system or AI.
appliedAtintegerunixepoch(‘subsecond’) * 1000NoWhen the label was applied.

Indexes: unique on (messageId, labelId).


inbox_thread_label

Join table. Many-to-many between threads and labels. Used for thread-level filtering and organization.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
threadIdtext (FK -> inbox_thread.id)NoThe thread.
labelIdtext (FK -> inbox_label.id)NoThe label.
appliedBytextYesUser ID of who applied the label. Null means system or AI.
appliedAtintegerunixepoch(‘subsecond’) * 1000NoWhen the label was applied.

Indexes: unique on (threadId, labelId).


inbox_attachment

Attachment metadata. Actual content lives in blob storage. Supports both inline attachments (referenced by Content-ID in HTML body) and regular file attachments.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
messageIdtext (FK -> inbox_message.id)NoParent message.
filenametextYesOriginal filename.
contentTypetextNoMIME type (e.g., application/pdf, image/png).
sizeBytesintegerNoFile size in bytes.
blobKeytextNoBlob storage key for the attachment content.
contentIdtextYesContent-ID for inline attachments. Used in HTML cid: references.
isInlineinteger0No1 if this is an inline attachment (embedded in HTML body).
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: on messageId, on contentId.


thread_assignment

Thread-level assignment for shared mailbox collaboration. One active assignment per thread at a time. See Assignments.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
threadIdtext (FK -> inbox_thread.id)NoThe thread being assigned.
assigneeIdtextNoUser ID of the assignee. Plain text, no FK.
assignedBytextYesUser ID of who made the assignment. Null for system assignment.
statustext’active’Noactive, completed, reassigned.
notetextYesOptional note about the assignment.
assignedAtintegerunixepoch(‘subsecond’) * 1000NoWhen the assignment was created.
completedAtintegerYesWhen the assignment was completed.
deletedAtintegerYesSoft delete timestamp. Used for audit trail.

Indexes: on (threadId, status), on assigneeId.

Constraint: Only one row with status = 'active' per threadId at any time. Enforced at the service layer, not the database.


thread_note

Internal notes on threads. Markdown content. Not visible to external parties. Used for team collaboration on shared mailbox threads.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
threadIdtext (FK -> inbox_thread.id)NoParent thread.
authorIdtextNoUser ID of the note author. Plain text, no FK.
contenttextNoNote body. Markdown.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
updatedAtintegerunixepoch(‘subsecond’) * 1000NoLast update timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: on threadId.


Service Interfaces

All service interfaces are defined in @rafters/mail. Implementations live in ORM adapter packages. The default @rafters/mail-drizzle ships factory functions (createMailServices, createInboxEmailService, etc.) that receive a Drizzle database instance plus any other required adapters and return objects that satisfy the core interfaces.


InboxEmailService

Outbound email operations from the inbox. Handles composing new emails and replying within existing threads.

interface InboxEmailService {
  replyToThread(params: ReplyToThreadParams): Promise<{ messageId: string }>;
  composeEmail(params: ComposeEmailParams): Promise<{ threadId: string; messageId: string }>;
}

replyToThread

Send a reply within an existing thread.

ParameterTypeRequiredDescription
params.threadIdstringYesThread to reply to.
params.senderIdstringYesUser ID of the sender.
params.bodyHtmlstringYesHTML body of the reply.
params.bodystringNoPlain text alternative.
params.ccEmailsstring[]NoCC recipients.
params.bccEmailsstring[]NoBCC recipients.

Returns: { messageId: string }. The ID of the newly created message.

Behavior:

  1. Looks up the thread and its latest message.
  2. Generates a new Message-ID via generateMessageId().
  3. Sets In-Reply-To to the latest message’s Message-ID.
  4. Builds the References header by appending In-Reply-To to the existing chain.
  5. Sends via the EmailProvider adapter.
  6. Stores the outbound message in inbox_message with isOutbound = true.
  7. Moves the thread to the sent folder context and updates lastMessageAt, snippet, and messageCount.

composeEmail

Create a new thread with an outbound message.

ParameterTypeRequiredDescription
params.mailboxIdstringYesSending mailbox.
params.tostringYesRecipient email address.
params.subjectstringYesSubject line.
params.bodystringYesHTML body.
params.plainTextstringNoPlain text alternative.
params.ccEmailsstring[]NoCC recipients.
params.bccEmailsstring[]NoBCC recipients.

Returns: { threadId: string; messageId: string }.

Behavior:

  1. Creates a new inbox_thread with the subject and initial participants.
  2. Creates a new inbox_message with isOutbound = true and a generated Message-ID.
  3. Sends via the EmailProvider adapter.
  4. Places the thread in the sent folder.

ThreadService

Thread management: retrieval, folder movement, status updates, and bulk operations.

interface ThreadService {
  getThread(threadId: string): Promise<Thread | undefined>;
  listThreads(mailboxId: string, folderId?: string): Promise<Thread[]>;
  moveToFolder(threadId: string, folderId: string): Promise<void>;
  updateStatus(threadId: string, status: ThreadStatus): Promise<void>;
  updatePriority(threadId: string, priority: ThreadPriority): Promise<void>;
  archive(threadId: string): Promise<void>;
  trash(threadId: string): Promise<void>;
}

getThread

Retrieve a single thread with its messages.

ParameterTypeRequiredDescription
threadIdstringYesThread to retrieve.

Returns: Thread | undefined. Thread record with nested messages, labels, and assignment. Returns undefined if the thread does not exist or is soft-deleted.

listThreads

List threads in a mailbox, optionally filtered by folder.

ParameterTypeRequiredDescription
mailboxIdstringYesMailbox to list threads from.
folderIdstringNoFilter to a specific folder. If omitted, returns all threads.

Returns: Thread[]. Ordered by lastMessageAt descending.

moveToFolder

Move a thread to a different folder.

ParameterTypeRequiredDescription
threadIdstringYesThread to move.
folderIdstringYesTarget folder.

Behavior: Updates inbox_thread.folderId. Validates the target folder exists and belongs to the same mailbox.

updateStatus

Set the thread’s workflow status.

ParameterTypeRequiredDescription
threadIdstringYesThread to update.
statusThreadStatusYesNew status: open, pending, resolved, closed.

updatePriority

Set the thread’s priority level.

ParameterTypeRequiredDescription
threadIdstringYesThread to update.
priorityThreadPriorityYesNew priority: low, normal, high, urgent.

archive

Move a thread to the archive folder.

ParameterTypeRequiredDescription
threadIdstringYesThread to archive.

Behavior: Finds the archive system folder for the thread’s mailbox and updates folderId. Equivalent to moveToFolder(threadId, archiveFolderId).

trash

Move a thread to the trash folder.

ParameterTypeRequiredDescription
threadIdstringYesThread to trash.

Behavior: Finds the trash system folder for the thread’s mailbox and updates folderId. Trash auto-purge (30 days) is an app-level concern, not implemented in core.


FolderService

Folder CRUD and system folder initialization.

interface FolderService {
  createFolder(mailboxId: string, name: string): Promise<Folder>;
  listFolders(mailboxId: string): Promise<Folder[]>;
  deleteFolder(folderId: string): Promise<void>;
  initSystemFolders(mailboxId: string): Promise<void>;
}

createFolder

Create a custom folder.

ParameterTypeRequiredDescription
mailboxIdstringYesParent mailbox.
namestringYesFolder display name.

Returns: Folder. The created folder. Slug is derived from the name.

Behavior: Sets isSystem = 0. Validates the slug does not collide with system folder slugs.

listFolders

List all folders for a mailbox.

ParameterTypeRequiredDescription
mailboxIdstringYesParent mailbox.

Returns: Folder[]. Ordered by sortOrder. System folders first, then custom folders.

deleteFolder

Soft-delete a custom folder.

ParameterTypeRequiredDescription
folderIdstringYesFolder to delete.

Throws: If the folder is a system folder (isSystem = 1). System folders cannot be deleted.

Behavior: Sets deletedAt. Threads in the deleted folder should be moved by the app (core does not auto-relocate).

initSystemFolders

Create the six system folders for a mailbox. Idempotent.

ParameterTypeRequiredDescription
mailboxIdstringYesMailbox to initialize.

Behavior: Creates inbox, sent, drafts, spam, trash, archive folders with isSystem = 1. Skips any that already exist. Should be called when a mailbox is created.


LabelService

Label CRUD and application to messages and threads.

interface LabelService {
  createLabel(mailboxId: string, name: string): Promise<Label>;
  listLabels(mailboxId: string): Promise<Label[]>;
  applyToMessage(messageId: string, labelId: string, appliedBy?: string): Promise<void>;
  applyToThread(threadId: string, labelId: string, appliedBy?: string): Promise<void>;
  removeFromMessage(messageId: string, labelId: string): Promise<void>;
  removeFromThread(threadId: string, labelId: string): Promise<void>;
}

createLabel

Create a user-defined label.

ParameterTypeRequiredDescription
mailboxIdstringYesParent mailbox.
namestringYesLabel display name.

Returns: Label. The created label with isSystem = 0, isAiGenerated = 0.

listLabels

List all labels for a mailbox.

ParameterTypeRequiredDescription
mailboxIdstringYesParent mailbox.

Returns: Label[]. All label types (system, AI, user).

applyToMessage

Apply a label to a message.

ParameterTypeRequiredDescription
messageIdstringYesTarget message.
labelIdstringYesLabel to apply.
appliedBystringNoUser ID. Null means system or AI applied it.

Behavior: Inserts into inbox_message_label. No-op if the label is already applied.

applyToThread

Apply a label to a thread.

ParameterTypeRequiredDescription
threadIdstringYesTarget thread.
labelIdstringYesLabel to apply.
appliedBystringNoUser ID. Null means system or AI applied it.

Behavior: Inserts into inbox_thread_label. No-op if the label is already applied.

removeFromMessage

Remove a label from a message.

ParameterTypeRequiredDescription
messageIdstringYesTarget message.
labelIdstringYesLabel to remove.

Behavior: Deletes the row from inbox_message_label.

removeFromThread

Remove a label from a thread.

ParameterTypeRequiredDescription
threadIdstringYesTarget thread.
labelIdstringYesLabel to remove.

Behavior: Deletes the row from inbox_thread_label.


AssignmentService

Thread assignment for shared mailbox collaboration. See Assignments.

interface AssignmentService {
  assign(threadId: string, assigneeId: string, assignedBy?: string): Promise<void>;
  reassign(threadId: string, newAssigneeId: string, assignedBy?: string): Promise<void>;
  complete(threadId: string): Promise<void>;
  getActiveAssignment(threadId: string): Promise<Assignment | null>;
}

assign

Assign a thread to a user.

ParameterTypeRequiredDescription
threadIdstringYesThread to assign.
assigneeIdstringYesUser ID of the assignee.
assignedBystringNoUser ID of who made the assignment.

Throws: If the thread already has an active assignment. Use reassign instead.

Behavior: Creates a thread_assignment row with status = 'active'.

reassign

Reassign a thread to a different user.

ParameterTypeRequiredDescription
threadIdstringYesThread to reassign.
newAssigneeIdstringYesUser ID of the new assignee.
assignedBystringNoUser ID of who initiated the reassignment.

Behavior: Sets the current active assignment’s status to reassigned and soft-deletes it. Creates a new thread_assignment row with status = 'active' for the new assignee. The old assignment is preserved for audit.

complete

Mark the active assignment as completed.

ParameterTypeRequiredDescription
threadIdstringYesThread whose assignment to complete.

Behavior: Sets status = 'completed' on the active assignment. Does not soft-delete. A completed assignment is a terminal state.

getActiveAssignment

Get the current active assignment for a thread.

ParameterTypeRequiredDescription
threadIdstringYesThread to query.

Returns: Assignment | null. Null if no active assignment exists.


NoteService

Internal notes on threads. See Notes.

interface NoteService {
  addNote(threadId: string, authorId: string, content: string): Promise<Note>;
  listNotes(threadId: string): Promise<Note[]>;
  deleteNote(noteId: string): Promise<void>;
}

addNote

Add an internal note to a thread.

ParameterTypeRequiredDescription
threadIdstringYesParent thread.
authorIdstringYesUser ID of the note author.
contentstringYesMarkdown content.

Returns: Note. The created note.

listNotes

List all notes on a thread.

ParameterTypeRequiredDescription
threadIdstringYesParent thread.

Returns: Note[]. Ordered by createdAt ascending. Excludes soft-deleted notes.

deleteNote

Soft-delete a note.

ParameterTypeRequiredDescription
noteIdstringYesNote to delete.

Behavior: Sets deletedAt. The note is preserved for audit trail but excluded from listNotes results.


Adapter Interfaces

Adapters are contracts that the consuming app must implement (or use a provided adapter package). The core package defines the interfaces as Zod schemas with inferred TypeScript types. The core has zero dependency on any adapter implementation.


AuthAdapter

Resolves user identity and access control. The app provides the implementation. No default implementation is shipped.

const inboxUserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string().optional(),
});

const inboxRoleSchema = z.enum(["owner", "admin", "agent", "viewer"]);

type InboxUser = z.infer<typeof inboxUserSchema>;
type InboxRole = z.infer<typeof inboxRoleSchema>;

interface AuthAdapter {
  getCurrentUser(): Promise<InboxUser>;
  getUserById(id: string): Promise<InboxUser | null>;
  hasMailboxAccess(userId: string, mailboxId: string): Promise<boolean>;
  getUserRole(userId: string, mailboxId: string): Promise<InboxRole | null>;
}

getCurrentUser

Return the authenticated user for the current request.

Returns: InboxUser.

Notes: How authentication works is entirely the app’s concern. This could read from a session cookie, JWT, or any other mechanism.

getUserById

Look up a user by ID.

ParameterTypeRequiredDescription
idstringYesUser ID.

Returns: InboxUser | null. Null if the user does not exist.

hasMailboxAccess

Check whether a user has any level of access to a mailbox.

ParameterTypeRequiredDescription
userIdstringYesUser to check.
mailboxIdstringYesMailbox to check against.

Returns: boolean.

getUserRole

Get the user’s role for a specific mailbox.

ParameterTypeRequiredDescription
userIdstringYesUser to check.
mailboxIdstringYesMailbox to check against.

Returns: InboxRole | null. Null if the user has no role (no access).

Roles:

  • owner: Full control. Can delete the mailbox.
  • admin: Manage folders, labels, assignments. Cannot delete the mailbox.
  • agent: Can read, reply, assign, add notes. Cannot manage folders or labels.
  • viewer: Read-only access.

InboundAdapter

Receives email from an external source, parses it, stores it, and creates/updates the thread.

const inboundEmailSchema = z.object({
  raw: z.instanceof(ArrayBuffer),
  from: z.string().email(),
  to: z.string().email(),
  headers: z.record(z.string()),
});

type InboundEmail = z.infer<typeof inboundEmailSchema>;

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

handleIncoming

Process an inbound email.

ParameterTypeRequiredDescription
email.rawArrayBufferYesRaw RFC 5322 email bytes.
email.fromstringYesSender email address (from envelope).
email.tostringYesRecipient email address (from envelope).
email.headersRecord<string, string>YesParsed headers. Must include Message-ID. Should include In-Reply-To, References, Subject, Date.

Returns: { messageId: string; threadId: string }.

Behavior:

  1. Store raw .eml in blob storage.
  2. Parse and store HTML and plain text bodies separately in blob storage.
  3. Insert metadata into inbox_message.
  4. Match to an existing thread via In-Reply-To/References headers. Create a new thread if no match.
  5. Update thread’s snippet, lastMessageAt, messageCount, participants.
  6. Dispatch to classification queue/workflow.

Implementations: @rafters/mail-cloudflare (Cloudflare Email Routing + R2).


EmailProvider (OutboundAdapter)

Sends email via an external provider. Also manages mailing lists, subscribers, and campaigns for newsletter functionality.

const emailParamsSchema = z.object({
  to: z.string().email(),
  subject: z.string().min(1),
  html: z.string().optional(),
  text: z.string().optional(),
  from: z.string().email().optional(),
  replyTo: z.string().email().optional(),
});

type EmailParams = z.infer<typeof emailParamsSchema>;

interface EmailProvider {
  // Transactional
  sendEmail(params: EmailParams): Promise<{ id: string }>;

  // Mailing lists
  createMailingList(name: string): Promise<MailingList>;
  getMailingList(id: string): Promise<MailingList>;
  deleteMailingList(id: string): Promise<void>;

  // Subscribers
  addSubscriber(listId: string, email: string, data?: SubscriberData): Promise<Subscriber>;
  removeSubscriber(listId: string, subscriberId: string): Promise<void>;
  updateSubscriber(subscriberId: string, updates: SubscriberUpdates): Promise<Subscriber>;
  listSubscribers(listId: string): Promise<Subscriber[]>;

  // Campaigns
  sendCampaign(params: CampaignParams): Promise<{ id: string }>;
  getCampaign(id: string): Promise<{ id: string; subject: string; sentAt: Date }>;
  createCampaignDraft(params: CampaignParams): Promise<{ id: string }>;
  sendCampaignDraft(campaignId: string): Promise<{ id: string }>;
  getCampaignStatus(campaignId: string): Promise<CampaignStatus>;

  // Audiences
  listAudiences(): Promise<Audience[]>;
}

sendEmail

Send a single transactional email.

ParameterTypeRequiredDescription
params.tostringYesRecipient.
params.subjectstringYesSubject line.
params.htmlstringNoHTML body.
params.textstringNoPlain text body.
params.fromstringNoSender override. Uses default if omitted.
params.replyTostringNoReply-To address.

Returns: { id: string }. Provider-assigned message ID.

Mailing list methods

createMailingList, getMailingList, deleteMailingList manage platform-wide mailing lists. These map to the platform_audience table and the provider’s concept of audiences/lists.

Subscriber methods

addSubscriber, removeSubscriber, updateSubscriber, listSubscribers manage individual subscriptions. These map to platform_subscriber and the provider’s subscriber/contact records.

Campaign methods

sendCampaign sends immediately. createCampaignDraft + sendCampaignDraft supports a two-step draft-then-send flow. getCampaign and getCampaignStatus retrieve campaign state and delivery status.

Vocabulary mapping: The core uses platform vocabulary (MailingList, Subscriber, Campaign). Adapter implementations translate to vendor vocabulary at the boundary. Example: Resend uses Audience, Contact, Broadcast.

Implementations: @rafters/mail-resend (Resend API via fetch).


TemplateRenderer

Renders email templates to HTML and optional plain text.

interface TemplateRenderer {
  render(
    template: string,
    props: Record<string, unknown>,
  ): Promise<{ html: string; text?: string }>;
}

render

ParameterTypeRequiredDescription
templatestringYesTemplate identifier (e.g., 'otp', 'welcome').
propsRecord<string, unknown>YesTemplate data. Shape depends on the template.

Returns: { html: string; text?: string }. The text field is optional; not all renderers produce a plain text version.

Implementations: @rafters/mail-react-email (React Email).


EmailClassifier

Classifies email content into categories with confidence scores and auto-generated tags.

const emailClassificationSchema = z.object({
  category: z.enum([
    "support",
    "feedback",
    "abuse",
    "partnership",
    "spam",
    "billing",
    "legal",
    "other",
  ]),
  confidence: z.number().min(0).max(100),
  tags: z.array(z.string()),
  priority: z.enum(["low", "normal", "high", "urgent"]),
});

type EmailClassification = z.infer<typeof emailClassificationSchema>;

interface EmailClassifier {
  classify(from: string, subject: string, body: string): Promise<EmailClassification>;
}

classify

ParameterTypeRequiredDescription
fromstringYesSender email address.
subjectstringYesMessage subject.
bodystringYesPlain text body (truncated to classifier’s max input length).

Returns: EmailClassification.

Category-to-priority defaults:

  • abuse, legal: always high.
  • support, billing: default normal.
  • feedback, partnership: default normal.
  • other: default low.
  • Keyword overrides can escalate to urgent or high regardless of category.

Helper function:

function isLegitimateCategory(category: EmailCategory): boolean;

Returns true for all categories except spam. Used by the inbound flow to decide whether a message stays in the inbox or moves to the spam folder.

Implementations: @rafters/mail-workers-ai (Cloudflare Workers AI, DeBERTa-v3 zero-shot).


BlobStorage

Stores and retrieves raw email content, parsed bodies, and attachments.

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

interface BlobStorage {
  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;
}

put

Store content at a key.

ParameterTypeRequiredDescription
keystringYesStorage key.
contentstring or ArrayBufferYesContent to store.
optionsBlobPutOptionsNoMetadata, content type, etc.

get

Retrieve content by key.

ParameterTypeRequiredDescription
keystringYesStorage key.
optionsBlobGetOptionsNoRange reads, etc.

Returns: BlobObject | null. Null if the key does not exist.

delete

Remove content by key.

ParameterTypeRequiredDescription
keystringYesStorage key.

generateKey

Generate a storage key from a content hash and file extension.

ParameterTypeRequiredDescription
contentHashstringYesSHA-256 hash of the content (first 16 chars used).
extensionstringYesFile extension: eml, html, txt.

Returns: string. Format: emails/{year}/{month}/{hash}.{extension} (month is zero-padded, e.g. 01 not 1).

Implementations: @rafters/mail-cloudflare (Cloudflare R2). Community can add S3, GCS, etc.


Threading

RFC 5322 compliant. Gmail-style conversation grouping.

Message-ID Generation

function generateMessageId(domain: string): string;

Returns: <{uuidv7}@{domain}>. Example: <01912345-6789-7abc-def0-123456789abc@example.com>.

UUIDv7 guarantees uniqueness and provides natural time-ordering. The domain portion identifies the originating system.

References Chain

function buildReferences(existingReferences: string | null, inReplyTo: string | null): string;

Appends the In-Reply-To value to the existing References chain. If existingReferences is null, the result is just the In-Reply-To value. If both are null, returns an empty string.

Example chain for a 4-message thread:

Message 1: Message-ID: <A@ex.com>
Message 2: Message-ID: <B@ex.com>, In-Reply-To: <A@ex.com>, References: <A@ex.com>
Message 3: Message-ID: <C@ex.com>, In-Reply-To: <B@ex.com>, References: <A@ex.com> <B@ex.com>
Message 4: Message-ID: <D@ex.com>, In-Reply-To: <C@ex.com>, References: <A@ex.com> <B@ex.com> <C@ex.com>

Inbound Thread Matching

When a new inbound message arrives:

  1. Check In-Reply-To: Look up inbox_message where messageId = incomingMessage.inReplyTo. If found, use that message’s threadId.
  2. Check References: If In-Reply-To match fails, iterate the References header (newest to oldest) and look for any matching inbox_message.messageId. If found, use that message’s threadId.
  3. New thread: If no match on either header, create a new inbox_thread.

Snippet Generation

Thread snippet is the first 200 characters of the latest message’s plain text body. Updated on every new message (inbound or outbound).


Folders

System Folders

Created by FolderService.initSystemFolders(). Cannot be renamed or deleted.

SlugNamePurpose
inboxInboxDefault landing folder for inbound email.
sentSentOutbound emails.
draftsDraftsUnsent drafts.
spamSpamAI-classified or manually flagged spam.
trashTrashSoft-deleted threads. Auto-purge after 30 days is an app-level concern.
archiveArchiveArchived conversations.

Custom Folders

Created via FolderService.createFolder(). isSystem = 0. Can be renamed and deleted. Slug is derived from the name.

Folder Scope

Folders are per-mailbox. Each mailbox has its own complete set of system and custom folders. There are no global or cross-mailbox folders.

Thread-Folder Relationship

A thread exists in exactly one folder at a time (inbox_thread.folderId). Moving a thread moves it entirely. Individual messages do not have their own folder assignment.


Labels

Three Label Types

System labels: Created during mailbox initialization. isSystem = 1. Cannot be renamed or deleted.

SlugPurpose
importantHigh-importance flag.
starredUser-starred for quick access.
unreadTracks unread state at the label level.

AI-generated labels: Created by the EmailClassifier. isAiGenerated = 1. Based on regex tag patterns applied during classification. Examples: bug-report, feature-request, billing, account.

User-created labels: Created via LabelService.createLabel(). isSystem = 0, isAiGenerated = 0. Custom tags for organization.

Label Application

Labels can be applied to both messages (via inbox_message_label) and threads (via inbox_thread_label). Both junction tables track:

  • Who applied it: appliedBy column. Null means system or AI.
  • When: appliedAt column.

A label can be applied to a message, a thread, or both independently. Message-level labels are for granular classification. Thread-level labels are for filtering and views.

Label Uniqueness

The (mailboxId, slug) pair is unique. Two labels in different mailboxes can have the same slug. Within a mailbox, each label slug is unique.


Assignments

Thread-level assignment for shared mailbox collaboration.

Rules

  • A thread can have at most one active assignment at a time.
  • The active assignment has status = 'active'.
  • assign() fails if an active assignment already exists. Use reassign() to change assignees.
  • reassign() sets the old assignment to status = 'reassigned', soft-deletes it, and creates a new active assignment.
  • complete() sets status = 'completed'. This is a terminal state. The assignment row is not soft-deleted.
  • Assignment history is preserved via soft delete on reassignment. Completed assignments remain as active records. This provides a full audit trail.

Status Values

StatusMeaning
activeCurrently assigned and in progress.
completedWork finished. Terminal state.
reassignedWas active, then reassigned to someone else. Soft-deleted.

Workflow Integration

When an agent replies to a thread, the app should update the thread status from open to pending (awaiting customer response). This is an app-level convention, not enforced by core.


Notes

Internal notes on threads. Not visible to external parties. Markdown content.

Purpose

Team collaboration on shared mailbox threads. Agents can leave context for each other: escalation reasons, customer history, resolution steps.

Behavior

  • Notes belong to a thread (threadId).
  • Notes have an author (authorId). Plain text user ID, no FK.
  • Content is Markdown.
  • listNotes() returns notes ordered by createdAt ascending. Excludes soft-deleted notes.
  • deleteNote() soft-deletes. The note is preserved for audit but hidden from the list.

Newsletter Tables

Three tables for outbound newsletter/broadcast functionality. Separate from the inbox schema but share the same database and the EmailProvider adapter.

platform_audience

Platform-wide mailing lists.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
nametextNoAudience name (e.g., “Newsletter”, “Product Updates”).
providerListIdtextYesExternal ID from the email provider.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
updatedAtintegerunixepoch(‘subsecond’) * 1000NoLast update timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

platform_subscriber

User subscriptions to platform audiences.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
audienceIdtext (FK -> platform_audience.id)NoParent audience.
userIdtextNoUser ID. Plain text, no FK.
providerSubscriberIdtextYesExternal ID from the email provider.
createdAtintegerunixepoch(‘subsecond’) * 1000NoCreation timestamp in ms.
deletedAtintegerYesSoft delete timestamp.

Indexes: unique on (audienceId, userId).

broadcast_audit

Compliance trail for sent broadcasts.

ColumnTypeDefaultNullableDescription
idtext (PK)UUIDv7NoPrimary key.
audienceIdtext (FK -> platform_audience.id)NoTarget audience.
subjecttextNoBroadcast subject line.
sentBytextNoUser ID of who triggered the send.
recipientCountintegerNoNumber of recipients at send time.
providerCampaignIdtextYesExternal campaign ID from the provider.
sentAtintegerNoSend timestamp in ms.
createdAtintegerunixepoch(‘subsecond’) * 1000NoRow creation timestamp in ms.

Design Principle

The email provider is the source of truth for subscriber data. These tables store:

  • Which audiences exist (registry).
  • Which users subscribe to which audiences (mapping).
  • Provider identifiers for sync.
  • Minimal audit trail for compliance.

These tables do NOT store subscriber email addresses, unsubscribe status, or campaign content. The provider owns that data.


Zod Schemas and Enums

Every table has three Zod schemas: insert, select, update. Types are always inferred via z.infer<>, never written as TypeScript interfaces first.

Enum Schemas

const mailboxTypeSchema = z.enum(["personal", "shared"]);
type MailboxType = z.infer<typeof mailboxTypeSchema>;

const threadStatusSchema = z.enum(["open", "pending", "resolved", "closed"]);
type ThreadStatus = z.infer<typeof threadStatusSchema>;

const threadPrioritySchema = z.enum(["low", "normal", "high", "urgent"]);
type ThreadPriority = z.infer<typeof threadPrioritySchema>;

const aiCategorySchema = z.enum([
  "support",
  "feedback",
  "abuse",
  "partnership",
  "spam",
  "billing",
  "legal",
  "other",
]);
type AiCategory = z.infer<typeof aiCategorySchema>;

const systemFolderSchema = z.enum(["inbox", "sent", "drafts", "spam", "trash", "archive"]);
type SystemFolder = z.infer<typeof systemFolderSchema>;

Schema Pattern

For each table (example: inbox_thread):

// Insert: required fields for creating a record
const insertInboxThreadSchema = z.object({
  mailboxId: z.string().uuid(),
  folderId: z.string().uuid(),
  subject: z.string().min(1),
  // ... other required fields
});

// Select: full record shape as returned from the database
const selectInboxThreadSchema = z.object({
  id: z.string().uuid(),
  mailboxId: z.string().uuid(),
  folderId: z.string().uuid(),
  subject: z.string(),
  snippet: z.string().nullable(),
  participants: z.array(z.string()),
  messageCount: z.number(),
  status: threadStatusSchema,
  priority: threadPrioritySchema,
  lastMessageAt: z.number(),
  unreadCount: z.number(),
  startedAt: z.number(),
  updatedAt: z.number(),
  archivedAt: z.number().nullable(),
  deletedAt: z.number().nullable(),
});

// Update: all fields optional except id
const updateInboxThreadSchema = z.object({
  folderId: z.string().uuid().optional(),
  subject: z.string().min(1).optional(),
  snippet: z.string().optional(),
  // ... other updatable fields
});

// Inferred types
type InsertInboxThread = z.infer<typeof insertInboxThreadSchema>;
type SelectInboxThread = z.infer<typeof selectInboxThreadSchema>;
type UpdateInboxThread = z.infer<typeof updateInboxThreadSchema>;

Exports

All schemas and inferred types are exported from the package. Apps use them for:

  • Runtime validation at API boundaries.
  • Type inference for service method parameters and return types.
  • Mock data generation with Zocker.

Migrations

The package exports raw SQL strings for table creation. It never runs migrations itself. Apps own their migration workflow.

import { migrationSQL } from "@rafters/mail/migrations";

Usage with Wrangler (D1)

  1. wrangler d1 migrations create add-mail-tables
  2. Copy migrationSQL into the generated .sql file.
  3. wrangler d1 migrations apply

Upgrade Migrations

When the package adds columns or tables in a new version, apps must generate new migration files with the appropriate ALTER TABLE or CREATE TABLE statements. The package exports the full current schema SQL, not diffs between versions.


Design Decisions

Why UUIDv7

UUIDv7 encodes a Unix timestamp in the high bits. IDs are naturally time-ordered, which means:

  • B-tree indexes on ID columns are insert-optimized (new rows append, no page splits).
  • IDs double as coarse timestamps for ordering.
  • No coordination required for ID generation (no auto-increment, no sequence table).

Why integer timestamps in milliseconds

SQLite has no native datetime type. Storing millisecond timestamps as integers avoids timezone ambiguity, string parsing overhead, and SQLite’s inconsistent date function behavior. The unixepoch('subsecond') * 1000 default generates values server-side.

Why soft delete everywhere

Audit trail. Email systems need to answer “what happened and when.” Hard deletes destroy evidence. Soft deletes preserve history while keeping it out of default queries. The deletedAt IS NULL filter is the convention for all read operations.

Why plain text user IDs with no FK

The mail schema is designed to work with any auth system. Foreign keys to a user table would create a hard dependency on a specific auth schema. Plain text user IDs let the AuthAdapter resolve identity at runtime. Trade-off: no referential integrity at the database level. Accepted because the AuthAdapter enforces validity at the application level.

Why JSON columns

SQLite stores JSON as text. Drizzle’s mode: 'json' handles serialization/deserialization transparently. For participants, ccEmails, and bccEmails, the alternative would be separate junction tables. JSON columns are simpler for read-heavy workloads where the data is always loaded with the parent row. Trade-off: no indexed lookups on individual array elements. Accepted because these fields are displayed, not queried.

Why no barrel files

Edge runtimes have bundle size constraints (Cloudflare Workers: 1MB compressed). Barrel exports (index.ts re-exporting everything) pull the entire module graph into every consumer. Subpath exports in package.json let consumers import exactly what they need. Example: import { threadStatusSchema } from '@rafters/mail/schema' loads only the Zod schemas, not the threading helpers or service interfaces. Drizzle table definitions are a separate package (@rafters/mail-drizzle) so consumers using a different ORM never pull drizzle-orm into their bundle at all.

Why Zod is source of truth

Types inferred from Zod schemas via z.infer<> guarantee that runtime validation and compile-time types are always in sync. Writing TypeScript interfaces first and then duplicating the shape in Zod is a maintenance burden and a bug vector. Zod-first means one definition, two outputs (types + validation). This also enables mock data generation with Zocker for testing.