diff --git a/docs/plans/2026-01-23-email-client-implementation.md b/docs/plans/2026-01-23-email-client-implementation.md new file mode 100644 index 000000000..598056011 --- /dev/null +++ b/docs/plans/2026-01-23-email-client-implementation.md @@ -0,0 +1,2355 @@ +# Email Client Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a full-featured email client with IMAP/POP3/SMTP support as a modular package system with Redux state management, FakeMUI components, and backend services. + +**Architecture:** Email client is implemented as a **minimal Next.js bootloader** (`emailclient/`) that loads the `email_client` package from `packages/email_client/`. All UI is declarative JSON (page-config, component definitions). State is managed via Redux slices. IMAP/SMTP operations are implemented as workflow plugins. Backend sync/send operations run as Python services. DBAL handles multi-tenant database operations with row-level ACL. + +**Tech Stack:** Next.js 16, React 19, Redux Toolkit, FakeMUI, DBAL TypeScript, Workflow Engine (N8N-like DAG), Python (Flask for email service), YAML (DBAL entity schemas), JSON (package configs/workflows). + +--- + +## Phase 1: DBAL Email Schemas (CRITICAL PATH) + +**Blocks:** All other phases - MUST be done first. + +**Deliverables:** 4 YAML entity schemas (email_client, email_account, email_message, email_folder) in DBAL. + +### Task 1.1: Create email_client Entity Schema + +**Files:** +- Create: `dbal/shared/api/schema/entities/packages/email_client.yaml` + +**Step 1: Write the email_client entity schema** + +```yaml +entity: EmailClient +version: "1.0" +package: email_client +description: "Email account configuration (IMAP/POP3)" + +fields: + id: + type: cuid + primary: true + generated: true + description: "Unique email client ID" + + tenantId: + type: uuid + required: true + index: true + description: "Tenant ownership" + + userId: + type: uuid + required: true + index: true + description: "User who owns this email account" + + accountName: + type: string + required: true + max_length: 255 + description: "Display name (e.g., 'Work Email', 'Gmail')" + + emailAddress: + type: string + required: true + unique: true + description: "Email address (e.g., user@gmail.com)" + + protocol: + type: enum + values: [imap, pop3] + default: imap + description: "Email protocol" + + hostname: + type: string + required: true + description: "IMAP/POP3 server (e.g., imap.gmail.com)" + + port: + type: int + required: true + description: "Server port (993 for IMAP+TLS, 995 for POP3+TLS)" + + encryption: + type: enum + values: [none, tls, starttls] + default: tls + description: "Encryption method" + + username: + type: string + required: true + description: "Username for auth" + + credentialId: + type: uuid + required: true + description: "FK to Credential entity (password stored encrypted)" + + isSyncEnabled: + type: boolean + default: true + description: "Whether to auto-sync new emails" + + syncInterval: + type: int + default: 300 + description: "Sync interval in seconds (default 5 min)" + + lastSyncAt: + type: bigint + nullable: true + description: "Last successful sync timestamp (ms)" + + isSyncing: + type: boolean + default: false + description: "Currently syncing flag" + + isEnabled: + type: boolean + default: true + description: "Account enabled/disabled" + + createdAt: + type: bigint + generated: true + description: "Creation timestamp (ms)" + + updatedAt: + type: bigint + generated: true + description: "Last update timestamp (ms)" + +indexes: + - fields: [userId, tenantId] + name: user_tenant_idx + - fields: [emailAddress, tenantId] + name: email_address_idx + +acl: + create: + self: true + admin: true + read: + self: true + row_level: "userId = $user.id AND tenantId = $context.tenantId" + admin: true + update: + self: true + row_level: "userId = $user.id AND tenantId = $context.tenantId" + admin: true + delete: + self: true + row_level: "userId = $user.id AND tenantId = $context.tenantId" + admin: true +``` + +**Step 2: Verify schema file exists and is valid YAML** + +Run: `cat dbal/shared/api/schema/entities/packages/email_client.yaml` + +Expected: File contains complete schema with all 20 fields. + +**Step 3: Commit** + +```bash +git add dbal/shared/api/schema/entities/packages/email_client.yaml +git commit -m "feat(dbal): add EmailClient entity schema" +``` + +--- + +### Task 1.2: Create email_folder Entity Schema + +**Files:** +- Create: `dbal/shared/api/schema/entities/packages/email_folder.yaml` + +**Step 1: Write the email_folder entity schema** + +```yaml +entity: EmailFolder +version: "1.0" +package: email_client +description: "Email folder (Inbox, Sent, Drafts, custom)" + +fields: + id: + type: cuid + primary: true + generated: true + + tenantId: + type: uuid + required: true + index: true + + emailClientId: + type: uuid + required: true + index: true + description: "FK to EmailClient" + + name: + type: string + required: true + max_length: 255 + description: "Folder name (e.g., 'Inbox', 'Sent', 'Drafts')" + + type: + type: enum + values: [inbox, sent, drafts, trash, spam, archive, custom] + default: custom + description: "System folder type" + + unreadCount: + type: int + default: 0 + description: "Count of unread messages" + + totalCount: + type: int + default: 0 + description: "Total message count" + + syncToken: + type: string + nullable: true + description: "IMAP sync token for incremental sync" + + isSelectable: + type: boolean + default: true + description: "Whether folder can contain messages" + + createdAt: + type: bigint + generated: true + + updatedAt: + type: bigint + generated: true + +indexes: + - fields: [emailClientId, name] + name: client_folder_idx + - fields: [tenantId, emailClientId, type] + name: type_lookup_idx + +acl: + read: + self: true + row_level: "emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id AND tenantId = $context.tenantId)" + create: false # System-managed + update: + self: true + row_level: "emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id)" + delete: false # Soft-delete only +``` + +**Step 2: Verify schema file exists** + +Run: `cat dbal/shared/api/schema/entities/packages/email_folder.yaml | head -20` + +Expected: First 20 lines show entity definition. + +**Step 3: Commit** + +```bash +git add dbal/shared/api/schema/entities/packages/email_folder.yaml +git commit -m "feat(dbal): add EmailFolder entity schema" +``` + +--- + +### Task 1.3: Create email_message Entity Schema + +**Files:** +- Create: `dbal/shared/api/schema/entities/packages/email_message.yaml` + +**Step 1: Write the email_message entity schema** + +```yaml +entity: EmailMessage +version: "1.0" +package: email_client +description: "Email message stored in folder" + +fields: + id: + type: cuid + primary: true + generated: true + + tenantId: + type: uuid + required: true + index: true + + emailClientId: + type: uuid + required: true + index: true + description: "FK to EmailClient" + + folderId: + type: uuid + required: true + index: true + description: "FK to EmailFolder" + + messageId: + type: string + required: true + description: "RFC 5322 Message-ID header" + + imapUid: + type: string + nullable: true + description: "IMAP UID for sync tracking" + + from: + type: string + required: true + description: "Sender email address" + + to: + type: json + required: true + description: "Recipient addresses (JSON array of strings)" + + cc: + type: json + nullable: true + description: "CC recipients (JSON array)" + + bcc: + type: json + nullable: true + description: "BCC recipients (JSON array)" + + replyTo: + type: string + nullable: true + description: "Reply-To header" + + subject: + type: string + max_length: 500 + description: "Email subject" + + textBody: + type: text + nullable: true + description: "Plain text version" + + htmlBody: + type: text + nullable: true + description: "HTML version (sanitized)" + + headers: + type: json + nullable: true + description: "All headers as JSON object" + + receivedAt: + type: bigint + required: true + index: true + description: "Message timestamp (ms)" + + isRead: + type: boolean + default: false + index: true + description: "Read/unread flag" + + isStarred: + type: boolean + default: false + index: true + description: "Starred/flagged status" + + isSpam: + type: boolean + default: false + description: "Marked as spam" + + isDraft: + type: boolean + default: false + description: "Draft message" + + isSent: + type: boolean + default: false + description: "Sent message" + + isDeleted: + type: boolean + default: false + description: "Soft-delete flag" + + attachmentCount: + type: int + default: 0 + description: "Number of attachments" + + conversationId: + type: uuid + nullable: true + index: true + description: "FK to conversation thread (for grouping)" + + labels: + type: json + nullable: true + description: "Custom labels (JSON array)" + + size: + type: bigint + nullable: true + description: "Message size in bytes" + + createdAt: + type: bigint + generated: true + + updatedAt: + type: bigint + generated: true + +indexes: + - fields: [emailClientId, folderId, receivedAt] + name: client_folder_date_idx + - fields: [tenantId, isRead, receivedAt] + name: unread_date_idx + - fields: [conversationId] + name: conversation_idx + +acl: + read: + self: true + row_level: "emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id AND tenantId = $context.tenantId)" + create: + self: true + row_level: "emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id)" + update: + self: true + row_level: "emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id)" + writable_fields: [isRead, isStarred, isSpam, labels] + delete: false # Soft-delete via isDeleted flag +``` + +**Step 2: Verify schema file exists** + +Run: `wc -l dbal/shared/api/schema/entities/packages/email_message.yaml` + +Expected: Output shows ~120+ lines. + +**Step 3: Commit** + +```bash +git add dbal/shared/api/schema/entities/packages/email_message.yaml +git commit -m "feat(dbal): add EmailMessage entity schema" +``` + +--- + +### Task 1.4: Create email_attachment Entity Schema + +**Files:** +- Create: `dbal/shared/api/schema/entities/packages/email_attachment.yaml` + +**Step 1: Write the email_attachment entity schema** + +```yaml +entity: EmailAttachment +version: "1.0" +package: email_client +description: "Email attachment metadata" + +fields: + id: + type: cuid + primary: true + generated: true + + tenantId: + type: uuid + required: true + index: true + + messageId: + type: uuid + required: true + index: true + description: "FK to EmailMessage" + + filename: + type: string + required: true + description: "Original filename" + + mimeType: + type: string + required: true + description: "MIME type (e.g., image/png, application/pdf)" + + size: + type: bigint + required: true + description: "File size in bytes" + + contentId: + type: string + nullable: true + description: "Content-ID for embedded attachments" + + isInline: + type: boolean + default: false + description: "Inline vs attachment" + + storageKey: + type: string + required: true + unique: true + description: "S3/blob storage key for download" + + downloadUrl: + type: string + nullable: true + description: "Pre-signed download URL (expires)" + + createdAt: + type: bigint + generated: true + +indexes: + - fields: [messageId] + name: message_attachments_idx + - fields: [tenantId, messageId] + name: tenant_message_idx + +acl: + read: + self: true + row_level: "messageId IN (SELECT id FROM EmailMessage WHERE emailClientId IN (SELECT id FROM EmailClient WHERE userId = $user.id AND tenantId = $context.tenantId))" + create: + system: true + update: false + delete: false +``` + +**Step 2: Verify schema file exists** + +Run: `cat dbal/shared/api/schema/entities/packages/email_attachment.yaml | grep "entity:"` + +Expected: Output shows `entity: EmailAttachment` + +**Step 3: Commit** + +```bash +git add dbal/shared/api/schema/entities/packages/email_attachment.yaml +git commit -m "feat(dbal): add EmailAttachment entity schema" +``` + +--- + +### Task 1.5: Register Email Entities in Master Schema + +**Files:** +- Modify: `dbal/shared/api/schema/entities/entities.yaml:end` + +**Step 1: Check current entities.yaml structure** + +Run: `tail -20 dbal/shared/api/schema/entities/entities.yaml` + +Expected: Shows list of existing entities like `- $ref: './core/user.yaml'` and `- $ref: './packages/notification.yaml'` + +**Step 2: Add email entity references** + +Append to `dbal/shared/api/schema/entities/entities.yaml`: + +```yaml + # Email Client Package (New - Jan 23, 2026) + - $ref: './packages/email_client.yaml' + - $ref: './packages/email_folder.yaml' + - $ref: './packages/email_message.yaml' + - $ref: './packages/email_attachment.yaml' +``` + +**Step 3: Verify entities.yaml is still valid YAML** + +Run: `node -e "console.log(require('js-yaml').load(require('fs').readFileSync('dbal/shared/api/schema/entities/entities.yaml')))"` + +Expected: No errors, loads as valid YAML object. + +**Step 4: Commit** + +```bash +git add dbal/shared/api/schema/entities/entities.yaml +git commit -m "feat(dbal): register email entities in master schema" +``` + +--- + +### Task 1.6: Verify DBAL Schema Generation (CRITICAL) + +**Files:** +- No new files +- Output: Auto-generated Prisma schema + +**Step 1: Run YAML-to-Prisma codegen** + +Run: `npm --prefix dbal/development run codegen:prisma` + +Expected output: +``` +✓ Parsed 18 existing entities +✓ Parsed 4 new email entities +✓ Generated EmailClient model +✓ Generated EmailFolder model +✓ Generated EmailMessage model +✓ Generated EmailAttachment model +✓ Schema updated: dbal/development/prisma/schema.prisma +``` + +**Step 2: Verify Prisma schema contains email models** + +Run: `grep -A 5 "model EmailClient" dbal/development/prisma/schema.prisma` + +Expected: Shows EmailClient model with fields: id, tenantId, userId, accountName, etc. + +**Step 3: Create Prisma migration (but DON'T apply yet)** + +Run: `npm --prefix dbal/development run db:migrate:dev -- --name add_email_models` + +Expected: Migration file created at `dbal/development/prisma/migrations/{timestamp}_add_email_models/migration.sql` + +**Step 4: Inspect migration SQL** + +Run: `ls -lah dbal/development/prisma/migrations | tail -1` + +Expected: Shows timestamp directory with migration inside. + +**Step 5: Commit schema generation (but NOT migrations yet)** + +```bash +git add dbal/development/prisma/schema.prisma +git commit -m "feat(dbal): generate Prisma schema for email models" +``` + +--- + +**Phase 1 Complete!** Email entities are now in DBAL. Next phases can proceed in parallel. + +--- + +## Phase 2: FakeMUI Email Components (PARALLEL) + +**Depends on:** Nothing (can start immediately) + +**Deliverables:** 22 React components organized by category (atoms, inputs, surfaces, data-display, feedback, layout, navigation). + +### Task 2.1: Create Email Atoms + +**Files:** +- Create: `fakemui/react/components/email/atoms/AttachmentIcon.tsx` +- Create: `fakemui/react/components/email/atoms/StarButton.tsx` +- Create: `fakemui/react/components/email/atoms/MarkAsReadCheckbox.tsx` +- Create: `fakemui/react/components/email/index.ts` + +**Step 1: Create AttachmentIcon component** + +```typescript +// fakemui/react/components/email/atoms/AttachmentIcon.tsx +import React, { forwardRef } from 'react' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface AttachmentIconProps extends React.SVGAttributes { + filename?: string + mimeType?: string + testId?: string +} + +export const AttachmentIcon = forwardRef( + ({ filename, mimeType, testId: customTestId, ...props }, ref) => { + const accessible = useAccessible({ + feature: 'email', + component: 'attachment-icon', + identifier: customTestId || filename || 'attachment' + }) + + // Determine icon based on mime type + const getIconContent = () => { + if (mimeType?.startsWith('image/')) return '🖼️' + if (mimeType?.startsWith('video/')) return '🎬' + if (mimeType?.startsWith('audio/')) return '🎵' + if (mimeType === 'application/pdf') return '📄' + return '📎' + } + + return ( + + + {getIconContent()} + + + ) + } +) + +AttachmentIcon.displayName = 'AttachmentIcon' +``` + +**Step 2: Create StarButton component** + +```typescript +// fakemui/react/components/email/atoms/StarButton.tsx +import React, { forwardRef, useState } from 'react' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface StarButtonProps extends React.ButtonHTMLAttributes { + isStarred?: boolean + onToggleStar?: (starred: boolean) => void + testId?: string +} + +export const StarButton = forwardRef( + ({ isStarred = false, onToggleStar, testId: customTestId, ...props }, ref) => { + const [starred, setStarred] = useState(isStarred) + + const accessible = useAccessible({ + feature: 'email', + component: 'star-button', + identifier: customTestId || 'star' + }) + + const handleClick = (e: React.MouseEvent) => { + const newState = !starred + setStarred(newState) + onToggleStar?.(newState) + props.onClick?.(e) + } + + return ( + + ) + } +) + +StarButton.displayName = 'StarButton' +``` + +**Step 3: Create MarkAsReadCheckbox component** + +```typescript +// fakemui/react/components/email/atoms/MarkAsReadCheckbox.tsx +import React, { forwardRef, useState } from 'react' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface MarkAsReadCheckboxProps extends React.InputHTMLAttributes { + isRead?: boolean + onToggleRead?: (read: boolean) => void + testId?: string +} + +export const MarkAsReadCheckbox = forwardRef( + ({ isRead = false, onToggleRead, testId: customTestId, ...props }, ref) => { + const [read, setRead] = useState(isRead) + + const accessible = useAccessible({ + feature: 'email', + component: 'read-checkbox', + identifier: customTestId || 'read-status' + }) + + const handleChange = (e: React.ChangeEvent) => { + const newState = e.target.checked + setRead(newState) + onToggleRead?.(newState) + props.onChange?.(e) + } + + return ( + + ) + } +) + +MarkAsReadCheckbox.displayName = 'MarkAsReadCheckbox' +``` + +**Step 2: Create barrel export for atoms** + +```typescript +// fakemui/react/components/email/atoms/index.ts +export { AttachmentIcon, type AttachmentIconProps } from './AttachmentIcon' +export { StarButton, type StarButtonProps } from './StarButton' +export { MarkAsReadCheckbox, type MarkAsReadCheckboxProps } from './MarkAsReadCheckbox' +``` + +**Step 3: Verify components compile** + +Run: `cd fakemui && npx tsc --noEmit fakemui/react/components/email/atoms/*.tsx` + +Expected: No errors. + +**Step 4: Commit** + +```bash +git add fakemui/react/components/email/atoms/ +git commit -m "feat(fakemui): add email atom components (icons, buttons)" +``` + +--- + +### Task 2.2: Create Email Input Components + +**Files:** +- Create: `fakemui/react/components/email/inputs/EmailAddressInput.tsx` +- Create: `fakemui/react/components/email/inputs/RecipientInput.tsx` +- Create: `fakemui/react/components/email/inputs/BodyEditor.tsx` + +**Step 1: Create EmailAddressInput component** + +```typescript +// fakemui/react/components/email/inputs/EmailAddressInput.tsx +import React, { forwardRef } from 'react' +import { TextField, TextFieldProps } from '../../inputs/TextField' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface EmailAddressInputProps extends Omit { + onValidate?: (valid: boolean) => void + allowMultiple?: boolean +} + +export const EmailAddressInput = forwardRef( + ({ onValidate, allowMultiple = false, testId: customTestId, ...props }, ref) => { + const accessible = useAccessible({ + feature: 'email', + component: 'email-input', + identifier: customTestId || 'email' + }) + + const validateEmail = (value: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (allowMultiple) { + const emails = value.split(',').map(e => e.trim()) + return emails.every(e => emailRegex.test(e) || e === '') + } + return emailRegex.test(value) || value === '' + } + + const handleChange = (e: React.ChangeEvent) => { + const valid = validateEmail(e.target.value) + onValidate?.(valid) + props.onChange?.(e) + } + + return ( + + ) + } +) + +EmailAddressInput.displayName = 'EmailAddressInput' +``` + +**Step 2: Create RecipientInput component** + +```typescript +// fakemui/react/components/email/inputs/RecipientInput.tsx +import React, { forwardRef, useState } from 'react' +import { Box, TextField, Chip } from '../..' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface RecipientInputProps extends React.InputHTMLAttributes { + recipients?: string[] + onRecipientsChange?: (recipients: string[]) => void + recipientType?: 'to' | 'cc' | 'bcc' + testId?: string +} + +export const RecipientInput = forwardRef( + ({ recipients = [], onRecipientsChange, recipientType = 'to', testId: customTestId, ...props }, ref) => { + const [inputValue, setInputValue] = useState('') + const accessible = useAccessible({ + feature: 'email', + component: 'recipient-input', + identifier: customTestId || recipientType + }) + + const handleAddRecipient = () => { + if (inputValue && inputValue.includes('@')) { + const newRecipients = [...recipients, inputValue.trim()] + onRecipientsChange?.(newRecipients) + setInputValue('') + } + } + + const handleRemoveRecipient = (index: number) => { + const newRecipients = recipients.filter((_, i) => i !== index) + onRecipientsChange?.(newRecipients) + } + + return ( + +
+ {recipients.map((recipient, index) => ( + handleRemoveRecipient(index)} + /> + ))} +
+ setInputValue(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddRecipient()} + {...accessible} + {...props} + /> +
+ ) + } +) + +RecipientInput.displayName = 'RecipientInput' +``` + +**Step 3: Create BodyEditor component** + +```typescript +// fakemui/react/components/email/inputs/BodyEditor.tsx +import React, { forwardRef } from 'react' +import { Box, TextField } from '../..' +import { useAccessible } from '@metabuilder/fakemui/hooks' + +export interface BodyEditorProps extends React.TextareaHTMLAttributes { + mode?: 'plain' | 'html' + onModeChange?: (mode: 'plain' | 'html') => void + testId?: string +} + +export const BodyEditor = forwardRef( + ({ mode = 'plain', onModeChange, testId: customTestId, ...props }, ref) => { + const accessible = useAccessible({ + feature: 'email', + component: 'body-editor', + identifier: customTestId || 'body' + }) + + return ( + +
+ + +
+