feat(mail): add complete Mail app with backend, web, mobile, and landing

- Backend (NestJS): Complete API with accounts, folders, emails, labels, compose, attachments, sync providers (IMAP, Gmail, Outlook), AI features (summarize, smart replies, categorization), and OAuth support
- Web (SvelteKit): Full email client UI with Svelte 5 runes, sidebar navigation, email list/detail views, compose modal, and theme support
- Mobile (Expo): React Native app with drawer navigation, email list/detail, compose screen, account management, and theme provider
- Landing (Astro): Marketing page with features, pricing, FAQ sections using shared-landing-ui components
- Storage: Added mail bucket and createMailStorage to shared-storage package
- Branding: Added MailLogo component

Note: Run `pnpm install` to install new dependencies (mailparser)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-03 16:06:32 +01:00
parent 79dd56403e
commit 604727c8f9
127 changed files with 15653 additions and 0 deletions

300
apps/mail/CLAUDE.md Normal file
View file

@ -0,0 +1,300 @@
# Mail Project Guide
## Project Structure
```
apps/mail/
├── apps/
│ ├── backend/ # NestJS API server (@mail/backend) - Port 3017
│ ├── landing/ # Astro marketing landing page (@mail/landing)
│ ├── web/ # SvelteKit web application (@mail/web) - Port 5186
│ └── mobile/ # Expo/React Native mobile app (@mail/mobile)
├── packages/
│ └── shared/ # Shared types, utils, configs (@mail/shared)
└── package.json
```
## Commands
### Root Level (from monorepo root)
```bash
pnpm mail:dev # Run all mail apps
pnpm dev:mail:mobile # Start mobile app
pnpm dev:mail:web # Start web app
pnpm dev:mail:landing # Start landing page
pnpm dev:mail:backend # Start backend server
pnpm dev:mail:app # Start web + backend together
```
### Mobile App (apps/mail/apps/mobile)
```bash
pnpm dev # Start Expo dev server
pnpm ios # Run on iOS simulator
pnpm android # Run on Android emulator
```
### Backend (apps/mail/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
### Web App (apps/mail/apps/web)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
pnpm preview # Preview production build
```
### Landing Page (apps/mail/apps/landing)
```bash
pnpm dev # Start dev server
pnpm build # Build for production
```
## Technology Stack
- **Mobile**: React Native 0.81 + Expo SDK 54, NativeWind, Expo Router, Zustand
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
- **Landing**: Astro 5.x, Tailwind CSS
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
- **Email**: ImapFlow, Nodemailer, Google APIs, Microsoft Graph
- **AI**: Google Gemini API
- **Types**: TypeScript 5.x
## Architecture
### Email Providers
| Provider | Protocol | Library |
|----------|----------|---------|
| IMAP/SMTP | Standard | imapflow, nodemailer |
| Gmail | OAuth 2.0 | googleapis |
| Outlook | OAuth 2.0 | @microsoft/microsoft-graph-client |
### Backend API Endpoints
#### Accounts
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/accounts` | GET | List email accounts |
| `/api/v1/accounts` | POST | Add IMAP account |
| `/api/v1/accounts/:id` | GET | Get account details |
| `/api/v1/accounts/:id` | PATCH | Update account |
| `/api/v1/accounts/:id` | DELETE | Remove account |
| `/api/v1/accounts/:id/sync` | POST | Trigger sync |
| `/api/v1/accounts/:id/test` | POST | Test connection |
| `/api/v1/accounts/:id/default` | POST | Set as default |
#### OAuth
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/oauth/google/init` | POST | Start Gmail OAuth |
| `/api/v1/oauth/google/callback` | GET | Gmail callback |
| `/api/v1/oauth/microsoft/init` | POST | Start Outlook OAuth |
| `/api/v1/oauth/microsoft/callback` | GET | Outlook callback |
#### Folders
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/folders` | GET | List all folders |
| `/api/v1/folders` | POST | Create folder |
| `/api/v1/folders/:id` | GET | Get folder |
| `/api/v1/folders/:id` | PATCH | Update folder |
| `/api/v1/folders/:id` | DELETE | Delete folder |
| `/api/v1/folders/:id/hide` | POST | Toggle hide folder |
#### Emails
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/emails` | GET | List emails |
| `/api/v1/emails/search` | GET | Search emails |
| `/api/v1/emails/:id` | GET | Get email |
| `/api/v1/emails/:id` | PATCH | Update flags |
| `/api/v1/emails/:id` | DELETE | Delete email |
| `/api/v1/emails/:id/move` | POST | Move to folder |
| `/api/v1/emails/batch` | POST | Batch operations |
| `/api/v1/emails/:id/thread` | GET | Get email thread |
#### Compose
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/drafts` | GET | List drafts |
| `/api/v1/drafts` | POST | Create draft |
| `/api/v1/drafts/:id` | GET | Get draft |
| `/api/v1/drafts/:id` | PATCH | Update draft |
| `/api/v1/drafts/:id` | DELETE | Delete draft |
| `/api/v1/drafts/:id/send` | POST | Send draft |
| `/api/v1/send` | POST | Send directly |
| `/api/v1/emails/:id/reply` | POST | Create reply draft |
| `/api/v1/emails/:id/reply-all` | POST | Create reply-all draft |
| `/api/v1/emails/:id/forward` | POST | Create forward draft |
#### Attachments
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/emails/:emailId/attachments` | GET | List email attachments |
| `/api/v1/attachments/:id` | GET | Get attachment |
| `/api/v1/attachments` | POST | Create attachment record |
| `/api/v1/attachments/:id` | DELETE | Delete attachment |
| `/api/v1/attachments/upload-url` | POST | Get presigned upload URL |
| `/api/v1/attachments/:id/download` | GET | Get download URL |
| `/api/v1/attachments/:id/complete` | POST | Mark upload complete |
#### Labels
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/labels` | GET | List labels |
| `/api/v1/labels` | POST | Create label |
| `/api/v1/labels/:id` | GET | Get label |
| `/api/v1/labels/:id` | PATCH | Update label |
| `/api/v1/labels/:id` | DELETE | Delete label |
| `/api/v1/labels/email/:emailId` | GET | Get email labels |
| `/api/v1/labels/email/:emailId/add` | POST | Add labels to email |
| `/api/v1/labels/email/:emailId/remove` | POST | Remove labels from email |
| `/api/v1/labels/email/:emailId/set` | POST | Set email labels |
#### Sync
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/sync/accounts/:accountId` | POST | Sync account |
| `/api/v1/sync/accounts/:accountId/folders/:folderId` | POST | Sync folder |
| `/api/v1/sync/emails/:emailId/fetch` | POST | Fetch full email |
#### AI
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/emails/:id/summarize` | POST | AI summary |
| `/api/v1/emails/:id/suggest-replies` | POST | Smart reply |
| `/api/v1/emails/:id/categorize` | POST | Auto-categorize |
### Database Schema
**email_accounts** - Email account configurations
- `id` (UUID) - Primary key
- `user_id` (VARCHAR) - User reference
- `name`, `email` (VARCHAR) - Display name and email address
- `provider` (VARCHAR) - gmail, outlook, imap
- `imap_host`, `imap_port`, `smtp_host`, `smtp_port` - Server settings
- `encrypted_password` (TEXT) - Encrypted credentials
- `access_token`, `refresh_token` (TEXT) - OAuth tokens
- `sync_state` (JSONB) - Provider-specific sync state
- `created_at`, `updated_at` (TIMESTAMP)
**folders** - Email folders
- `id` (UUID) - Primary key
- `account_id` (UUID) - Account reference
- `name`, `type`, `path` (VARCHAR) - Folder info
- `unread_count`, `total_count` (INTEGER)
**emails** - Email messages
- `id` (UUID) - Primary key
- `account_id`, `folder_id`, `thread_id` (UUID) - References
- `message_id` (VARCHAR) - RFC 2822 Message-ID
- `subject`, `from_address`, `from_name` (VARCHAR/TEXT)
- `to_addresses`, `cc_addresses` (JSONB) - Recipients
- `body_plain`, `body_html` (TEXT) - Content
- `is_read`, `is_starred`, `has_attachments` (BOOLEAN)
- `ai_summary`, `ai_category`, `ai_priority` (VARCHAR/TEXT)
**attachments** - Email attachments
- `id` (UUID) - Primary key
- `email_id` (UUID) - Email reference
- `filename`, `mime_type`, `size` - File info
- `storage_key` (VARCHAR) - S3 storage key
**labels** - Custom labels
- `id` (UUID) - Primary key
- `name`, `color` (VARCHAR)
**drafts** - Email drafts
- `id` (UUID) - Primary key
- `account_id` (UUID) - Account reference
- `subject`, `body_html` (TEXT)
- `to_addresses`, `cc_addresses` (JSONB)
- `scheduled_at` (TIMESTAMP)
### Environment Variables
#### Backend (.env)
```env
NODE_ENV=development
PORT=3017
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mail
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:5186,http://localhost:8081
# OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3017/api/v1/oauth/google/callback
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI=http://localhost:3017/api/v1/oauth/microsoft/callback
# AI
GOOGLE_GENAI_API_KEY=
# Encryption
ENCRYPTION_KEY=your-32-byte-encryption-key
# Queue (optional)
REDIS_URL=redis://localhost:6379
```
#### Mobile (.env)
```env
EXPO_PUBLIC_BACKEND_URL=http://localhost:3017
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
#### Web (.env)
```env
PUBLIC_BACKEND_URL=http://localhost:3017
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## AI Features
### Summarization
- 1-2 sentence email summary
- Focus on action items and key information
- Uses Google Gemini API
### Smart Reply
- 3 reply suggestions per email
- Different tones (positive, neutral, declining)
- Context-aware responses
### Auto-Categorization
Categories:
- `work` - Work-related emails
- `personal` - Personal communications
- `newsletter` - Newsletters/subscriptions
- `transactional` - Receipts, confirmations
- `promotional` - Marketing emails
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Mobile**: Functional components with hooks, Zustand for state
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Styling**: Tailwind CSS / NativeWind
- **Formatting**: Prettier with project config
## Important Notes
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
2. **Database**: PostgreSQL with Drizzle ORM
3. **Port**: Backend runs on port 3017, Web on port 5186 by default
4. **Storage**: Uses MinIO/S3 for attachments via @manacore/shared-storage
5. **Encryption**: IMAP passwords are encrypted at rest
6. **Multi-Account**: Each user can have multiple email accounts

View file

@ -0,0 +1,15 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url:
process.env.MAIL_DATABASE_URL ||
process.env.DATABASE_URL ||
'postgresql://manacore:devpassword@localhost:5432/mail',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false,
"assets": [],
"watchAssets": false
}
}

View file

@ -0,0 +1,67 @@
{
"name": "@mail/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-storage": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.0",
"@nestjs/bull": "^10.2.1",
"bull": "^4.16.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"imapflow": "^1.0.171",
"mailparser": "^3.7.2",
"nodemailer": "^6.9.16",
"googleapis": "^144.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@azure/identity": "^4.5.0",
"@google/generative-ai": "^0.21.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/mailparser": "^3.4.5",
"@types/multer": "^1.4.11",
"@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.17",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,129 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AccountService } from './account.service';
import { CreateImapAccountDto, UpdateAccountDto, AccountQueryDto } from './dto/account.dto';
@Controller('accounts')
@UseGuards(JwtAuthGuard)
export class AccountController {
constructor(private readonly accountService: AccountService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: AccountQueryDto) {
const accounts = await this.accountService.findByUserId(user.userId, query);
const total = await this.accountService.count(user.userId);
// Remove sensitive fields from response
const safeAccounts = accounts.map((account) => ({
...account,
encryptedPassword: undefined,
accessToken: undefined,
refreshToken: undefined,
}));
return { accounts: safeAccounts, total };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const account = await this.accountService.findById(id, user.userId);
if (!account) {
return { account: null };
}
// Remove sensitive fields
const safeAccount = {
...account,
encryptedPassword: undefined,
accessToken: undefined,
refreshToken: undefined,
};
return { account: safeAccount };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateImapAccountDto) {
const account = await this.accountService.create({
...dto,
userId: user.userId,
provider: 'imap',
});
// Remove sensitive fields
const safeAccount = {
...account,
encryptedPassword: undefined,
};
return { account: safeAccount };
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateAccountDto
) {
const account = await this.accountService.update(id, user.userId, dto);
// Remove sensitive fields
const safeAccount = {
...account,
encryptedPassword: undefined,
accessToken: undefined,
refreshToken: undefined,
};
return { account: safeAccount };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.accountService.delete(id, user.userId);
return { success: true };
}
@Post(':id/default')
async setDefault(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const account = await this.accountService.setDefault(id, user.userId);
// Remove sensitive fields
const safeAccount = {
...account,
encryptedPassword: undefined,
accessToken: undefined,
refreshToken: undefined,
};
return { account: safeAccount };
}
@Post(':id/sync')
async triggerSync(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
// TODO: Trigger sync via SyncService
// For now, just return success
return { success: true, message: 'Sync triggered' };
}
@Post(':id/test')
async testConnection(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
// TODO: Test IMAP/SMTP connection
// For now, just return success
return { success: true, message: 'Connection test not yet implemented' };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
providers: [AccountService],
exports: [AccountService],
})
export class AccountModule {}

View file

@ -0,0 +1,179 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { emailAccounts, type EmailAccount, type NewEmailAccount } from '../db/schema';
import * as crypto from 'crypto';
export interface AccountFilters {
limit?: number;
offset?: number;
}
@Injectable()
export class AccountService {
private encryptionKey: Buffer;
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
// Get encryption key from environment or use a default for development
const key = process.env.ENCRYPTION_KEY || 'dev-encryption-key-32-bytes-long';
this.encryptionKey = crypto.scryptSync(key, 'salt', 32);
}
// Encrypt password for storage
private encryptPassword(password: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv);
let encrypted = cipher.update(password, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// Decrypt password for use
private decryptPassword(encryptedPassword: string): string {
const [ivHex, encrypted] = encryptedPassword.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
async findByUserId(userId: string, filters: AccountFilters = {}): Promise<EmailAccount[]> {
const { limit = 50, offset = 0 } = filters;
return this.db
.select()
.from(emailAccounts)
.where(eq(emailAccounts.userId, userId))
.orderBy(desc(emailAccounts.isDefault), desc(emailAccounts.createdAt))
.limit(limit)
.offset(offset);
}
async findById(id: string, userId: string): Promise<EmailAccount | null> {
const [account] = await this.db
.select()
.from(emailAccounts)
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)));
return account || null;
}
async create(data: NewEmailAccount & { password?: string }): Promise<EmailAccount> {
const { password, ...accountData } = data;
// Encrypt password if provided
let encryptedPassword: string | undefined;
if (password) {
encryptedPassword = this.encryptPassword(password);
}
// If this is set as default, unset other defaults first
if (accountData.isDefault) {
await this.db
.update(emailAccounts)
.set({ isDefault: false })
.where(eq(emailAccounts.userId, accountData.userId));
}
const [account] = await this.db
.insert(emailAccounts)
.values({
...accountData,
encryptedPassword,
})
.returning();
return account;
}
async update(id: string, userId: string, data: Partial<NewEmailAccount>): Promise<EmailAccount> {
// If setting as default, unset other defaults first
if (data.isDefault) {
await this.db
.update(emailAccounts)
.set({ isDefault: false })
.where(eq(emailAccounts.userId, userId));
}
const [account] = await this.db
.update(emailAccounts)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)))
.returning();
if (!account) {
throw new NotFoundException('Email account not found');
}
return account;
}
async delete(id: string, userId: string): Promise<void> {
const account = await this.findById(id, userId);
if (!account) {
throw new NotFoundException('Email account not found');
}
await this.db
.delete(emailAccounts)
.where(and(eq(emailAccounts.id, id), eq(emailAccounts.userId, userId)));
}
async setDefault(id: string, userId: string): Promise<EmailAccount> {
// Unset all defaults first
await this.db
.update(emailAccounts)
.set({ isDefault: false })
.where(eq(emailAccounts.userId, userId));
// Set this one as default
return this.update(id, userId, { isDefault: true });
}
async count(userId: string): Promise<number> {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(emailAccounts)
.where(eq(emailAccounts.userId, userId));
return Number(result[0]?.count || 0);
}
// Get decrypted password for IMAP/SMTP connection
async getDecryptedPassword(id: string, userId: string): Promise<string | null> {
const account = await this.findById(id, userId);
if (!account || !account.encryptedPassword) {
return null;
}
return this.decryptPassword(account.encryptedPassword);
}
// Update OAuth tokens
async updateTokens(
id: string,
userId: string,
tokens: { accessToken: string; refreshToken?: string; expiresAt?: Date }
): Promise<EmailAccount> {
return this.update(id, userId, {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
});
}
// Update sync state
async updateSyncState(id: string, userId: string, syncState: object): Promise<EmailAccount> {
return this.update(id, userId, {
syncState,
lastSyncAt: new Date(),
lastSyncError: null,
});
}
// Record sync error
async recordSyncError(id: string, userId: string, error: string): Promise<EmailAccount> {
return this.update(id, userId, {
lastSyncError: error,
});
}
}

View file

@ -0,0 +1,107 @@
import {
IsString,
IsOptional,
IsEmail,
IsBoolean,
IsNumber,
IsIn,
MaxLength,
Min,
Max,
} from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateImapAccountDto {
@IsString()
@MaxLength(255)
name: string;
@IsEmail()
@MaxLength(255)
email: string;
// IMAP settings
@IsString()
@MaxLength(255)
imapHost: string;
@IsNumber()
@Min(1)
@Max(65535)
imapPort: number;
@IsString()
@IsIn(['ssl', 'tls', 'none'])
imapSecurity: string;
// SMTP settings
@IsString()
@MaxLength(255)
smtpHost: string;
@IsNumber()
@Min(1)
@Max(65535)
smtpPort: number;
@IsString()
@IsIn(['ssl', 'tls', 'none'])
smtpSecurity: string;
// Credentials
@IsString()
password: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
@IsString()
@IsOptional()
signature?: string;
}
export class UpdateAccountDto {
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
@IsBoolean()
@IsOptional()
isDefault?: boolean;
@IsBoolean()
@IsOptional()
syncEnabled?: boolean;
@IsNumber()
@IsOptional()
@Min(1)
@Max(60)
syncInterval?: number;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
@IsString()
@IsOptional()
signature?: string;
}
export class AccountQueryDto {
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
offset?: number;
}

View file

@ -0,0 +1,30 @@
import { Controller, Post, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AIService } from './ai.service';
@Controller('emails')
@UseGuards(JwtAuthGuard)
export class AIController {
constructor(private readonly aiService: AIService) {}
@Post(':id/summarize')
async summarize(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const result = await this.aiService.summarizeEmail(id, user.userId);
return result;
}
@Post(':id/suggest-replies')
async suggestReplies(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const result = await this.aiService.suggestReplies(id, user.userId);
return result;
}
@Post(':id/categorize')
async categorize(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const result = await this.aiService.categorizeEmail(id, user.userId);
return result;
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AIController } from './ai.controller';
import { AIService } from './ai.service';
@Module({
controllers: [AIController],
providers: [AIService],
exports: [AIService],
})
export class AIModule {}

View file

@ -0,0 +1,270 @@
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { emails, type Email } from '../db/schema';
export interface SummaryResult {
summary: string;
keyPoints?: string[];
}
export interface SmartReplyResult {
replies: {
text: string;
tone: 'positive' | 'neutral' | 'declining';
}[];
}
export interface CategoryResult {
category: 'work' | 'personal' | 'newsletter' | 'transactional' | 'promotional' | 'social';
confidence: number;
priority: 'high' | 'medium' | 'low';
}
@Injectable()
export class AIService {
private readonly logger = new Logger(AIService.name);
private readonly geminiClient: GoogleGenerativeAI | null = null;
private readonly modelName = 'gemini-1.5-flash';
constructor(
private configService: ConfigService,
@Inject(DATABASE_CONNECTION) private db: Database
) {
const apiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
if (apiKey) {
this.geminiClient = new GoogleGenerativeAI(apiKey);
this.logger.log('Google Gemini client initialized for AI features');
} else {
this.logger.warn('GOOGLE_GENAI_API_KEY is not set - AI features unavailable');
}
}
async summarizeEmail(emailId: string, userId: string): Promise<SummaryResult> {
const email = await this.getEmail(emailId, userId);
if (!this.geminiClient) {
throw new Error('AI service not configured');
}
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
if (!content) {
return { summary: 'Email has no content to summarize.' };
}
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
const prompt = `Summarize this email in 1-2 sentences. Focus on the main purpose and any action items.
Subject: ${email.subject || '(No Subject)'}
From: ${email.fromName || email.fromAddress}
Content:
${content.substring(0, 5000)}
Respond with a JSON object:
{
"summary": "Brief summary here",
"keyPoints": ["Key point 1", "Key point 2"]
}`;
try {
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
// Update email with summary
await this.db
.update(emails)
.set({
aiSummary: parsed.summary,
updatedAt: new Date(),
})
.where(eq(emails.id, emailId));
return {
summary: parsed.summary,
keyPoints: parsed.keyPoints,
};
}
return { summary: response.trim() };
} catch (error) {
this.logger.error('Failed to summarize email:', error);
throw new Error('Failed to generate summary');
}
}
async suggestReplies(emailId: string, userId: string): Promise<SmartReplyResult> {
const email = await this.getEmail(emailId, userId);
if (!this.geminiClient) {
throw new Error('AI service not configured');
}
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
if (!content) {
return { replies: [] };
}
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
const prompt = `Generate 3 short reply suggestions for this email. Make them varied in tone: one positive/accepting, one neutral/informative, and one politely declining.
Subject: ${email.subject || '(No Subject)'}
From: ${email.fromName || email.fromAddress}
Content:
${content.substring(0, 3000)}
Respond with a JSON object:
{
"replies": [
{ "text": "Reply text here", "tone": "positive" },
{ "text": "Reply text here", "tone": "neutral" },
{ "text": "Reply text here", "tone": "declining" }
]
}
Keep replies brief (1-3 sentences each).`;
try {
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
// Update email with suggested replies
await this.db
.update(emails)
.set({
aiSuggestedReplies: parsed.replies,
updatedAt: new Date(),
})
.where(eq(emails.id, emailId));
return {
replies: parsed.replies,
};
}
return { replies: [] };
} catch (error) {
this.logger.error('Failed to generate reply suggestions:', error);
throw new Error('Failed to generate reply suggestions');
}
}
async categorizeEmail(emailId: string, userId: string): Promise<CategoryResult> {
const email = await this.getEmail(emailId, userId);
if (!this.geminiClient) {
throw new Error('AI service not configured');
}
const content = email.bodyPlain || email.bodyHtml || email.snippet || '';
const model = this.geminiClient.getGenerativeModel({ model: this.modelName });
const prompt = `Categorize this email and determine its priority.
Subject: ${email.subject || '(No Subject)'}
From: ${email.fromName || ''} <${email.fromAddress}>
Snippet: ${email.snippet || content.substring(0, 500)}
Categories:
- work: Work-related emails (meetings, projects, colleagues)
- personal: Personal communications (friends, family)
- newsletter: Newsletters, subscriptions, updates
- transactional: Receipts, confirmations, shipping, billing
- promotional: Marketing, sales, offers
- social: Social network notifications
Priority:
- high: Urgent, requires immediate attention
- medium: Important but not urgent
- low: Informational, can wait
Respond with a JSON object:
{
"category": "work",
"confidence": 0.95,
"priority": "high"
}`;
try {
const result = await model.generateContent(prompt);
const response = result.response.text();
// Parse JSON response
const jsonMatch = response.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]);
// Update email with category
await this.db
.update(emails)
.set({
aiCategory: parsed.category,
aiPriority: parsed.priority,
updatedAt: new Date(),
})
.where(eq(emails.id, emailId));
return {
category: parsed.category,
confidence: parsed.confidence,
priority: parsed.priority,
};
}
// Default fallback
return {
category: 'personal',
confidence: 0.5,
priority: 'medium',
};
} catch (error) {
this.logger.error('Failed to categorize email:', error);
throw new Error('Failed to categorize email');
}
}
async autoCategorizeNewEmails(userId: string, emailIds: string[]): Promise<void> {
if (!this.geminiClient) {
this.logger.warn('AI service not configured, skipping auto-categorization');
return;
}
for (const emailId of emailIds) {
try {
await this.categorizeEmail(emailId, userId);
} catch (error) {
this.logger.error(`Failed to auto-categorize email ${emailId}:`, error);
}
}
}
private async getEmail(emailId: string, userId: string): Promise<Email> {
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
if (!email) {
throw new NotFoundException('Email not found');
}
return email;
}
}

View file

@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { AccountModule } from './account/account.module';
import { OAuthModule } from './oauth/oauth.module';
import { FolderModule } from './folder/folder.module';
import { EmailModule } from './email/email.module';
import { ComposeModule } from './compose/compose.module';
import { AttachmentModule } from './attachment/attachment.module';
import { LabelModule } from './label/label.module';
import { SyncModule } from './sync/sync.module';
import { AIModule } from './ai/ai.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ScheduleModule.forRoot(),
DatabaseModule,
HealthModule,
AccountModule,
OAuthModule,
FolderModule,
EmailModule,
ComposeModule,
AttachmentModule,
LabelModule,
SyncModule,
AIModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,81 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AttachmentService } from './attachment.service';
import { AttachmentQueryDto, CreateAttachmentDto, UploadUrlDto } from './dto/attachment.dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class AttachmentController {
constructor(private readonly attachmentService: AttachmentService) {}
@Get('emails/:emailId/attachments')
async findByEmail(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string
) {
const attachments = await this.attachmentService.findByEmailId(emailId, user.userId);
return { attachments };
}
@Get('attachments/:id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const attachment = await this.attachmentService.findById(id, user.userId);
if (!attachment) {
return { attachment: null };
}
return { attachment };
}
@Post('attachments')
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAttachmentDto) {
const attachment = await this.attachmentService.create({
...dto,
userId: user.userId,
});
return { attachment };
}
@Delete('attachments/:id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.attachmentService.delete(id, user.userId);
return { success: true };
}
// Get presigned URL for client-side upload
@Post('attachments/upload-url')
async getUploadUrl(@CurrentUser() user: CurrentUserData, @Body() dto: UploadUrlDto) {
const result = await this.attachmentService.getUploadUrl(user.userId, dto);
return result;
}
// Get presigned URL for downloading
@Get('attachments/:id/download')
async getDownloadUrl(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const result = await this.attachmentService.getDownloadUrl(id, user.userId);
return result;
}
// Mark attachment as uploaded (called after client uploads to presigned URL)
@Post('attachments/:id/complete')
async markUploaded(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() body: { storageKey: string }
) {
const attachment = await this.attachmentService.markUploaded(id, user.userId, body.storageKey);
return { attachment };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AttachmentController } from './attachment.controller';
import { AttachmentService } from './attachment.service';
@Module({
controllers: [AttachmentController],
providers: [AttachmentService],
exports: [AttachmentService],
})
export class AttachmentModule {}

View file

@ -0,0 +1,195 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { attachments, type Attachment, type NewAttachment } from '../db/schema';
import { createMailStorage, generateUserFileKey, getContentType } from '@manacore/shared-storage';
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB
export interface AttachmentFilters {
emailId?: string;
limit?: number;
offset?: number;
}
@Injectable()
export class AttachmentService {
private storage = createMailStorage();
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByEmailId(emailId: string, userId: string): Promise<Attachment[]> {
return this.db
.select()
.from(attachments)
.where(and(eq(attachments.emailId, emailId), eq(attachments.userId, userId)))
.orderBy(desc(attachments.createdAt));
}
async findById(id: string, userId: string): Promise<Attachment | null> {
const [attachment] = await this.db
.select()
.from(attachments)
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)));
return attachment || null;
}
async create(data: NewAttachment): Promise<Attachment> {
const [attachment] = await this.db.insert(attachments).values(data).returning();
return attachment;
}
async delete(id: string, userId: string): Promise<void> {
const attachment = await this.findById(id, userId);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
// Delete from storage if uploaded
if (attachment.storageKey) {
try {
await this.storage.delete(attachment.storageKey);
} catch (error) {
// Log but don't fail if storage deletion fails
console.error('Failed to delete attachment from storage:', error);
}
}
await this.db
.delete(attachments)
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)));
}
async deleteByEmailId(emailId: string, userId: string): Promise<void> {
const emailAttachments = await this.findByEmailId(emailId, userId);
// Delete all from storage
for (const attachment of emailAttachments) {
if (attachment.storageKey) {
try {
await this.storage.delete(attachment.storageKey);
} catch (error) {
console.error('Failed to delete attachment from storage:', error);
}
}
}
await this.db
.delete(attachments)
.where(and(eq(attachments.emailId, emailId), eq(attachments.userId, userId)));
}
// Generate a presigned URL for uploading
async getUploadUrl(
userId: string,
data: { filename: string; mimeType: string; size: number }
): Promise<{ uploadUrl: string; key: string }> {
if (data.size > MAX_FILE_SIZE) {
throw new BadRequestException(
`File size exceeds maximum of ${MAX_FILE_SIZE / 1024 / 1024}MB`
);
}
const key = generateUserFileKey(userId, data.filename, 'attachments');
const uploadUrl = await this.storage.getUploadUrl(key, { expiresIn: 3600 });
return { uploadUrl, key };
}
// Generate a presigned URL for downloading
async getDownloadUrl(
id: string,
userId: string
): Promise<{ downloadUrl: string; filename: string }> {
const attachment = await this.findById(id, userId);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
if (!attachment.storageKey) {
throw new BadRequestException('Attachment not yet uploaded');
}
const downloadUrl = await this.storage.getDownloadUrl(attachment.storageKey, {
expiresIn: 3600,
});
return { downloadUrl, filename: attachment.filename };
}
// Mark attachment as uploaded with storage key
async markUploaded(id: string, userId: string, storageKey: string): Promise<Attachment> {
const attachment = await this.findById(id, userId);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
const [updated] = await this.db
.update(attachments)
.set({
storageKey,
isDownloaded: true,
})
.where(and(eq(attachments.id, id), eq(attachments.userId, userId)))
.returning();
return updated;
}
// Upload directly (for server-side operations like sync)
async uploadDirect(
userId: string,
emailId: string,
data: { filename: string; mimeType: string; content: Buffer }
): Promise<Attachment> {
if (data.content.length > MAX_FILE_SIZE) {
throw new BadRequestException(
`File size exceeds maximum of ${MAX_FILE_SIZE / 1024 / 1024}MB`
);
}
const key = generateUserFileKey(userId, data.filename, 'attachments');
// Upload to storage
await this.storage.upload(key, data.content, {
contentType: data.mimeType,
});
// Create attachment record
const attachment = await this.create({
emailId,
userId,
filename: data.filename,
mimeType: data.mimeType,
size: data.content.length,
storageKey: key,
isDownloaded: true,
});
return attachment;
}
// Download content directly (for server-side operations)
async downloadDirect(
id: string,
userId: string
): Promise<{ content: Buffer; filename: string; mimeType: string }> {
const attachment = await this.findById(id, userId);
if (!attachment) {
throw new NotFoundException('Attachment not found');
}
if (!attachment.storageKey) {
throw new BadRequestException('Attachment not available');
}
const content = await this.storage.download(attachment.storageKey);
return {
content,
filename: attachment.filename,
mimeType: attachment.mimeType,
};
}
}

View file

@ -0,0 +1,45 @@
import { IsString, IsOptional, IsUUID, IsNumber, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
export class AttachmentQueryDto {
@IsUUID()
@IsOptional()
emailId?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
offset?: number;
}
export class CreateAttachmentDto {
@IsUUID()
emailId: string;
@IsString()
filename: string;
@IsString()
mimeType: string;
@IsNumber()
size: number;
@IsString()
@IsOptional()
contentId?: string;
}
export class UploadUrlDto {
@IsString()
filename: string;
@IsString()
mimeType: string;
@IsNumber()
size: number;
}

View file

@ -0,0 +1,108 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ComposeService } from './compose.service';
import { CreateDraftDto, UpdateDraftDto, SendEmailDto, DraftQueryDto } from './dto/compose.dto';
@Controller()
@UseGuards(JwtAuthGuard)
export class ComposeController {
constructor(private readonly composeService: ComposeService) {}
// ==================== Drafts ====================
@Get('drafts')
async findAllDrafts(@CurrentUser() user: CurrentUserData, @Query() query: DraftQueryDto) {
const drafts = await this.composeService.findDraftsByUserId(user.userId, query);
const total = await this.composeService.countDrafts(user.userId, query.accountId);
return { drafts, total };
}
@Get('drafts/:id')
async findDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const draft = await this.composeService.findDraftById(id, user.userId);
if (!draft) {
return { draft: null };
}
return { draft };
}
@Post('drafts')
async createDraft(@CurrentUser() user: CurrentUserData, @Body() dto: CreateDraftDto) {
const draft = await this.composeService.createDraft({
...dto,
userId: user.userId,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
});
return { draft };
}
@Patch('drafts/:id')
async updateDraft(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDraftDto
) {
const draft = await this.composeService.updateDraft(id, user.userId, {
...dto,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
});
return { draft };
}
@Delete('drafts/:id')
async deleteDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.composeService.deleteDraft(id, user.userId);
return { success: true };
}
@Post('drafts/:id/send')
async sendDraft(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const result = await this.composeService.sendDraft(id, user.userId);
return result;
}
// ==================== Direct Send ====================
@Post('send')
async sendEmail(@CurrentUser() user: CurrentUserData, @Body() dto: SendEmailDto) {
const result = await this.composeService.sendEmail(user.userId, dto);
return result;
}
// ==================== Reply/Forward ====================
@Post('emails/:id/reply')
async createReply(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply');
return { draft };
}
@Post('emails/:id/reply-all')
async createReplyAll(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'reply-all');
return { draft };
}
@Post('emails/:id/forward')
async createForward(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
const draft = await this.composeService.createReplyDraft(user.userId, id, 'forward');
return { draft };
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ComposeController } from './compose.controller';
import { ComposeService } from './compose.service';
import { AccountModule } from '../account/account.module';
import { EmailModule } from '../email/email.module';
@Module({
imports: [AccountModule, EmailModule],
controllers: [ComposeController],
providers: [ComposeService],
exports: [ComposeService],
})
export class ComposeModule {}

View file

@ -0,0 +1,363 @@
import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { drafts, type Draft, type NewDraft, emailAccounts, type EmailAddress } from '../db/schema';
import { AccountService } from '../account/account.service';
import { EmailService } from '../email/email.service';
import * as nodemailer from 'nodemailer';
export interface DraftFilters {
accountId?: string;
limit?: number;
offset?: number;
}
@Injectable()
export class ComposeService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private accountService: AccountService,
private emailService: EmailService
) {}
// ==================== Draft Management ====================
async findDraftsByUserId(userId: string, filters: DraftFilters = {}): Promise<Draft[]> {
const { accountId, limit = 50, offset = 0 } = filters;
let conditions = [eq(drafts.userId, userId)];
if (accountId) {
conditions.push(eq(drafts.accountId, accountId));
}
return this.db
.select()
.from(drafts)
.where(and(...conditions))
.orderBy(desc(drafts.updatedAt))
.limit(limit)
.offset(offset);
}
async findDraftById(id: string, userId: string): Promise<Draft | null> {
const [draft] = await this.db
.select()
.from(drafts)
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
return draft || null;
}
async createDraft(data: NewDraft): Promise<Draft> {
const [draft] = await this.db.insert(drafts).values(data).returning();
return draft;
}
async updateDraft(id: string, userId: string, data: Partial<NewDraft>): Promise<Draft> {
const [draft] = await this.db
.update(drafts)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(drafts.id, id), eq(drafts.userId, userId)))
.returning();
if (!draft) {
throw new NotFoundException('Draft not found');
}
return draft;
}
async deleteDraft(id: string, userId: string): Promise<void> {
const draft = await this.findDraftById(id, userId);
if (!draft) {
throw new NotFoundException('Draft not found');
}
await this.db.delete(drafts).where(and(eq(drafts.id, id), eq(drafts.userId, userId)));
}
async countDrafts(userId: string, accountId?: string): Promise<number> {
let conditions = [eq(drafts.userId, userId)];
if (accountId) {
conditions.push(eq(drafts.accountId, accountId));
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(drafts)
.where(and(...conditions));
return Number(result[0]?.count || 0);
}
// ==================== Send Email ====================
async sendEmail(
userId: string,
data: {
accountId: string;
subject?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: string;
}
): Promise<{ success: boolean; messageId?: string }> {
// Get the account
const account = await this.accountService.findById(data.accountId, userId);
if (!account) {
throw new NotFoundException('Email account not found');
}
// Build the email
const mailOptions: nodemailer.SendMailOptions = {
from: {
name: account.name,
address: account.email,
},
to: data.toAddresses.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
cc: data.ccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
bcc: data.bccAddresses?.map((a) => (a.name ? `"${a.name}" <${a.email}>` : a.email)),
subject: data.subject || '(No Subject)',
html: data.bodyHtml,
text: data.bodyPlain,
};
// Add reply headers if replying
if (data.replyToEmailId) {
const originalEmail = await this.emailService.findById(data.replyToEmailId, userId);
if (originalEmail) {
mailOptions.inReplyTo = originalEmail.messageId;
mailOptions.references = originalEmail.messageId;
}
}
// Send based on provider
switch (account.provider) {
case 'imap':
return this.sendViaSMTP(account, mailOptions);
case 'gmail':
return this.sendViaGmail(account, mailOptions);
case 'outlook':
return this.sendViaOutlook(account, mailOptions);
default:
throw new BadRequestException(`Unknown provider: ${account.provider}`);
}
}
async sendDraft(
draftId: string,
userId: string
): Promise<{ success: boolean; messageId?: string }> {
const draft = await this.findDraftById(draftId, userId);
if (!draft) {
throw new NotFoundException('Draft not found');
}
if (!draft.toAddresses || draft.toAddresses.length === 0) {
throw new BadRequestException('Draft must have at least one recipient');
}
const result = await this.sendEmail(userId, {
accountId: draft.accountId,
subject: draft.subject || undefined,
toAddresses: draft.toAddresses,
ccAddresses: draft.ccAddresses || undefined,
bccAddresses: draft.bccAddresses || undefined,
bodyHtml: draft.bodyHtml || undefined,
bodyPlain: draft.bodyPlain || undefined,
replyToEmailId: draft.replyToEmailId || undefined,
replyType: draft.replyType || undefined,
});
// Delete draft after successful send
if (result.success) {
await this.deleteDraft(draftId, userId);
}
return result;
}
// ==================== Provider-specific send methods ====================
private async sendViaSMTP(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.smtpHost || !account.smtpPort) {
throw new BadRequestException('SMTP settings not configured for this account');
}
// Get decrypted password
const password = await this.accountService.getDecryptedPassword(account.id, account.userId);
if (!password) {
throw new BadRequestException('Account password not found');
}
// Create transporter
const transporter = nodemailer.createTransport({
host: account.smtpHost,
port: account.smtpPort,
secure: account.smtpSecurity === 'ssl',
auth: {
user: account.email,
pass: password,
},
tls: {
rejectUnauthorized: false, // Allow self-signed certs in dev
},
});
try {
const info = await transporter.sendMail(mailOptions);
return { success: true, messageId: info.messageId };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`SMTP send failed: ${message}`);
}
}
private async sendViaGmail(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.accessToken) {
throw new BadRequestException('Gmail access token not found');
}
// Use OAuth2 with Gmail
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
type: 'OAuth2',
user: account.email,
accessToken: account.accessToken,
},
});
try {
const info = await transporter.sendMail(mailOptions);
return { success: true, messageId: info.messageId };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`Gmail send failed: ${message}`);
}
}
private async sendViaOutlook(
account: typeof emailAccounts.$inferSelect,
mailOptions: nodemailer.SendMailOptions
): Promise<{ success: boolean; messageId?: string }> {
if (!account.accessToken) {
throw new BadRequestException('Outlook access token not found');
}
// Use Microsoft Graph API to send
const { Client } = await import('@microsoft/microsoft-graph-client');
const client = Client.init({
authProvider: (done) => {
done(null, account.accessToken!);
},
});
// Convert to Graph API format
const message = {
subject: mailOptions.subject,
body: {
contentType: mailOptions.html ? 'HTML' : 'Text',
content: mailOptions.html || mailOptions.text || '',
},
toRecipients: (mailOptions.to as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
ccRecipients: (mailOptions.cc as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
bccRecipients: (mailOptions.bcc as string[])?.map((email) => ({
emailAddress: { address: email.replace(/.*<(.+)>/, '$1') },
})),
};
try {
await client.api('/me/sendMail').post({ message, saveToSentItems: true });
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to send email';
throw new BadRequestException(`Outlook send failed: ${message}`);
}
}
// ==================== Reply/Forward Helpers ====================
async createReplyDraft(
userId: string,
emailId: string,
replyType: 'reply' | 'reply-all' | 'forward'
): Promise<Draft> {
const originalEmail = await this.emailService.findById(emailId, userId);
if (!originalEmail) {
throw new NotFoundException('Original email not found');
}
let toAddresses: EmailAddress[] = [];
let ccAddresses: EmailAddress[] = [];
let subject = originalEmail.subject || '';
let bodyHtml = '';
switch (replyType) {
case 'reply':
toAddresses = [
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
];
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
break;
case 'reply-all':
toAddresses = [
{ email: originalEmail.fromAddress || '', name: originalEmail.fromName || undefined },
];
ccAddresses =
originalEmail.toAddresses?.filter((a) => a.email !== originalEmail.fromAddress) || [];
if (originalEmail.ccAddresses) {
ccAddresses = [...ccAddresses, ...originalEmail.ccAddresses];
}
subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
break;
case 'forward':
subject = subject.startsWith('Fwd:') ? subject : `Fwd: ${subject}`;
break;
}
// Build quoted content
const date = originalEmail.sentAt?.toLocaleString() || 'Unknown date';
const from = originalEmail.fromName
? `${originalEmail.fromName} <${originalEmail.fromAddress}>`
: originalEmail.fromAddress;
bodyHtml = `
<br><br>
<div style="border-left: 2px solid #ccc; padding-left: 10px; margin-left: 10px;">
<p><strong>On ${date}, ${from} wrote:</strong></p>
${originalEmail.bodyHtml || `<pre>${originalEmail.bodyPlain || ''}</pre>`}
</div>
`;
return this.createDraft({
userId,
accountId: originalEmail.accountId,
replyToEmailId: emailId,
replyType,
subject,
toAddresses,
ccAddresses,
bodyHtml,
});
}
}

View file

@ -0,0 +1,161 @@
import {
IsString,
IsOptional,
IsUUID,
IsArray,
IsDateString,
ValidateNested,
IsEmail,
IsIn,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class EmailAddressDto {
@IsEmail()
email: string;
@IsString()
@IsOptional()
name?: string;
}
export class CreateDraftDto {
@IsUUID()
accountId: string;
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
toAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsUUID()
@IsOptional()
replyToEmailId?: string;
@IsString()
@IsOptional()
@IsIn(['reply', 'reply-all', 'forward'])
replyType?: string;
@IsDateString()
@IsOptional()
scheduledAt?: string;
}
export class UpdateDraftDto {
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
toAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsDateString()
@IsOptional()
scheduledAt?: string;
}
export class SendEmailDto {
@IsUUID()
accountId: string;
@IsString()
@IsOptional()
subject?: string;
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
toAddresses: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
ccAddresses?: EmailAddressDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => EmailAddressDto)
@IsOptional()
bccAddresses?: EmailAddressDto[];
@IsString()
@IsOptional()
bodyHtml?: string;
@IsString()
@IsOptional()
bodyPlain?: string;
@IsUUID()
@IsOptional()
replyToEmailId?: string;
@IsString()
@IsOptional()
@IsIn(['reply', 'reply-all', 'forward'])
replyType?: string;
}
export class DraftQueryDto {
@IsUUID()
@IsOptional()
accountId?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
offset?: number;
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,25 @@
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
import { emails } from './emails.schema';
export const attachments = pgTable('attachments', {
id: uuid('id').primaryKey().defaultRandom(),
emailId: uuid('email_id')
.references(() => emails.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
filename: varchar('filename', { length: 500 }).notNull(),
mimeType: varchar('mime_type', { length: 255 }).notNull(),
size: integer('size').notNull(),
contentId: varchar('content_id', { length: 255 }), // For inline images
// Storage
storageKey: varchar('storage_key', { length: 500 }),
storageUrl: varchar('storage_url', { length: 1000 }),
isDownloaded: boolean('is_downloaded').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Attachment = typeof attachments.$inferSelect;
export type NewAttachment = typeof attachments.$inferInsert;

View file

@ -0,0 +1,33 @@
import { pgTable, uuid, timestamp, varchar, text, jsonb } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { emails, type EmailAddress } from './emails.schema';
export const drafts = pgTable('drafts', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
// Reply context
replyToEmailId: uuid('reply_to_email_id').references(() => emails.id, { onDelete: 'set null' }),
replyType: varchar('reply_type', { length: 20 }), // reply, reply-all, forward
// Content
subject: text('subject'),
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
bodyHtml: text('body_html'),
bodyPlain: text('body_plain'),
attachmentIds: jsonb('attachment_ids').$type<string[]>(),
// Scheduling
scheduledAt: timestamp('scheduled_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Draft = typeof drafts.$inferSelect;
export type NewDraft = typeof drafts.$inferInsert;

View file

@ -0,0 +1,63 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
} from 'drizzle-orm/pg-core';
export interface SyncState {
// IMAP sync state
uidValidity?: number;
lastUid?: number;
// Gmail sync state
historyId?: string;
// Outlook sync state
deltaLink?: string;
}
export const emailAccounts = pgTable('email_accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
// Account info
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull(),
provider: varchar('provider', { length: 50 }).notNull(), // gmail, outlook, imap
isDefault: boolean('is_default').default(false),
// IMAP/SMTP credentials (encrypted)
imapHost: varchar('imap_host', { length: 255 }),
imapPort: integer('imap_port'),
imapSecurity: varchar('imap_security', { length: 20 }), // ssl, tls, none
smtpHost: varchar('smtp_host', { length: 255 }),
smtpPort: integer('smtp_port'),
smtpSecurity: varchar('smtp_security', { length: 20 }),
encryptedPassword: text('encrypted_password'),
// OAuth tokens (Gmail/Outlook)
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
tokenScopes: jsonb('token_scopes').$type<string[]>(),
// Sync settings
syncEnabled: boolean('sync_enabled').default(true),
syncInterval: integer('sync_interval').default(5), // minutes
lastSyncAt: timestamp('last_sync_at', { withTimezone: true }),
lastSyncError: text('last_sync_error'),
syncState: jsonb('sync_state').$type<SyncState>(),
// Display settings
color: varchar('color', { length: 7 }).default('#3B82F6'),
signature: text('signature'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type EmailAccount = typeof emailAccounts.$inferSelect;
export type NewEmailAccount = typeof emailAccounts.$inferInsert;

View file

@ -0,0 +1,88 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
boolean,
integer,
jsonb,
index,
} from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { folders } from './folders.schema';
export interface EmailAddress {
email: string;
name?: string;
}
export const emails = pgTable(
'emails',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
folderId: uuid('folder_id').references(() => folders.id, { onDelete: 'set null' }),
userId: varchar('user_id', { length: 255 }).notNull(),
threadId: uuid('thread_id'), // For conversation threading
// Message identifiers
messageId: varchar('message_id', { length: 500 }).notNull(), // RFC 2822 Message-ID
externalId: varchar('external_id', { length: 255 }), // Provider-specific ID
// Headers
subject: text('subject'),
fromAddress: varchar('from_address', { length: 255 }),
fromName: varchar('from_name', { length: 255 }),
toAddresses: jsonb('to_addresses').$type<EmailAddress[]>(),
ccAddresses: jsonb('cc_addresses').$type<EmailAddress[]>(),
bccAddresses: jsonb('bcc_addresses').$type<EmailAddress[]>(),
replyTo: varchar('reply_to', { length: 255 }),
inReplyTo: varchar('in_reply_to', { length: 500 }), // Parent message ID
references: jsonb('references').$type<string[]>(), // Thread references
// Content
snippet: text('snippet'), // Preview text (first ~200 chars)
bodyPlain: text('body_plain'),
bodyHtml: text('body_html'),
// Dates
sentAt: timestamp('sent_at', { withTimezone: true }),
receivedAt: timestamp('received_at', { withTimezone: true }),
// Flags
isRead: boolean('is_read').default(false),
isStarred: boolean('is_starred').default(false),
isDraft: boolean('is_draft').default(false),
isDeleted: boolean('is_deleted').default(false),
isSpam: boolean('is_spam').default(false),
hasAttachments: boolean('has_attachments').default(false),
// AI-generated metadata
aiSummary: text('ai_summary'),
aiCategory: varchar('ai_category', { length: 50 }), // work, personal, newsletter, etc.
aiPriority: varchar('ai_priority', { length: 20 }), // high, medium, low
aiSentiment: varchar('ai_sentiment', { length: 20 }), // positive, neutral, negative
aiSuggestedReplies: jsonb('ai_suggested_replies').$type<string[]>(),
// Size and metadata
size: integer('size'), // bytes
headers: jsonb('headers').$type<Record<string, string>>(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index('emails_account_id_idx').on(table.accountId),
index('emails_folder_id_idx').on(table.folderId),
index('emails_thread_id_idx').on(table.threadId),
index('emails_message_id_idx').on(table.messageId),
index('emails_received_at_idx').on(table.receivedAt),
index('emails_user_id_idx').on(table.userId),
]
);
export type Email = typeof emails.$inferSelect;
export type NewEmail = typeof emails.$inferInsert;

View file

@ -0,0 +1,33 @@
import { pgTable, uuid, timestamp, varchar, integer, boolean } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
export const folders = pgTable('folders', {
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id')
.references(() => emailAccounts.id, { onDelete: 'cascade' })
.notNull(),
userId: varchar('user_id', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
type: varchar('type', { length: 50 }).notNull(), // inbox, sent, drafts, trash, spam, archive, custom
path: varchar('path', { length: 500 }).notNull(), // IMAP folder path
color: varchar('color', { length: 7 }),
icon: varchar('icon', { length: 50 }),
// Provider-specific ID
externalId: varchar('external_id', { length: 255 }),
// Counts (cached)
totalCount: integer('total_count').default(0),
unreadCount: integer('unread_count').default(0),
// Flags
isSystem: boolean('is_system').default(false),
isHidden: boolean('is_hidden').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Folder = typeof folders.$inferSelect;
export type NewFolder = typeof folders.$inferInsert;

View file

@ -0,0 +1,6 @@
export * from './email-accounts.schema';
export * from './folders.schema';
export * from './emails.schema';
export * from './attachments.schema';
export * from './labels.schema';
export * from './drafts.schema';

View file

@ -0,0 +1,30 @@
import { pgTable, uuid, timestamp, varchar, primaryKey } from 'drizzle-orm/pg-core';
import { emailAccounts } from './email-accounts.schema';
import { emails } from './emails.schema';
export const labels = pgTable('labels', {
id: uuid('id').primaryKey().defaultRandom(),
userId: varchar('user_id', { length: 255 }).notNull(),
accountId: uuid('account_id').references(() => emailAccounts.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const emailLabels = pgTable(
'email_labels',
{
emailId: uuid('email_id')
.references(() => emails.id, { onDelete: 'cascade' })
.notNull(),
labelId: uuid('label_id')
.references(() => labels.id, { onDelete: 'cascade' })
.notNull(),
},
(table) => [primaryKey({ columns: [table.emailId, table.labelId] })]
);
export type Label = typeof labels.$inferSelect;
export type NewLabel = typeof labels.$inferInsert;

View file

@ -0,0 +1,98 @@
import { IsString, IsOptional, IsUUID, IsBoolean, IsArray, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
export class EmailQueryDto {
@IsUUID()
@IsOptional()
accountId?: string;
@IsUUID()
@IsOptional()
folderId?: string;
@IsUUID()
@IsOptional()
threadId?: string;
@IsString()
@IsOptional()
search?: string;
@IsOptional()
@Transform(({ value }) => value === 'true')
isRead?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true')
isStarred?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true')
hasAttachments?: boolean;
@IsString()
@IsOptional()
@IsIn(['work', 'personal', 'newsletter', 'transactional', 'promotional', 'social'])
aiCategory?: string;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
limit?: number;
@IsOptional()
@Transform(({ value }) => parseInt(value, 10))
offset?: number;
@IsString()
@IsOptional()
@IsIn(['receivedAt', 'sentAt', 'subject', 'fromAddress'])
orderBy?: string;
@IsString()
@IsOptional()
@IsIn(['asc', 'desc'])
order?: 'asc' | 'desc';
}
export class UpdateEmailDto {
@IsBoolean()
@IsOptional()
isRead?: boolean;
@IsBoolean()
@IsOptional()
isStarred?: boolean;
@IsBoolean()
@IsOptional()
isDeleted?: boolean;
@IsBoolean()
@IsOptional()
isSpam?: boolean;
}
export class MoveEmailDto {
@IsUUID()
folderId: string;
}
export class BatchEmailDto {
@IsArray()
@IsUUID('4', { each: true })
ids: string[];
@IsString()
@IsIn(['read', 'unread', 'star', 'unstar', 'delete', 'spam', 'archive'])
action: string;
@IsUUID()
@IsOptional()
folderId?: string; // For move action
}
export class UpdateLabelsDto {
@IsArray()
@IsUUID('4', { each: true })
labelIds: string[];
}

View file

@ -0,0 +1,172 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { EmailService } from './email.service';
import {
EmailQueryDto,
UpdateEmailDto,
MoveEmailDto,
BatchEmailDto,
UpdateLabelsDto,
} from './dto/email.dto';
@Controller('emails')
@UseGuards(JwtAuthGuard)
export class EmailController {
constructor(private readonly emailService: EmailService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: EmailQueryDto) {
const emails = await this.emailService.findByUserId(user.userId, query);
const total = await this.emailService.count(user.userId, {
accountId: query.accountId,
folderId: query.folderId,
isRead: query.isRead,
});
return { emails, total };
}
@Get('search')
async search(@CurrentUser() user: CurrentUserData, @Query() query: EmailQueryDto) {
if (!query.search) {
return { emails: [], total: 0 };
}
const emails = await this.emailService.findByUserId(user.userId, query);
return { emails, total: emails.length };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.findById(id, user.userId);
if (!email) {
return { email: null };
}
// Automatically mark as read when viewing
if (!email.isRead) {
await this.emailService.markAsRead(id, user.userId);
}
return { email };
}
@Get(':id/thread')
async getThread(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.findById(id, user.userId);
if (!email || !email.threadId) {
return { emails: email ? [email] : [] };
}
const emails = await this.emailService.findByThreadId(email.threadId, user.userId);
return { emails };
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateEmailDto
) {
const email = await this.emailService.update(id, user.userId, dto);
return { email };
}
@Post(':id/read')
async markAsRead(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.markAsRead(id, user.userId);
return { email };
}
@Post(':id/unread')
async markAsUnread(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.markAsUnread(id, user.userId);
return { email };
}
@Post(':id/star')
async toggleStar(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.toggleStar(id, user.userId);
return { email };
}
@Post(':id/move')
async move(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: MoveEmailDto
) {
const email = await this.emailService.moveToFolder(id, user.userId, dto.folderId);
return { email };
}
@Post(':id/trash')
async moveToTrash(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.moveToTrash(id, user.userId);
return { email };
}
@Post(':id/spam')
async markAsSpam(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.markAsSpam(id, user.userId);
return { email };
}
@Post(':id/archive')
async archive(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const email = await this.emailService.archive(id, user.userId);
return { email };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
// Soft delete (move to trash)
const email = await this.emailService.moveToTrash(id, user.userId);
return { success: true, email };
}
@Delete(':id/permanent')
async permanentDelete(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string
) {
await this.emailService.permanentDelete(id, user.userId);
return { success: true };
}
// Batch operations
@Post('batch')
async batchOperation(@CurrentUser() user: CurrentUserData, @Body() dto: BatchEmailDto) {
let affected = 0;
switch (dto.action) {
case 'read':
affected = await this.emailService.batchMarkAsRead(dto.ids, user.userId);
break;
case 'unread':
affected = await this.emailService.batchMarkAsUnread(dto.ids, user.userId);
break;
case 'star':
affected = await this.emailService.batchStar(dto.ids, user.userId, true);
break;
case 'unstar':
affected = await this.emailService.batchStar(dto.ids, user.userId, false);
break;
case 'delete':
affected = await this.emailService.batchDelete(dto.ids, user.userId);
break;
// TODO: Implement move and archive batch operations
}
return { success: true, affected };
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { EmailController } from './email.controller';
import { EmailService } from './email.service';
import { FolderModule } from '../folder/folder.module';
@Module({
imports: [FolderModule],
controllers: [EmailController],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View file

@ -0,0 +1,358 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc, asc, ilike, or, sql, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { emails, type Email, type NewEmail, emailLabels } from '../db/schema';
import { FolderService } from '../folder/folder.service';
export interface EmailFilters {
accountId?: string;
folderId?: string;
threadId?: string;
search?: string;
isRead?: boolean;
isStarred?: boolean;
hasAttachments?: boolean;
aiCategory?: string;
limit?: number;
offset?: number;
orderBy?: string;
order?: 'asc' | 'desc';
}
@Injectable()
export class EmailService {
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private folderService: FolderService
) {}
async findByUserId(userId: string, filters: EmailFilters = {}): Promise<Email[]> {
const {
accountId,
folderId,
threadId,
search,
isRead,
isStarred,
hasAttachments,
aiCategory,
limit = 50,
offset = 0,
orderBy = 'receivedAt',
order = 'desc',
} = filters;
let conditions = [eq(emails.userId, userId), eq(emails.isDeleted, false)];
if (accountId) {
conditions.push(eq(emails.accountId, accountId));
}
if (folderId) {
conditions.push(eq(emails.folderId, folderId));
}
if (threadId) {
conditions.push(eq(emails.threadId, threadId));
}
if (isRead !== undefined) {
conditions.push(eq(emails.isRead, isRead));
}
if (isStarred !== undefined) {
conditions.push(eq(emails.isStarred, isStarred));
}
if (hasAttachments !== undefined) {
conditions.push(eq(emails.hasAttachments, hasAttachments));
}
if (aiCategory) {
conditions.push(eq(emails.aiCategory, aiCategory));
}
if (search) {
conditions.push(
or(
ilike(emails.subject, `%${search}%`),
ilike(emails.fromAddress, `%${search}%`),
ilike(emails.fromName, `%${search}%`),
ilike(emails.snippet, `%${search}%`)
)!
);
}
// Determine sort column
let sortColumn;
switch (orderBy) {
case 'sentAt':
sortColumn = emails.sentAt;
break;
case 'subject':
sortColumn = emails.subject;
break;
case 'fromAddress':
sortColumn = emails.fromAddress;
break;
default:
sortColumn = emails.receivedAt;
}
const orderFn = order === 'asc' ? asc : desc;
return this.db
.select()
.from(emails)
.where(and(...conditions))
.orderBy(orderFn(sortColumn))
.limit(limit)
.offset(offset);
}
async findById(id: string, userId: string): Promise<Email | null> {
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, id), eq(emails.userId, userId)));
return email || null;
}
async findByMessageId(messageId: string, userId: string): Promise<Email | null> {
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.messageId, messageId), eq(emails.userId, userId)));
return email || null;
}
async findByThreadId(threadId: string, userId: string): Promise<Email[]> {
return this.db
.select()
.from(emails)
.where(and(eq(emails.threadId, threadId), eq(emails.userId, userId)))
.orderBy(asc(emails.receivedAt));
}
async create(data: NewEmail): Promise<Email> {
const [email] = await this.db.insert(emails).values(data).returning();
// Update folder counts
if (email.folderId) {
await this.folderService.incrementTotalCount(email.folderId, 1);
if (!email.isRead) {
await this.folderService.incrementUnreadCount(email.folderId, 1);
}
}
return email;
}
async update(id: string, userId: string, data: Partial<NewEmail>): Promise<Email> {
const existingEmail = await this.findById(id, userId);
if (!existingEmail) {
throw new NotFoundException('Email not found');
}
const [email] = await this.db
.update(emails)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(emails.id, id), eq(emails.userId, userId)))
.returning();
// Update folder unread counts if read status changed
if (
data.isRead !== undefined &&
existingEmail.isRead !== data.isRead &&
existingEmail.folderId
) {
const delta = data.isRead ? -1 : 1;
await this.folderService.incrementUnreadCount(existingEmail.folderId, delta);
}
return email;
}
async markAsRead(id: string, userId: string): Promise<Email> {
return this.update(id, userId, { isRead: true });
}
async markAsUnread(id: string, userId: string): Promise<Email> {
return this.update(id, userId, { isRead: false });
}
async toggleStar(id: string, userId: string): Promise<Email> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
return this.update(id, userId, { isStarred: !email.isStarred });
}
async moveToFolder(id: string, userId: string, folderId: string): Promise<Email> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
const folder = await this.folderService.findById(folderId, userId);
if (!folder) {
throw new NotFoundException('Folder not found');
}
// Update old folder counts
if (email.folderId) {
await this.folderService.incrementTotalCount(email.folderId, -1);
if (!email.isRead) {
await this.folderService.incrementUnreadCount(email.folderId, -1);
}
}
// Update new folder counts
await this.folderService.incrementTotalCount(folderId, 1);
if (!email.isRead) {
await this.folderService.incrementUnreadCount(folderId, 1);
}
return this.update(id, userId, { folderId });
}
async moveToTrash(id: string, userId: string): Promise<Email> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
// Find trash folder
const trashFolder = await this.folderService.findByType(email.accountId, userId, 'trash');
if (trashFolder) {
return this.moveToFolder(id, userId, trashFolder.id);
}
// If no trash folder, just mark as deleted
return this.update(id, userId, { isDeleted: true });
}
async markAsSpam(id: string, userId: string): Promise<Email> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
// Find spam folder
const spamFolder = await this.folderService.findByType(email.accountId, userId, 'spam');
if (spamFolder) {
await this.moveToFolder(id, userId, spamFolder.id);
}
return this.update(id, userId, { isSpam: true });
}
async archive(id: string, userId: string): Promise<Email> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
// Find archive folder
const archiveFolder = await this.folderService.findByType(email.accountId, userId, 'archive');
if (archiveFolder) {
return this.moveToFolder(id, userId, archiveFolder.id);
}
throw new NotFoundException('Archive folder not found');
}
async permanentDelete(id: string, userId: string): Promise<void> {
const email = await this.findById(id, userId);
if (!email) {
throw new NotFoundException('Email not found');
}
// Update folder counts
if (email.folderId) {
await this.folderService.incrementTotalCount(email.folderId, -1);
if (!email.isRead) {
await this.folderService.incrementUnreadCount(email.folderId, -1);
}
}
await this.db.delete(emails).where(and(eq(emails.id, id), eq(emails.userId, userId)));
}
// Batch operations
async batchMarkAsRead(ids: string[], userId: string): Promise<number> {
const result = await this.db
.update(emails)
.set({ isRead: true, updatedAt: new Date() })
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
return ids.length;
}
async batchMarkAsUnread(ids: string[], userId: string): Promise<number> {
const result = await this.db
.update(emails)
.set({ isRead: false, updatedAt: new Date() })
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
return ids.length;
}
async batchStar(ids: string[], userId: string, starred: boolean): Promise<number> {
await this.db
.update(emails)
.set({ isStarred: starred, updatedAt: new Date() })
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
return ids.length;
}
async batchDelete(ids: string[], userId: string): Promise<number> {
await this.db
.update(emails)
.set({ isDeleted: true, updatedAt: new Date() })
.where(and(inArray(emails.id, ids), eq(emails.userId, userId)));
return ids.length;
}
async count(userId: string, filters: Partial<EmailFilters> = {}): Promise<number> {
let conditions = [eq(emails.userId, userId), eq(emails.isDeleted, false)];
if (filters.accountId) {
conditions.push(eq(emails.accountId, filters.accountId));
}
if (filters.folderId) {
conditions.push(eq(emails.folderId, filters.folderId));
}
if (filters.isRead !== undefined) {
conditions.push(eq(emails.isRead, filters.isRead));
}
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(emails)
.where(and(...conditions));
return Number(result[0]?.count || 0);
}
// Update AI metadata
async updateAIMetadata(
id: string,
userId: string,
metadata: {
aiSummary?: string;
aiCategory?: string;
aiPriority?: string;
aiSentiment?: string;
aiSuggestedReplies?: string[];
}
): Promise<Email> {
return this.update(id, userId, metadata);
}
}

View file

@ -0,0 +1,57 @@
import { IsString, IsOptional, IsUUID, IsBoolean, MaxLength, IsIn } from 'class-validator';
import { Transform } from 'class-transformer';
export class CreateFolderDto {
@IsUUID()
accountId: string;
@IsString()
@MaxLength(255)
name: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
@IsString()
@IsOptional()
@MaxLength(50)
icon?: string;
}
export class UpdateFolderDto {
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
@IsString()
@IsOptional()
@MaxLength(7)
color?: string;
@IsString()
@IsOptional()
@MaxLength(50)
icon?: string;
@IsBoolean()
@IsOptional()
isHidden?: boolean;
}
export class FolderQueryDto {
@IsUUID()
@IsOptional()
accountId?: string;
@IsString()
@IsOptional()
@IsIn(['inbox', 'sent', 'drafts', 'trash', 'spam', 'archive', 'custom'])
type?: string;
@IsOptional()
@Transform(({ value }) => value === 'true')
includeHidden?: boolean;
}

View file

@ -0,0 +1,88 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
BadRequestException,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FolderService } from './folder.service';
import { CreateFolderDto, UpdateFolderDto, FolderQueryDto } from './dto/folder.dto';
@Controller('folders')
@UseGuards(JwtAuthGuard)
export class FolderController {
constructor(private readonly folderService: FolderService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: FolderQueryDto) {
const folders = await this.folderService.findByUserId(user.userId, query);
return { folders };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const folder = await this.folderService.findById(id, user.userId);
if (!folder) {
return { folder: null };
}
return { folder };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFolderDto) {
const folder = await this.folderService.create({
...dto,
userId: user.userId,
type: 'custom',
path: dto.name, // For custom folders, path is the name
isSystem: false,
});
return { folder };
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateFolderDto
) {
const existingFolder = await this.folderService.findById(id, user.userId);
if (!existingFolder) {
throw new BadRequestException('Folder not found');
}
// Don't allow renaming system folders
if (existingFolder.isSystem && dto.name) {
throw new BadRequestException('Cannot rename system folders');
}
const folder = await this.folderService.update(id, user.userId, dto);
return { folder };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.folderService.delete(id, user.userId);
return { success: true };
}
@Post(':id/hide')
async toggleHidden(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const folder = await this.folderService.findById(id, user.userId);
if (!folder) {
throw new BadRequestException('Folder not found');
}
const updatedFolder = await this.folderService.update(id, user.userId, {
isHidden: !folder.isHidden,
});
return { folder: updatedFolder };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FolderController } from './folder.controller';
import { FolderService } from './folder.service';
@Module({
controllers: [FolderController],
providers: [FolderService],
exports: [FolderService],
})
export class FolderModule {}

View file

@ -0,0 +1,200 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { folders, type Folder, type NewFolder } from '../db/schema';
export interface FolderFilters {
accountId?: string;
type?: string;
includeHidden?: boolean;
}
// Standard folder types that should be created for each account
export const SYSTEM_FOLDERS = [
{ type: 'inbox', name: 'Inbox', path: 'INBOX', icon: 'inbox' },
{ type: 'sent', name: 'Sent', path: 'Sent', icon: 'send' },
{ type: 'drafts', name: 'Drafts', path: 'Drafts', icon: 'file-text' },
{ type: 'trash', name: 'Trash', path: 'Trash', icon: 'trash' },
{ type: 'spam', name: 'Spam', path: 'Spam', icon: 'alert-triangle' },
{ type: 'archive', name: 'Archive', path: 'Archive', icon: 'archive' },
];
@Injectable()
export class FolderService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string, filters: FolderFilters = {}): Promise<Folder[]> {
const { accountId, type, includeHidden = false } = filters;
let conditions = [eq(folders.userId, userId)];
if (accountId) {
conditions.push(eq(folders.accountId, accountId));
}
if (type) {
conditions.push(eq(folders.type, type));
}
if (!includeHidden) {
conditions.push(eq(folders.isHidden, false));
}
return this.db
.select()
.from(folders)
.where(and(...conditions))
.orderBy(desc(folders.isSystem), folders.name);
}
async findById(id: string, userId: string): Promise<Folder | null> {
const [folder] = await this.db
.select()
.from(folders)
.where(and(eq(folders.id, id), eq(folders.userId, userId)));
return folder || null;
}
async findByAccountId(accountId: string, userId: string): Promise<Folder[]> {
return this.db
.select()
.from(folders)
.where(and(eq(folders.accountId, accountId), eq(folders.userId, userId)))
.orderBy(desc(folders.isSystem), folders.name);
}
async findByType(accountId: string, userId: string, type: string): Promise<Folder | null> {
const [folder] = await this.db
.select()
.from(folders)
.where(
and(eq(folders.accountId, accountId), eq(folders.userId, userId), eq(folders.type, type))
);
return folder || null;
}
async create(data: NewFolder): Promise<Folder> {
const [folder] = await this.db.insert(folders).values(data).returning();
return folder;
}
async update(id: string, userId: string, data: Partial<NewFolder>): Promise<Folder> {
const [folder] = await this.db
.update(folders)
.set({ ...data, updatedAt: new Date() })
.where(and(eq(folders.id, id), eq(folders.userId, userId)))
.returning();
if (!folder) {
throw new NotFoundException('Folder not found');
}
return folder;
}
async delete(id: string, userId: string): Promise<void> {
const folder = await this.findById(id, userId);
if (!folder) {
throw new NotFoundException('Folder not found');
}
// Prevent deletion of system folders
if (folder.isSystem) {
throw new NotFoundException('Cannot delete system folder');
}
await this.db.delete(folders).where(and(eq(folders.id, id), eq(folders.userId, userId)));
}
// Create system folders for a new account
async createSystemFolders(accountId: string, userId: string): Promise<Folder[]> {
const createdFolders: Folder[] = [];
for (const systemFolder of SYSTEM_FOLDERS) {
const folder = await this.create({
accountId,
userId,
name: systemFolder.name,
type: systemFolder.type,
path: systemFolder.path,
icon: systemFolder.icon,
isSystem: true,
isHidden: false,
});
createdFolders.push(folder);
}
return createdFolders;
}
// Update folder counts
async updateCounts(id: string, totalCount: number, unreadCount: number): Promise<void> {
await this.db
.update(folders)
.set({ totalCount, unreadCount, updatedAt: new Date() })
.where(eq(folders.id, id));
}
// Increment/decrement counts
async incrementUnreadCount(id: string, delta: number): Promise<void> {
await this.db
.update(folders)
.set({
unreadCount: sql`GREATEST(0, ${folders.unreadCount} + ${delta})`,
updatedAt: new Date(),
})
.where(eq(folders.id, id));
}
async incrementTotalCount(id: string, delta: number): Promise<void> {
await this.db
.update(folders)
.set({
totalCount: sql`GREATEST(0, ${folders.totalCount} + ${delta})`,
updatedAt: new Date(),
})
.where(eq(folders.id, id));
}
// Sync folders from external provider
async syncFromProvider(
accountId: string,
userId: string,
providerFolders: Array<{ name: string; path: string; type?: string; externalId?: string }>
): Promise<Folder[]> {
const existingFolders = await this.findByAccountId(accountId, userId);
const existingPaths = new Set(existingFolders.map((f) => f.path));
const newFolders: Folder[] = [];
for (const pf of providerFolders) {
if (!existingPaths.has(pf.path)) {
// Determine folder type
let type = pf.type || 'custom';
const lowerPath = pf.path.toLowerCase();
if (!pf.type) {
if (lowerPath === 'inbox') type = 'inbox';
else if (lowerPath.includes('sent')) type = 'sent';
else if (lowerPath.includes('draft')) type = 'drafts';
else if (lowerPath.includes('trash') || lowerPath.includes('deleted')) type = 'trash';
else if (lowerPath.includes('spam') || lowerPath.includes('junk')) type = 'spam';
else if (lowerPath.includes('archive')) type = 'archive';
}
const folder = await this.create({
accountId,
userId,
name: pf.name,
path: pf.path,
type,
externalId: pf.externalId,
isSystem: ['inbox', 'sent', 'drafts', 'trash', 'spam', 'archive'].includes(type),
});
newFolders.push(folder);
}
}
return newFolders;
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
service: 'mail-backend',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,45 @@
import { IsString, IsOptional, IsUUID, IsArray, Matches, MaxLength } from 'class-validator';
export class CreateLabelDto {
@IsString()
@MaxLength(100)
name: string;
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color (e.g., #FF5733)' })
color: string;
@IsUUID()
@IsOptional()
accountId?: string;
}
export class UpdateLabelDto {
@IsString()
@MaxLength(100)
@IsOptional()
name?: string;
@IsString()
@Matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a valid hex color (e.g., #FF5733)' })
@IsOptional()
color?: string;
}
export class LabelQueryDto {
@IsUUID()
@IsOptional()
accountId?: string;
}
export class AddLabelsDto {
@IsArray()
@IsUUID('4', { each: true })
labelIds: string[];
}
export class RemoveLabelsDto {
@IsArray()
@IsUUID('4', { each: true })
labelIds: string[];
}

View file

@ -0,0 +1,107 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
ParseUUIDPipe,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LabelService } from './label.service';
import {
CreateLabelDto,
UpdateLabelDto,
LabelQueryDto,
AddLabelsDto,
RemoveLabelsDto,
} from './dto/label.dto';
@Controller('labels')
@UseGuards(JwtAuthGuard)
export class LabelController {
constructor(private readonly labelService: LabelService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query() query: LabelQueryDto) {
const labels = await this.labelService.findByUserId(user.userId, query);
return { labels };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
const label = await this.labelService.findById(id, user.userId);
if (!label) {
return { label: null };
}
return { label };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLabelDto) {
const label = await this.labelService.create({
...dto,
userId: user.userId,
});
return { label };
}
@Patch(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateLabelDto
) {
const label = await this.labelService.update(id, user.userId, dto);
return { label };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
await this.labelService.delete(id, user.userId);
return { success: true };
}
// Email-Label associations
@Get('/email/:emailId')
async getEmailLabels(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string
) {
const labels = await this.labelService.getEmailLabels(emailId, user.userId);
return { labels };
}
@Post('/email/:emailId/add')
async addLabelsToEmail(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string,
@Body() dto: AddLabelsDto
) {
await this.labelService.addLabelsToEmail(emailId, dto.labelIds, user.userId);
return { success: true };
}
@Post('/email/:emailId/remove')
async removeLabelsFromEmail(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string,
@Body() dto: RemoveLabelsDto
) {
await this.labelService.removeLabelsFromEmail(emailId, dto.labelIds, user.userId);
return { success: true };
}
@Post('/email/:emailId/set')
async setEmailLabels(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string,
@Body() dto: AddLabelsDto
) {
await this.labelService.setEmailLabels(emailId, dto.labelIds, user.userId);
return { success: true };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LabelController } from './label.controller';
import { LabelService } from './label.service';
@Module({
controllers: [LabelController],
providers: [LabelService],
exports: [LabelService],
})
export class LabelModule {}

View file

@ -0,0 +1,194 @@
import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common';
import { eq, and, inArray } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { labels, emailLabels, type Label, type NewLabel } from '../db/schema';
export interface LabelFilters {
accountId?: string;
}
@Injectable()
export class LabelService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
async findByUserId(userId: string, filters: LabelFilters = {}): Promise<Label[]> {
const { accountId } = filters;
let conditions = [eq(labels.userId, userId)];
if (accountId) {
conditions.push(eq(labels.accountId, accountId));
}
return this.db
.select()
.from(labels)
.where(and(...conditions));
}
async findById(id: string, userId: string): Promise<Label | null> {
const [label] = await this.db
.select()
.from(labels)
.where(and(eq(labels.id, id), eq(labels.userId, userId)));
return label || null;
}
async create(data: NewLabel): Promise<Label> {
// Check for duplicate name within same user/account
const existing = await this.db
.select()
.from(labels)
.where(
and(
eq(labels.userId, data.userId),
eq(labels.name, data.name),
data.accountId ? eq(labels.accountId, data.accountId) : undefined
)
);
if (existing.length > 0) {
throw new ConflictException('A label with this name already exists');
}
const [label] = await this.db.insert(labels).values(data).returning();
return label;
}
async update(id: string, userId: string, data: Partial<NewLabel>): Promise<Label> {
// Check name uniqueness if name is being updated
if (data.name) {
const label = await this.findById(id, userId);
if (!label) {
throw new NotFoundException('Label not found');
}
const existing = await this.db
.select()
.from(labels)
.where(
and(
eq(labels.userId, userId),
eq(labels.name, data.name),
label.accountId ? eq(labels.accountId, label.accountId) : undefined
)
);
if (existing.length > 0 && existing[0].id !== id) {
throw new ConflictException('A label with this name already exists');
}
}
const [updated] = await this.db
.update(labels)
.set(data)
.where(and(eq(labels.id, id), eq(labels.userId, userId)))
.returning();
if (!updated) {
throw new NotFoundException('Label not found');
}
return updated;
}
async delete(id: string, userId: string): Promise<void> {
const label = await this.findById(id, userId);
if (!label) {
throw new NotFoundException('Label not found');
}
// Email labels will be deleted via cascade
await this.db.delete(labels).where(and(eq(labels.id, id), eq(labels.userId, userId)));
}
// Get labels for a specific email
async getEmailLabels(emailId: string, userId: string): Promise<Label[]> {
const result = await this.db
.select({
label: labels,
})
.from(emailLabels)
.innerJoin(labels, eq(emailLabels.labelId, labels.id))
.where(and(eq(emailLabels.emailId, emailId), eq(labels.userId, userId)));
return result.map((r) => r.label);
}
// Add labels to an email
async addLabelsToEmail(emailId: string, labelIds: string[], userId: string): Promise<void> {
// Verify all labels belong to user
const userLabels = await this.db
.select()
.from(labels)
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
if (userLabels.length !== labelIds.length) {
throw new NotFoundException('One or more labels not found');
}
// Get existing labels for this email
const existing = await this.db
.select()
.from(emailLabels)
.where(and(eq(emailLabels.emailId, emailId), inArray(emailLabels.labelId, labelIds)));
const existingIds = new Set(existing.map((e) => e.labelId));
const newLabelIds = labelIds.filter((id) => !existingIds.has(id));
if (newLabelIds.length > 0) {
await this.db.insert(emailLabels).values(
newLabelIds.map((labelId) => ({
emailId,
labelId,
}))
);
}
}
// Remove labels from an email
async removeLabelsFromEmail(emailId: string, labelIds: string[], userId: string): Promise<void> {
// Verify all labels belong to user
const userLabels = await this.db
.select()
.from(labels)
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
if (userLabels.length !== labelIds.length) {
throw new NotFoundException('One or more labels not found');
}
await this.db
.delete(emailLabels)
.where(and(eq(emailLabels.emailId, emailId), inArray(emailLabels.labelId, labelIds)));
}
// Set labels for an email (replace all existing)
async setEmailLabels(emailId: string, labelIds: string[], userId: string): Promise<void> {
// Verify all labels belong to user
if (labelIds.length > 0) {
const userLabels = await this.db
.select()
.from(labels)
.where(and(eq(labels.userId, userId), inArray(labels.id, labelIds)));
if (userLabels.length !== labelIds.length) {
throw new NotFoundException('One or more labels not found');
}
}
// Remove all existing labels
await this.db.delete(emailLabels).where(eq(emailLabels.emailId, emailId));
// Add new labels
if (labelIds.length > 0) {
await this.db.insert(emailLabels).values(
labelIds.map((labelId) => ({
emailId,
labelId,
}))
);
}
}
}

View file

@ -0,0 +1,40 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
const corsOrigins = process.env.CORS_ORIGINS?.split(',').map((origin) => origin.trim()) || [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5186',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001',
];
app.enableCors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api/v1');
const port = process.env.PORT || 3017;
await app.listen(port);
console.log(`Mail backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,146 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { google, Auth } from 'googleapis';
import * as crypto from 'crypto';
export interface GoogleTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
scopes: string[];
}
export interface GoogleUserInfo {
email: string;
name?: string;
picture?: string;
}
@Injectable()
export class GoogleOAuthService {
private oauth2Client: Auth.OAuth2Client;
private readonly scopes = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
constructor(private configService: ConfigService) {
const clientId = this.configService.get<string>('GOOGLE_CLIENT_ID');
const clientSecret = this.configService.get<string>('GOOGLE_CLIENT_SECRET');
const redirectUri = this.configService.get<string>('GOOGLE_REDIRECT_URI');
if (clientId && clientSecret && redirectUri) {
this.oauth2Client = new google.auth.OAuth2(clientId, clientSecret, redirectUri);
}
}
private encodeState(data: { userId: string }): string {
const json = JSON.stringify(data);
return Buffer.from(json).toString('base64url');
}
private decodeState(state: string): { userId: string } {
try {
const json = Buffer.from(state, 'base64url').toString('utf-8');
return JSON.parse(json);
} catch {
throw new BadRequestException('Invalid state parameter');
}
}
isConfigured(): boolean {
return !!this.oauth2Client;
}
getAuthUrl(userId: string): string {
if (!this.isConfigured()) {
throw new BadRequestException('Google OAuth is not configured');
}
const state = this.encodeState({ userId });
return this.oauth2Client.generateAuthUrl({
access_type: 'offline',
scope: this.scopes,
state,
prompt: 'consent', // Force consent to get refresh token
});
}
async handleCallback(
code: string,
state: string
): Promise<{ userId: string; tokens: GoogleTokens; userInfo: GoogleUserInfo }> {
if (!this.isConfigured()) {
throw new BadRequestException('Google OAuth is not configured');
}
const { userId } = this.decodeState(state);
// Exchange code for tokens
const { tokens } = await this.oauth2Client.getToken(code);
if (!tokens.access_token) {
throw new BadRequestException('Failed to get access token from Google');
}
// Get user info
this.oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: 'v2', auth: this.oauth2Client });
const { data: userInfo } = await oauth2.userinfo.get();
const expiresAt = tokens.expiry_date ? new Date(tokens.expiry_date) : undefined;
return {
userId,
tokens: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || undefined,
expiresAt,
scopes: tokens.scope?.split(' ') || this.scopes,
},
userInfo: {
email: userInfo.email || '',
name: userInfo.name || undefined,
picture: userInfo.picture || undefined,
},
};
}
async refreshAccessToken(refreshToken: string): Promise<GoogleTokens> {
if (!this.isConfigured()) {
throw new BadRequestException('Google OAuth is not configured');
}
this.oauth2Client.setCredentials({ refresh_token: refreshToken });
const { credentials } = await this.oauth2Client.refreshAccessToken();
if (!credentials.access_token) {
throw new BadRequestException('Failed to refresh access token');
}
return {
accessToken: credentials.access_token,
refreshToken: credentials.refresh_token || refreshToken,
expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : undefined,
scopes: credentials.scope?.split(' ') || this.scopes,
};
}
getAuthenticatedClient(accessToken: string): Auth.OAuth2Client {
if (!this.isConfigured()) {
throw new BadRequestException('Google OAuth is not configured');
}
const client = new google.auth.OAuth2(
this.configService.get<string>('GOOGLE_CLIENT_ID'),
this.configService.get<string>('GOOGLE_CLIENT_SECRET')
);
client.setCredentials({ access_token: accessToken });
return client;
}
}

View file

@ -0,0 +1,190 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client } from '@microsoft/microsoft-graph-client';
export interface MicrosoftTokens {
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
scopes: string[];
}
export interface MicrosoftUserInfo {
email: string;
name?: string;
}
@Injectable()
export class MicrosoftOAuthService {
private clientId: string;
private clientSecret: string;
private redirectUri: string;
private tenantId: string;
private readonly scopes = [
'Mail.Read',
'Mail.Send',
'Mail.ReadWrite',
'User.Read',
'offline_access',
];
constructor(private configService: ConfigService) {
this.clientId = this.configService.get<string>('MICROSOFT_CLIENT_ID') || '';
this.clientSecret = this.configService.get<string>('MICROSOFT_CLIENT_SECRET') || '';
this.redirectUri = this.configService.get<string>('MICROSOFT_REDIRECT_URI') || '';
this.tenantId = this.configService.get<string>('MICROSOFT_TENANT_ID') || 'common';
}
private encodeState(data: { userId: string }): string {
const json = JSON.stringify(data);
return Buffer.from(json).toString('base64url');
}
private decodeState(state: string): { userId: string } {
try {
const json = Buffer.from(state, 'base64url').toString('utf-8');
return JSON.parse(json);
} catch {
throw new BadRequestException('Invalid state parameter');
}
}
isConfigured(): boolean {
return !!(this.clientId && this.clientSecret && this.redirectUri);
}
getAuthUrl(userId: string): string {
if (!this.isConfigured()) {
throw new BadRequestException('Microsoft OAuth is not configured');
}
const state = this.encodeState({ userId });
const scope = this.scopes.join(' ');
const params = new URLSearchParams({
client_id: this.clientId,
response_type: 'code',
redirect_uri: this.redirectUri,
response_mode: 'query',
scope,
state,
prompt: 'consent',
});
return `https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
}
async handleCallback(
code: string,
state: string
): Promise<{ userId: string; tokens: MicrosoftTokens; userInfo: MicrosoftUserInfo }> {
if (!this.isConfigured()) {
throw new BadRequestException('Microsoft OAuth is not configured');
}
const { userId } = this.decodeState(state);
// Exchange code for tokens
const tokenResponse = await fetch(
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
code,
redirect_uri: this.redirectUri,
grant_type: 'authorization_code',
scope: this.scopes.join(' '),
}),
}
);
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new BadRequestException(`Failed to get tokens from Microsoft: ${error}`);
}
const tokenData = await tokenResponse.json();
// Get user info using Graph API
const client = Client.init({
authProvider: (done) => {
done(null, tokenData.access_token);
},
});
const user = await client.api('/me').select('mail,displayName,userPrincipalName').get();
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000)
: undefined;
return {
userId,
tokens: {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt,
scopes: tokenData.scope?.split(' ') || this.scopes,
},
userInfo: {
email: user.mail || user.userPrincipalName || '',
name: user.displayName || undefined,
},
};
}
async refreshAccessToken(refreshToken: string): Promise<MicrosoftTokens> {
if (!this.isConfigured()) {
throw new BadRequestException('Microsoft OAuth is not configured');
}
const tokenResponse = await fetch(
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: this.scopes.join(' '),
}),
}
);
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new BadRequestException(`Failed to refresh Microsoft token: ${error}`);
}
const tokenData = await tokenResponse.json();
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000)
: undefined;
return {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || refreshToken,
expiresAt,
scopes: tokenData.scope?.split(' ') || this.scopes,
};
}
getGraphClient(accessToken: string): Client {
return Client.init({
authProvider: (done) => {
done(null, accessToken);
},
});
}
}

View file

@ -0,0 +1,150 @@
import { Controller, Get, Post, Query, Res, UseGuards, BadRequestException } from '@nestjs/common';
import { Response } from 'express';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { GoogleOAuthService } from './google-oauth.service';
import { MicrosoftOAuthService } from './microsoft-oauth.service';
import { AccountService } from '../account/account.service';
@Controller('oauth')
export class OAuthController {
constructor(
private readonly googleOAuthService: GoogleOAuthService,
private readonly microsoftOAuthService: MicrosoftOAuthService,
private readonly accountService: AccountService
) {}
// ==================== Google OAuth ====================
@Post('google/init')
@UseGuards(JwtAuthGuard)
async initGoogleOAuth(@CurrentUser() user: CurrentUserData) {
if (!this.googleOAuthService.isConfigured()) {
throw new BadRequestException(
'Google OAuth is not configured. Please set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REDIRECT_URI.'
);
}
const authUrl = this.googleOAuthService.getAuthUrl(user.userId);
return { authUrl };
}
@Get('google/callback')
async googleCallback(
@Query('code') code: string,
@Query('state') state: string,
@Query('error') error: string,
@Res() res: Response
) {
// Redirect URL for the frontend
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5186';
if (error) {
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(error)}`);
}
if (!code || !state) {
return res.redirect(`${frontendUrl}/accounts?error=missing_params`);
}
try {
const { userId, tokens, userInfo } = await this.googleOAuthService.handleCallback(
code,
state
);
// Create the email account
await this.accountService.create({
userId,
name: userInfo.name || userInfo.email,
email: userInfo.email,
provider: 'gmail',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
tokenScopes: tokens.scopes,
syncEnabled: true,
});
return res.redirect(`${frontendUrl}/accounts?success=gmail_connected`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
}
}
// ==================== Microsoft OAuth ====================
@Post('microsoft/init')
@UseGuards(JwtAuthGuard)
async initMicrosoftOAuth(@CurrentUser() user: CurrentUserData) {
if (!this.microsoftOAuthService.isConfigured()) {
throw new BadRequestException(
'Microsoft OAuth is not configured. Please set MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, and MICROSOFT_REDIRECT_URI.'
);
}
const authUrl = this.microsoftOAuthService.getAuthUrl(user.userId);
return { authUrl };
}
@Get('microsoft/callback')
async microsoftCallback(
@Query('code') code: string,
@Query('state') state: string,
@Query('error') error: string,
@Query('error_description') errorDescription: string,
@Res() res: Response
) {
// Redirect URL for the frontend
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5186';
if (error) {
const message = errorDescription || error;
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
}
if (!code || !state) {
return res.redirect(`${frontendUrl}/accounts?error=missing_params`);
}
try {
const { userId, tokens, userInfo } = await this.microsoftOAuthService.handleCallback(
code,
state
);
// Create the email account
await this.accountService.create({
userId,
name: userInfo.name || userInfo.email,
email: userInfo.email,
provider: 'outlook',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
tokenExpiresAt: tokens.expiresAt,
tokenScopes: tokens.scopes,
syncEnabled: true,
});
return res.redirect(`${frontendUrl}/accounts?success=outlook_connected`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return res.redirect(`${frontendUrl}/accounts?error=${encodeURIComponent(message)}`);
}
}
// ==================== Status ====================
@Get('status')
@UseGuards(JwtAuthGuard)
async getOAuthStatus() {
return {
google: {
configured: this.googleOAuthService.isConfigured(),
},
microsoft: {
configured: this.microsoftOAuthService.isConfigured(),
},
};
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { OAuthController } from './oauth.controller';
import { GoogleOAuthService } from './google-oauth.service';
import { MicrosoftOAuthService } from './microsoft-oauth.service';
import { AccountModule } from '../account/account.module';
@Module({
imports: [AccountModule],
controllers: [OAuthController],
providers: [GoogleOAuthService, MicrosoftOAuthService],
exports: [GoogleOAuthService, MicrosoftOAuthService],
})
export class OAuthModule {}

View file

@ -0,0 +1,113 @@
import { type EmailAccount, type Email, type Folder } from '../../db/schema';
export interface SyncState {
lastSyncAt?: Date;
lastMessageId?: string;
historyId?: string; // Gmail specific
deltaLink?: string; // Outlook specific
uidValidity?: number; // IMAP specific
highestModSeq?: string; // IMAP specific
}
export interface SyncResult {
success: boolean;
newEmails: number;
updatedEmails: number;
deletedEmails: number;
newFolders: number;
error?: string;
newSyncState: SyncState;
}
export interface FetchedEmail {
messageId: string;
externalId?: string;
subject?: string;
fromAddress?: string;
fromName?: string;
toAddresses: { email: string; name?: string }[];
ccAddresses?: { email: string; name?: string }[];
bccAddresses?: { email: string; name?: string }[];
snippet?: string;
bodyPlain?: string;
bodyHtml?: string;
sentAt?: Date;
receivedAt?: Date;
isRead: boolean;
isStarred: boolean;
hasAttachments: boolean;
threadId?: string;
inReplyTo?: string;
references?: string[];
headers?: Record<string, string>;
attachments?: {
filename: string;
mimeType: string;
size: number;
contentId?: string;
content?: Buffer;
}[];
}
export interface FetchedFolder {
name: string;
path: string;
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
delimiter?: string;
flags?: string[];
}
export interface EmailProvider {
/**
* Connect to the email provider
*/
connect(account: EmailAccount, password?: string): Promise<void>;
/**
* Disconnect from the email provider
*/
disconnect(): Promise<void>;
/**
* Sync folders from the provider
*/
syncFolders(account: EmailAccount): Promise<FetchedFolder[]>;
/**
* Perform delta sync to get new/updated/deleted emails
*/
sync(account: EmailAccount, state: SyncState): Promise<SyncResult>;
/**
* Fetch a single email by ID
*/
fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null>;
/**
* Fetch emails from a folder
*/
fetchEmails(
account: EmailAccount,
folderPath: string,
options?: { limit?: number; since?: Date }
): Promise<FetchedEmail[]>;
/**
* Update email flags (read, starred)
*/
updateFlags(
account: EmailAccount,
externalId: string,
flags: { isRead?: boolean; isStarred?: boolean }
): Promise<void>;
/**
* Move email to a different folder
*/
moveEmail(account: EmailAccount, externalId: string, targetFolderPath: string): Promise<void>;
/**
* Delete an email
*/
deleteEmail(account: EmailAccount, externalId: string): Promise<void>;
}

View file

@ -0,0 +1,307 @@
import { Injectable, Logger } from '@nestjs/common';
import { google, gmail_v1 } from 'googleapis';
import {
type EmailProvider,
type SyncState,
type SyncResult,
type FetchedEmail,
type FetchedFolder,
} from '../interfaces/email-provider.interface';
import { type EmailAccount } from '../../db/schema';
@Injectable()
export class GmailProvider implements EmailProvider {
private readonly logger = new Logger(GmailProvider.name);
private gmail: gmail_v1.Gmail | null = null;
async connect(account: EmailAccount): Promise<void> {
if (!account.accessToken) {
throw new Error('Gmail access token not configured');
}
const auth = new google.auth.OAuth2();
auth.setCredentials({ access_token: account.accessToken });
this.gmail = google.gmail({ version: 'v1', auth });
}
async disconnect(): Promise<void> {
this.gmail = null;
}
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
if (!this.gmail) throw new Error('Not connected');
const folders: FetchedFolder[] = [];
const response = await this.gmail.users.labels.list({ userId: 'me' });
for (const label of response.data.labels || []) {
folders.push({
name: label.name || '',
path: label.id || '',
type: this.mapLabelType(label.id || ''),
});
}
return folders;
}
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
if (!this.gmail) throw new Error('Not connected');
const result: SyncResult = {
success: true,
newEmails: 0,
updatedEmails: 0,
deletedEmails: 0,
newFolders: 0,
newSyncState: { ...state },
};
try {
// Use Gmail History API for incremental sync
if (state.historyId) {
const historyResponse = await this.gmail.users.history.list({
userId: 'me',
startHistoryId: state.historyId,
});
const history = historyResponse.data.history || [];
for (const record of history) {
result.newEmails += record.messagesAdded?.length || 0;
result.deletedEmails += record.messagesDeleted?.length || 0;
}
result.newSyncState.historyId = historyResponse.data.historyId || state.historyId;
} else {
// Initial sync - fetch recent messages
const messagesResponse = await this.gmail.users.messages.list({
userId: 'me',
maxResults: 100,
q: 'in:inbox',
});
result.newEmails = messagesResponse.data.messages?.length || 0;
// Get current history ID for future syncs
const profile = await this.gmail.users.getProfile({ userId: 'me' });
result.newSyncState.historyId = profile.data.historyId || undefined;
}
result.newSyncState.lastSyncAt = new Date();
} catch (error) {
result.success = false;
result.error = error instanceof Error ? error.message : 'Sync failed';
}
return result;
}
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
if (!this.gmail) throw new Error('Not connected');
try {
const response = await this.gmail.users.messages.get({
userId: 'me',
id: externalId,
format: 'full',
});
return this.parseGmailMessage(response.data);
} catch (error) {
this.logger.error(`Failed to fetch email ${externalId}:`, error);
return null;
}
}
async fetchEmails(
account: EmailAccount,
folderPath: string,
options?: { limit?: number; since?: Date }
): Promise<FetchedEmail[]> {
if (!this.gmail) throw new Error('Not connected');
const emails: FetchedEmail[] = [];
const limit = options?.limit || 50;
// Build query
let query = `in:${folderPath === 'INBOX' ? 'inbox' : folderPath}`;
if (options?.since) {
const dateStr = options.since.toISOString().split('T')[0];
query += ` after:${dateStr}`;
}
const listResponse = await this.gmail.users.messages.list({
userId: 'me',
maxResults: limit,
q: query,
});
for (const message of listResponse.data.messages || []) {
if (message.id) {
const email = await this.fetchEmail(account, message.id);
if (email) emails.push(email);
}
}
return emails;
}
async updateFlags(
account: EmailAccount,
externalId: string,
flags: { isRead?: boolean; isStarred?: boolean }
): Promise<void> {
if (!this.gmail) throw new Error('Not connected');
const addLabels: string[] = [];
const removeLabels: string[] = [];
if (flags.isRead !== undefined) {
if (flags.isRead) {
removeLabels.push('UNREAD');
} else {
addLabels.push('UNREAD');
}
}
if (flags.isStarred !== undefined) {
if (flags.isStarred) {
addLabels.push('STARRED');
} else {
removeLabels.push('STARRED');
}
}
if (addLabels.length > 0 || removeLabels.length > 0) {
await this.gmail.users.messages.modify({
userId: 'me',
id: externalId,
requestBody: {
addLabelIds: addLabels.length > 0 ? addLabels : undefined,
removeLabelIds: removeLabels.length > 0 ? removeLabels : undefined,
},
});
}
}
async moveEmail(
account: EmailAccount,
externalId: string,
targetFolderPath: string
): Promise<void> {
if (!this.gmail) throw new Error('Not connected');
// In Gmail, moving is done by modifying labels
const targetLabel = this.pathToLabelId(targetFolderPath);
await this.gmail.users.messages.modify({
userId: 'me',
id: externalId,
requestBody: {
addLabelIds: [targetLabel],
removeLabelIds: ['INBOX'],
},
});
}
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
if (!this.gmail) throw new Error('Not connected');
// Move to trash (or permanently delete)
await this.gmail.users.messages.trash({
userId: 'me',
id: externalId,
});
}
private mapLabelType(labelId: string): FetchedFolder['type'] {
const labelMap: Record<string, FetchedFolder['type']> = {
INBOX: 'inbox',
SENT: 'sent',
DRAFT: 'drafts',
TRASH: 'trash',
SPAM: 'spam',
};
return labelMap[labelId] || 'custom';
}
private pathToLabelId(path: string): string {
const pathMap: Record<string, string> = {
inbox: 'INBOX',
sent: 'SENT',
drafts: 'DRAFT',
trash: 'TRASH',
spam: 'SPAM',
};
return pathMap[path.toLowerCase()] || path;
}
private parseGmailMessage(message: gmail_v1.Schema$Message): FetchedEmail {
const headers = message.payload?.headers || [];
const getHeader = (name: string) =>
headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value;
const labels = message.labelIds || [];
// Extract body
let bodyPlain = '';
let bodyHtml = '';
const extractBody = (part: gmail_v1.Schema$MessagePart) => {
if (part.mimeType === 'text/plain' && part.body?.data) {
bodyPlain = Buffer.from(part.body.data, 'base64').toString('utf-8');
}
if (part.mimeType === 'text/html' && part.body?.data) {
bodyHtml = Buffer.from(part.body.data, 'base64').toString('utf-8');
}
for (const subPart of part.parts || []) {
extractBody(subPart);
}
};
if (message.payload) {
extractBody(message.payload);
}
// Parse from header
const fromHeader = getHeader('From') || '';
const fromMatch = fromHeader.match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
const fromName = fromMatch?.[1]?.trim();
const fromAddress = fromMatch?.[2] || fromHeader;
// Parse to/cc addresses
const parseAddresses = (header: string | undefined): { email: string; name?: string }[] => {
if (!header) return [];
return header.split(',').map((addr) => {
const match = addr.trim().match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
return {
email: match?.[2] || addr.trim(),
name: match?.[1]?.trim(),
};
});
};
return {
messageId: getHeader('Message-ID') || message.id || '',
externalId: message.id ?? undefined,
threadId: message.threadId ?? undefined,
subject: getHeader('Subject') ?? undefined,
fromAddress,
fromName,
toAddresses: parseAddresses(getHeader('To') ?? undefined),
ccAddresses: parseAddresses(getHeader('Cc') ?? undefined),
snippet: message.snippet ?? undefined,
bodyPlain: bodyPlain ?? undefined,
bodyHtml: bodyHtml ?? undefined,
sentAt: getHeader('Date') ? new Date(getHeader('Date')!) : undefined,
receivedAt: message.internalDate ? new Date(parseInt(message.internalDate, 10)) : undefined,
isRead: !labels.includes('UNREAD'),
isStarred: labels.includes('STARRED'),
hasAttachments:
message.payload?.parts?.some((p) => p.filename && p.filename.length > 0) || false,
inReplyTo: getHeader('In-Reply-To') ?? undefined,
references: getHeader('References')?.split(/\s+/),
};
}
}

View file

@ -0,0 +1,304 @@
import { Injectable, Logger } from '@nestjs/common';
import { ImapFlow, type MailboxObject, type FetchMessageObject, type ListResponse } from 'imapflow';
import { simpleParser, type ParsedMail, type AddressObject, type Attachment } from 'mailparser';
import {
type EmailProvider,
type SyncState,
type SyncResult,
type FetchedEmail,
type FetchedFolder,
} from '../interfaces/email-provider.interface';
import { type EmailAccount } from '../../db/schema';
@Injectable()
export class ImapProvider implements EmailProvider {
private readonly logger = new Logger(ImapProvider.name);
private client: ImapFlow | null = null;
async connect(account: EmailAccount, password?: string): Promise<void> {
if (!account.imapHost || !account.imapPort) {
throw new Error('IMAP settings not configured');
}
this.client = new ImapFlow({
host: account.imapHost,
port: account.imapPort,
secure: account.imapSecurity === 'ssl',
auth: {
user: account.email,
pass: password || '',
},
logger: false,
});
await this.client.connect();
}
async disconnect(): Promise<void> {
if (this.client) {
await this.client.logout();
this.client = null;
}
}
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
if (!this.client) throw new Error('Not connected');
const folders: FetchedFolder[] = [];
const mailboxes = await this.client.list();
for (const mailbox of mailboxes) {
folders.push({
name: mailbox.name,
path: mailbox.path,
type: this.mapFolderType(mailbox),
delimiter: mailbox.delimiter,
flags: mailbox.flags ? Array.from(mailbox.flags) : [],
});
}
return folders;
}
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
if (!this.client) throw new Error('Not connected');
const result: SyncResult = {
success: true,
newEmails: 0,
updatedEmails: 0,
deletedEmails: 0,
newFolders: 0,
newSyncState: { ...state },
};
try {
// Open INBOX
const mailbox = await this.client.mailboxOpen('INBOX');
// Use UIDVALIDITY and HIGHESTMODSEQ for incremental sync
const currentUidValidity = Number(mailbox.uidValidity);
const currentModSeq = mailbox.highestModseq?.toString();
// If UIDVALIDITY changed, we need full resync
if (state.uidValidity && state.uidValidity !== currentUidValidity) {
this.logger.warn('UIDVALIDITY changed, full resync required');
// Full resync would be handled separately
}
// Fetch new messages since last sync
const since = state.lastSyncAt || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // Default: 30 days
const messages = await this.fetchEmailsInternal('INBOX', { since, limit: 100 });
result.newEmails = messages.length;
result.newSyncState = {
lastSyncAt: new Date(),
uidValidity: currentUidValidity,
highestModSeq: currentModSeq,
};
} catch (error) {
result.success = false;
result.error = error instanceof Error ? error.message : 'Sync failed';
}
return result;
}
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
if (!this.client) throw new Error('Not connected');
try {
const mailbox = await this.client.mailboxOpen('INBOX');
const uid = parseInt(externalId, 10);
for await (const message of this.client.fetch(uid, { source: true }, { uid: true })) {
const parsed = await simpleParser(message.source);
return this.parseEmail(message, parsed);
}
} catch (error) {
this.logger.error(`Failed to fetch email ${externalId}:`, error);
}
return null;
}
async fetchEmails(
account: EmailAccount,
folderPath: string,
options?: { limit?: number; since?: Date }
): Promise<FetchedEmail[]> {
if (!this.client) throw new Error('Not connected');
return this.fetchEmailsInternal(folderPath, options);
}
private async fetchEmailsInternal(
folderPath: string,
options?: { limit?: number; since?: Date }
): Promise<FetchedEmail[]> {
if (!this.client) throw new Error('Not connected');
const emails: FetchedEmail[] = [];
const limit = options?.limit || 50;
await this.client.mailboxOpen(folderPath);
// Build search criteria
const searchCriteria: any = {};
if (options?.since) {
searchCriteria.since = options.since;
}
const searchResults = await this.client.search(searchCriteria, { uid: true });
if (!searchResults || searchResults.length === 0) return emails;
const uidsToFetch = searchResults.slice(-limit); // Get most recent
for await (const message of this.client.fetch(
uidsToFetch,
{ source: true, flags: true },
{ uid: true }
)) {
try {
const parsed = await simpleParser(message.source);
const email = this.parseEmail(message, parsed);
emails.push(email);
} catch (error) {
this.logger.error(`Failed to parse email UID ${message.uid}:`, error);
}
}
return emails;
}
async updateFlags(
account: EmailAccount,
externalId: string,
flags: { isRead?: boolean; isStarred?: boolean }
): Promise<void> {
if (!this.client) throw new Error('Not connected');
const uid = parseInt(externalId, 10);
await this.client.mailboxOpen('INBOX');
if (flags.isRead !== undefined) {
if (flags.isRead) {
await this.client.messageFlagsAdd(uid, ['\\Seen'], { uid: true });
} else {
await this.client.messageFlagsRemove(uid, ['\\Seen'], { uid: true });
}
}
if (flags.isStarred !== undefined) {
if (flags.isStarred) {
await this.client.messageFlagsAdd(uid, ['\\Flagged'], { uid: true });
} else {
await this.client.messageFlagsRemove(uid, ['\\Flagged'], { uid: true });
}
}
}
async moveEmail(
account: EmailAccount,
externalId: string,
targetFolderPath: string
): Promise<void> {
if (!this.client) throw new Error('Not connected');
const uid = parseInt(externalId, 10);
await this.client.mailboxOpen('INBOX');
await this.client.messageMove(uid, targetFolderPath, { uid: true });
}
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
if (!this.client) throw new Error('Not connected');
const uid = parseInt(externalId, 10);
await this.client.mailboxOpen('INBOX');
await this.client.messageDelete(uid, { uid: true });
}
private mapFolderType(mailbox: ListResponse): FetchedFolder['type'] {
const specialUse = mailbox.specialUse;
const nameLower = mailbox.path.toLowerCase();
if (specialUse === '\\Inbox' || nameLower === 'inbox') return 'inbox';
if (specialUse === '\\Sent' || nameLower.includes('sent')) return 'sent';
if (specialUse === '\\Drafts' || nameLower.includes('draft')) return 'drafts';
if (specialUse === '\\Trash' || nameLower.includes('trash') || nameLower.includes('deleted'))
return 'trash';
if (specialUse === '\\Junk' || nameLower.includes('spam') || nameLower.includes('junk'))
return 'spam';
if (specialUse === '\\Archive' || nameLower.includes('archive')) return 'archive';
return 'custom';
}
private parseEmail(message: FetchMessageObject, parsed: ParsedMail): FetchedEmail {
const flags = message.flags || new Set();
return {
messageId: parsed.messageId || `${message.uid}`,
externalId: message.uid?.toString(),
subject: parsed.subject,
fromAddress: this.extractEmail(parsed.from),
fromName: this.extractName(parsed.from),
toAddresses: this.extractAddresses(parsed.to),
ccAddresses: this.extractAddresses(parsed.cc),
snippet: parsed.text?.substring(0, 200),
bodyPlain: parsed.text,
bodyHtml: parsed.html || undefined,
sentAt: parsed.date,
receivedAt: parsed.date,
isRead: flags.has('\\Seen'),
isStarred: flags.has('\\Flagged'),
hasAttachments: (parsed.attachments?.length || 0) > 0,
inReplyTo: parsed.inReplyTo,
references: parsed.references
? Array.isArray(parsed.references)
? parsed.references
: [parsed.references]
: [],
attachments: parsed.attachments?.map((att: Attachment) => ({
filename: att.filename || 'attachment',
mimeType: att.contentType,
size: att.size,
contentId: att.contentId,
content: att.content,
})),
};
}
private extractEmail(address: AddressObject | undefined): string | undefined {
if (!address?.value?.[0]) return undefined;
return address.value[0].address;
}
private extractName(address: AddressObject | undefined): string | undefined {
if (!address?.value?.[0]) return undefined;
return address.value[0].name;
}
private extractAddresses(
address: AddressObject | AddressObject[] | undefined
): { email: string; name?: string }[] {
if (!address) return [];
const addresses = Array.isArray(address) ? address : [address];
const result: { email: string; name?: string }[] = [];
for (const addr of addresses) {
for (const val of addr.value || []) {
if (val.address) {
result.push({
email: val.address,
name: val.name,
});
}
}
}
return result;
}
}

View file

@ -0,0 +1,242 @@
import { Injectable, Logger } from '@nestjs/common';
import { Client } from '@microsoft/microsoft-graph-client';
import {
type EmailProvider,
type SyncState,
type SyncResult,
type FetchedEmail,
type FetchedFolder,
} from '../interfaces/email-provider.interface';
import { type EmailAccount } from '../../db/schema';
interface GraphMessage {
id: string;
conversationId?: string;
subject?: string;
from?: { emailAddress: { address: string; name?: string } };
toRecipients?: { emailAddress: { address: string; name?: string } }[];
ccRecipients?: { emailAddress: { address: string; name?: string } }[];
bodyPreview?: string;
body?: { content: string; contentType: string };
sentDateTime?: string;
receivedDateTime?: string;
isRead?: boolean;
flag?: { flagStatus: string };
hasAttachments?: boolean;
internetMessageId?: string;
parentFolderId?: string;
}
interface GraphMailFolder {
id: string;
displayName: string;
parentFolderId?: string;
wellKnownName?: string;
}
@Injectable()
export class OutlookProvider implements EmailProvider {
private readonly logger = new Logger(OutlookProvider.name);
private client: Client | null = null;
async connect(account: EmailAccount): Promise<void> {
if (!account.accessToken) {
throw new Error('Outlook access token not configured');
}
this.client = Client.init({
authProvider: (done) => {
done(null, account.accessToken!);
},
});
}
async disconnect(): Promise<void> {
this.client = null;
}
async syncFolders(account: EmailAccount): Promise<FetchedFolder[]> {
if (!this.client) throw new Error('Not connected');
const folders: FetchedFolder[] = [];
const response = await this.client.api('/me/mailFolders').get();
for (const folder of response.value as GraphMailFolder[]) {
folders.push({
name: folder.displayName,
path: folder.id,
type: this.mapFolderType(folder.wellKnownName),
});
}
return folders;
}
async sync(account: EmailAccount, state: SyncState): Promise<SyncResult> {
if (!this.client) throw new Error('Not connected');
const result: SyncResult = {
success: true,
newEmails: 0,
updatedEmails: 0,
deletedEmails: 0,
newFolders: 0,
newSyncState: { ...state },
};
try {
// Use delta query for incremental sync
let deltaUrl = state.deltaLink || '/me/mailFolders/inbox/messages/delta';
const response = await this.client.api(deltaUrl).get();
result.newEmails = (response.value as GraphMessage[]).length;
// Save delta link for next sync
if (response['@odata.deltaLink']) {
result.newSyncState.deltaLink = response['@odata.deltaLink'];
}
result.newSyncState.lastSyncAt = new Date();
} catch (error) {
result.success = false;
result.error = error instanceof Error ? error.message : 'Sync failed';
}
return result;
}
async fetchEmail(account: EmailAccount, externalId: string): Promise<FetchedEmail | null> {
if (!this.client) throw new Error('Not connected');
try {
const response = await this.client
.api(`/me/messages/${externalId}`)
.select(
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
)
.get();
return this.parseOutlookMessage(response);
} catch (error) {
this.logger.error(`Failed to fetch email ${externalId}:`, error);
return null;
}
}
async fetchEmails(
account: EmailAccount,
folderPath: string,
options?: { limit?: number; since?: Date }
): Promise<FetchedEmail[]> {
if (!this.client) throw new Error('Not connected');
const emails: FetchedEmail[] = [];
const limit = options?.limit || 50;
let request = this.client
.api(`/me/mailFolders/${folderPath}/messages`)
.top(limit)
.select(
'id,conversationId,subject,from,toRecipients,ccRecipients,bodyPreview,body,sentDateTime,receivedDateTime,isRead,flag,hasAttachments,internetMessageId'
);
if (options?.since) {
request = request.filter(`receivedDateTime ge ${options.since.toISOString()}`);
}
const response = await request.get();
for (const message of response.value as GraphMessage[]) {
emails.push(this.parseOutlookMessage(message));
}
return emails;
}
async updateFlags(
account: EmailAccount,
externalId: string,
flags: { isRead?: boolean; isStarred?: boolean }
): Promise<void> {
if (!this.client) throw new Error('Not connected');
const update: Record<string, any> = {};
if (flags.isRead !== undefined) {
update.isRead = flags.isRead;
}
if (flags.isStarred !== undefined) {
update.flag = {
flagStatus: flags.isStarred ? 'flagged' : 'notFlagged',
};
}
if (Object.keys(update).length > 0) {
await this.client.api(`/me/messages/${externalId}`).patch(update);
}
}
async moveEmail(
account: EmailAccount,
externalId: string,
targetFolderPath: string
): Promise<void> {
if (!this.client) throw new Error('Not connected');
await this.client.api(`/me/messages/${externalId}/move`).post({
destinationId: targetFolderPath,
});
}
async deleteEmail(account: EmailAccount, externalId: string): Promise<void> {
if (!this.client) throw new Error('Not connected');
// Move to deleted items
await this.client.api(`/me/messages/${externalId}/move`).post({
destinationId: 'deleteditems',
});
}
private mapFolderType(wellKnownName?: string): FetchedFolder['type'] {
const folderMap: Record<string, FetchedFolder['type']> = {
inbox: 'inbox',
sentitems: 'sent',
drafts: 'drafts',
deleteditems: 'trash',
junkemail: 'spam',
archive: 'archive',
};
return folderMap[wellKnownName?.toLowerCase() || ''] || 'custom';
}
private parseOutlookMessage(message: GraphMessage): FetchedEmail {
return {
messageId: message.internetMessageId || message.id,
externalId: message.id,
threadId: message.conversationId,
subject: message.subject,
fromAddress: message.from?.emailAddress.address,
fromName: message.from?.emailAddress.name,
toAddresses:
message.toRecipients?.map((r) => ({
email: r.emailAddress.address,
name: r.emailAddress.name,
})) || [],
ccAddresses:
message.ccRecipients?.map((r) => ({
email: r.emailAddress.address,
name: r.emailAddress.name,
})) || [],
snippet: message.bodyPreview,
bodyPlain: message.body?.contentType === 'text' ? message.body.content : undefined,
bodyHtml: message.body?.contentType === 'html' ? message.body.content : undefined,
sentAt: message.sentDateTime ? new Date(message.sentDateTime) : undefined,
receivedAt: message.receivedDateTime ? new Date(message.receivedDateTime) : undefined,
isRead: message.isRead ?? false,
isStarred: message.flag?.flagStatus === 'flagged',
hasAttachments: message.hasAttachments ?? false,
};
}
}

View file

@ -0,0 +1,38 @@
import { Controller, Post, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { SyncService } from './sync.service';
@Controller('sync')
@UseGuards(JwtAuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
@Post('accounts/:accountId')
async syncAccount(
@CurrentUser() user: CurrentUserData,
@Param('accountId', ParseUUIDPipe) accountId: string
) {
const result = await this.syncService.syncAccount(accountId, user.userId);
return result;
}
@Post('accounts/:accountId/folders/:folderId')
async syncFolder(
@CurrentUser() user: CurrentUserData,
@Param('accountId', ParseUUIDPipe) accountId: string,
@Param('folderId', ParseUUIDPipe) folderId: string
) {
const result = await this.syncService.syncFolder(accountId, user.userId, folderId);
return result;
}
@Post('emails/:emailId/fetch')
async fetchFullEmail(
@CurrentUser() user: CurrentUserData,
@Param('emailId', ParseUUIDPipe) emailId: string
) {
// Get the email to find its account
await this.syncService.fetchFullEmail('', user.userId, emailId);
return { success: true };
}
}

View file

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
import { ImapProvider } from './providers/imap.provider';
import { GmailProvider } from './providers/gmail.provider';
import { OutlookProvider } from './providers/outlook.provider';
import { AccountModule } from '../account/account.module';
import { AttachmentModule } from '../attachment/attachment.module';
@Module({
imports: [AccountModule, AttachmentModule],
controllers: [SyncController],
providers: [SyncService, ImapProvider, GmailProvider, OutlookProvider],
exports: [SyncService],
})
export class SyncModule {}

View file

@ -0,0 +1,425 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { eq, and, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
emailAccounts,
emails,
folders,
type EmailAccount,
type NewEmail,
type NewFolder,
} from '../db/schema';
import { AccountService } from '../account/account.service';
import { AttachmentService } from '../attachment/attachment.service';
import { ImapProvider } from './providers/imap.provider';
import { GmailProvider } from './providers/gmail.provider';
import { OutlookProvider } from './providers/outlook.provider';
import {
type EmailProvider,
type SyncState,
type SyncResult,
type FetchedEmail,
type FetchedFolder,
} from './interfaces/email-provider.interface';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
private syncInProgress = new Set<string>();
constructor(
@Inject(DATABASE_CONNECTION) private db: Database,
private accountService: AccountService,
private attachmentService: AttachmentService,
private imapProvider: ImapProvider,
private gmailProvider: GmailProvider,
private outlookProvider: OutlookProvider
) {}
// Run sync every 5 minutes
@Cron(CronExpression.EVERY_5_MINUTES)
async scheduledSync() {
this.logger.log('Starting scheduled sync');
const accounts = await this.db
.select()
.from(emailAccounts)
.where(eq(emailAccounts.syncEnabled, true));
for (const account of accounts) {
try {
await this.syncAccount(account.id, account.userId);
} catch (error) {
this.logger.error(`Scheduled sync failed for account ${account.id}:`, error);
}
}
}
async syncAccount(accountId: string, userId: string): Promise<SyncResult> {
// Prevent concurrent syncs for the same account
if (this.syncInProgress.has(accountId)) {
return {
success: false,
newEmails: 0,
updatedEmails: 0,
deletedEmails: 0,
newFolders: 0,
error: 'Sync already in progress',
newSyncState: {},
};
}
this.syncInProgress.add(accountId);
try {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
// Sync folders first
const fetchedFolders = await provider.syncFolders(account);
await this.saveFolders(account, fetchedFolders);
// Sync emails
const syncState: SyncState = (account.syncState as SyncState) || {};
const result = await provider.sync(account, syncState);
// Update account sync state
await this.db
.update(emailAccounts)
.set({
syncState: result.newSyncState,
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(emailAccounts.id, accountId));
return result;
} finally {
await provider.disconnect();
}
} catch (error) {
this.logger.error(`Sync failed for account ${accountId}:`, error);
return {
success: false,
newEmails: 0,
updatedEmails: 0,
deletedEmails: 0,
newFolders: 0,
error: error instanceof Error ? error.message : 'Sync failed',
newSyncState: {},
};
} finally {
this.syncInProgress.delete(accountId);
}
}
async syncFolder(
accountId: string,
userId: string,
folderId: string
): Promise<{ emails: number }> {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const [folder] = await this.db
.select()
.from(folders)
.where(and(eq(folders.id, folderId), eq(folders.userId, userId)));
if (!folder) {
throw new Error('Folder not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
const fetchedEmails = await provider.fetchEmails(account, folder.path, { limit: 50 });
await this.saveEmails(account, folder.id, fetchedEmails);
return { emails: fetchedEmails.length };
} finally {
await provider.disconnect();
}
}
async fetchFullEmail(accountId: string, userId: string, emailId: string): Promise<void> {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
if (!email || !email.externalId) {
throw new Error('Email not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
const fullEmail = await provider.fetchEmail(account, email.externalId);
if (fullEmail) {
// Update email with full body
await this.db
.update(emails)
.set({
bodyPlain: fullEmail.bodyPlain,
bodyHtml: fullEmail.bodyHtml,
updatedAt: new Date(),
})
.where(eq(emails.id, emailId));
// Save attachments
if (fullEmail.attachments) {
for (const att of fullEmail.attachments) {
if (att.content) {
await this.attachmentService.uploadDirect(userId, emailId, {
filename: att.filename,
mimeType: att.mimeType,
content: att.content,
});
}
}
}
}
} finally {
await provider.disconnect();
}
}
async updateEmailFlags(
accountId: string,
userId: string,
emailId: string,
flags: { isRead?: boolean; isStarred?: boolean }
): Promise<void> {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
if (!email || !email.externalId) {
throw new Error('Email not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
await provider.updateFlags(account, email.externalId, flags);
} finally {
await provider.disconnect();
}
}
async moveEmail(
accountId: string,
userId: string,
emailId: string,
targetFolderId: string
): Promise<void> {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
if (!email || !email.externalId) {
throw new Error('Email not found');
}
const [targetFolder] = await this.db
.select()
.from(folders)
.where(and(eq(folders.id, targetFolderId), eq(folders.userId, userId)));
if (!targetFolder) {
throw new Error('Target folder not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
await provider.moveEmail(account, email.externalId, targetFolder.path);
} finally {
await provider.disconnect();
}
}
async deleteEmail(accountId: string, userId: string, emailId: string): Promise<void> {
const account = await this.accountService.findById(accountId, userId);
if (!account) {
throw new Error('Account not found');
}
const [email] = await this.db
.select()
.from(emails)
.where(and(eq(emails.id, emailId), eq(emails.userId, userId)));
if (!email || !email.externalId) {
throw new Error('Email not found');
}
const provider = this.getProvider(account.provider);
const password =
account.provider === 'imap'
? await this.accountService.getDecryptedPassword(accountId, userId)
: undefined;
await provider.connect(account, password || undefined);
try {
await provider.deleteEmail(account, email.externalId);
} finally {
await provider.disconnect();
}
}
private getProvider(providerType: string): EmailProvider {
switch (providerType) {
case 'imap':
return this.imapProvider;
case 'gmail':
return this.gmailProvider;
case 'outlook':
return this.outlookProvider;
default:
throw new Error(`Unknown provider type: ${providerType}`);
}
}
private async saveFolders(account: EmailAccount, fetchedFolders: FetchedFolder[]): Promise<void> {
for (const fetched of fetchedFolders) {
// Check if folder exists
const [existing] = await this.db
.select()
.from(folders)
.where(and(eq(folders.accountId, account.id), eq(folders.path, fetched.path)));
if (!existing) {
await this.db.insert(folders).values({
accountId: account.id,
userId: account.userId,
name: fetched.name,
type: fetched.type,
path: fetched.path,
isSystem: ['inbox', 'sent', 'drafts', 'trash', 'spam'].includes(fetched.type),
});
}
}
}
private async saveEmails(
account: EmailAccount,
folderId: string,
fetchedEmails: FetchedEmail[]
): Promise<void> {
for (const fetched of fetchedEmails) {
// Check if email exists by messageId
const [existing] = await this.db
.select()
.from(emails)
.where(and(eq(emails.accountId, account.id), eq(emails.messageId, fetched.messageId)));
if (!existing) {
await this.db.insert(emails).values({
accountId: account.id,
folderId,
userId: account.userId,
messageId: fetched.messageId,
externalId: fetched.externalId,
threadId: fetched.threadId
? await this.getOrCreateThreadId(account.id, fetched.threadId)
: null,
subject: fetched.subject,
fromAddress: fetched.fromAddress,
fromName: fetched.fromName,
toAddresses: fetched.toAddresses,
ccAddresses: fetched.ccAddresses,
snippet: fetched.snippet,
bodyPlain: fetched.bodyPlain,
bodyHtml: fetched.bodyHtml,
sentAt: fetched.sentAt,
receivedAt: fetched.receivedAt,
isRead: fetched.isRead,
isStarred: fetched.isStarred,
hasAttachments: fetched.hasAttachments,
});
} else {
// Update existing email flags
await this.db
.update(emails)
.set({
isRead: fetched.isRead,
isStarred: fetched.isStarred,
updatedAt: new Date(),
})
.where(eq(emails.id, existing.id));
}
}
}
private async getOrCreateThreadId(accountId: string, externalThreadId: string): Promise<string> {
// Find existing email with same external thread ID
const [existingEmail] = await this.db
.select()
.from(emails)
.where(eq(emails.accountId, accountId))
.limit(1);
// For simplicity, we generate a new UUID for each thread
// In a real implementation, you'd want to track thread IDs properly
return externalThreadId;
}
}

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://mail.manacore.app',
integrations: [tailwind(), sitemap()],
});

View file

@ -0,0 +1,26 @@
{
"name": "@mail/landing",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"type-check": "astro check"
},
"dependencies": {
"@astrojs/check": "^0.9.0",
"@astrojs/sitemap": "^3.2.1",
"@manacore/shared-landing-ui": "workspace:*",
"astro": "^5.16.0",
"typescript": "^5.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^6.0.0",
"@tailwindcss/typography": "^0.5.16",
"tailwindcss": "^3.4.17"
}
}

View file

@ -0,0 +1,95 @@
---
const footerLinks = {
product: [
{ href: '#features', label: 'Features' },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
],
legal: [
{ href: '/privacy', label: 'Datenschutz' },
{ href: '/terms', label: 'AGB' },
{ href: '/imprint', label: 'Impressum' },
],
};
const currentYear = new Date().getFullYear();
---
<footer class="bg-background-card border-t border-border">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<!-- Brand -->
<div class="col-span-1 md:col-span-2">
<a href="/" class="flex items-center gap-2 mb-4">
<svg
class="w-8 h-8 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
></path>
</svg>
<span class="font-bold text-xl text-text-primary">ManaMail</span>
</a>
<p class="text-text-secondary text-sm max-w-md">
Dein intelligenter E-Mail-Client. Verwalte alle deine E-Mail-Konten an einem Ort - mit
KI-Unterstützung für intelligente Zusammenfassungen, Smart Replies und automatische
Kategorisierung.
</p>
</div>
<!-- Product Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
<ul class="space-y-2">
{
footerLinks.product.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
<!-- Legal Links -->
<div>
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
<ul class="space-y-2">
{
footerLinks.legal.map((link) => (
<li>
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
>
{link.label}
</a>
</li>
))
}
</ul>
</div>
</div>
<!-- Bottom -->
<div
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
>
<p class="text-text-muted text-sm">
&copy; {currentYear} ManaMail. Alle Rechte vorbehalten.
</p>
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
</div>
</div>
</footer>

View file

@ -0,0 +1,101 @@
---
const navLinks = [
{ href: '#features', label: 'Features' },
{ href: '#how-it-works', label: "So funktioniert's" },
{ href: '#pricing', label: 'Preise' },
{ href: '#faq', label: 'FAQ' },
];
---
<nav
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<a href="/" class="flex items-center gap-2">
<svg
class="w-8 h-8 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
></path>
</svg>
<span class="font-bold text-xl text-text-primary">ManaMail</span>
</a>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center gap-8">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
>
{link.label}
</a>
))
}
</div>
<!-- CTA Button -->
<div class="flex items-center gap-4">
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
<!-- Mobile Menu Button -->
<button
type="button"
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
aria-label="Menu"
id="mobile-menu-button"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<div class="hidden md:hidden" id="mobile-menu">
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
{
navLinks.map((link) => (
<a
href={link.href}
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
>
{link.label}
</a>
))
}
</div>
</div>
</nav>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu?.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
mobileMenu?.classList.add('hidden');
});
});
</script>

View file

@ -0,0 +1,50 @@
---
import '../styles/global.css';
interface Props {
title: string;
description?: string;
}
const {
title,
description = 'ManaMail - Dein intelligenter E-Mail-Client mit KI-Unterstützung für alle deine Konten',
} = Astro.props;
---
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<meta name="generator" content={Astro.generator} />
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Open Graph -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<title>{title}</title>
</head>
<body class="min-h-screen bg-background-page text-text-primary antialiased">
<slot />
</body>
</html>

View file

@ -0,0 +1,278 @@
---
import Layout from '../layouts/Layout.astro';
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
// Shared components
import HeroSection from '@manacore/shared-landing-ui/sections/HeroSection.astro';
import FeatureSection from '@manacore/shared-landing-ui/sections/FeatureSection.astro';
import StepsSection from '@manacore/shared-landing-ui/sections/StepsSection.astro';
import FAQSection from '@manacore/shared-landing-ui/sections/FAQSection.astro';
import CTASection from '@manacore/shared-landing-ui/sections/CTASection.astro';
import PricingSection from '@manacore/shared-landing-ui/sections/PricingSection.astro';
// Feature data
const features = [
{
icon: '📬',
title: 'Alle Konten an einem Ort',
description:
'Verbinde Gmail, Outlook, IMAP und mehr. Verwalte alle deine E-Mail-Konten in einer einzigen, eleganten App.',
},
{
icon: '🤖',
title: 'KI-Zusammenfassungen',
description:
'Erhalte sofortige Zusammenfassungen langer E-Mails. Die KI extrahiert die wichtigsten Informationen und Aktionspunkte.',
},
{
icon: '💡',
title: 'Smart Replies',
description:
'Lass dir intelligente Antwortvorschläge generieren. Die KI analysiert den Kontext und schlägt passende Antworten vor.',
},
{
icon: '🏷️',
title: 'Auto-Kategorisierung',
description:
'E-Mails werden automatisch kategorisiert: Arbeit, Persönlich, Newsletter, Transaktionen und Werbung.',
},
{
icon: '📱',
title: 'Plattformübergreifend',
description:
'Nutze ManaMail auf iOS, Android und im Web. Deine E-Mails sind überall verfügbar und synchronisiert.',
},
{
icon: '🔒',
title: 'Sicher & Privat',
description:
'Deine Passwörter werden verschlüsselt gespeichert. Volle DSGVO-Konformität und keine Datenweitergabe.',
},
];
// Steps data
const steps = [
{
number: '1',
title: 'App herunterladen',
description: 'Lade ManaMail kostenlos im App Store oder Google Play Store herunter.',
image: '/screenshots/download.png',
},
{
number: '2',
title: 'E-Mail-Konten verbinden',
description: 'Füge deine E-Mail-Konten hinzu - Gmail, Outlook oder beliebige IMAP-Server.',
image: '/screenshots/accounts.png',
},
{
number: '3',
title: 'KI-Features aktivieren',
description:
'Aktiviere Zusammenfassungen, Smart Replies und Auto-Kategorisierung nach deinen Wünschen.',
image: '/screenshots/ai.png',
},
];
// Pricing data
const pricingPlans = [
{
name: 'Free',
price: '0',
period: '/Monat',
description: 'Perfekt zum Ausprobieren',
features: [
{ text: '2 E-Mail-Konten', included: true },
{ text: 'Unbegrenzte E-Mails', included: true },
{ text: 'Push-Benachrichtigungen', included: true },
{ text: 'Basis-Ordner & Labels', included: true },
{ text: 'KI-Zusammenfassungen', included: false },
{ text: 'Smart Replies', included: false },
],
cta: {
text: 'Kostenlos starten',
href: '#download',
},
},
{
name: 'Pro',
price: '4,99',
period: '/Monat',
description: 'Alle KI-Features inklusive',
features: [
{ text: 'Unbegrenzte E-Mail-Konten', included: true },
{ text: 'KI-Zusammenfassungen', included: true },
{ text: 'Smart Replies', included: true },
{ text: 'Auto-Kategorisierung', included: true },
{ text: 'Prioritäts-Erkennung', included: true },
{ text: 'Premium-Support', included: true },
],
cta: {
text: 'Pro werden',
href: '#download',
},
highlighted: true,
badge: 'Beliebt',
},
{
name: 'Team',
price: '9,99',
period: '/Nutzer/Monat',
description: 'Für Teams und Unternehmen',
features: [
{ text: 'Alles aus Pro', included: true },
{ text: 'Team-Verwaltung', included: true },
{ text: 'Geteilte Postfächer', included: true },
{ text: 'Admin-Dashboard', included: true },
{ text: 'SSO-Integration', included: true },
{ text: 'Dedizierter Support', included: true },
],
cta: {
text: 'Team starten',
href: '#download',
},
},
];
// FAQ data
const faqs = [
{
question: 'Welche E-Mail-Anbieter werden unterstützt?',
answer:
'ManaMail unterstützt Gmail, Outlook/Office 365, Yahoo Mail und alle E-Mail-Anbieter mit IMAP/SMTP-Unterstützung. Du kannst beliebig viele Konten hinzufügen.',
},
{
question: 'Wie funktionieren die KI-Zusammenfassungen?',
answer:
'Unsere KI analysiert lange E-Mails und extrahiert die wichtigsten Informationen. Du siehst auf einen Blick, worum es geht, welche Aktionen erforderlich sind und welche Deadlines es gibt.',
},
{
question: 'Sind meine E-Mails und Passwörter sicher?',
answer:
'Absolut. Deine E-Mail-Passwörter werden mit Ende-zu-Ende-Verschlüsselung gespeichert. Wir haben keinen Zugriff auf deine Anmeldedaten. Für Gmail und Outlook nutzen wir sichere OAuth-Authentifizierung.',
},
{
question: 'Was sind Smart Replies?',
answer:
'Smart Replies sind KI-generierte Antwortvorschläge basierend auf dem E-Mail-Inhalt. Die KI schlägt passende Antworten in verschiedenen Tonalitäten vor - von formell bis locker.',
},
{
question: 'Kann ich ManaMail offline nutzen?',
answer:
'Ja, deine E-Mails werden lokal zwischengespeichert. Du kannst offline lesen und Antworten verfassen. Sobald du wieder online bist, werden alle Änderungen synchronisiert.',
},
{
question: 'Wie funktioniert die Auto-Kategorisierung?',
answer:
'Die KI analysiert eingehende E-Mails und kategorisiert sie automatisch: Arbeit, Persönlich, Newsletter, Transaktionen (Rechnungen, Bestellbestätigungen) und Werbung. Du kannst die Kategorien natürlich anpassen.',
},
];
---
<Layout title="ManaMail - Dein intelligenter E-Mail-Client mit KI-Unterstützung">
<Navigation />
<main class="pt-16">
<HeroSection
title="Alle deine E-Mails. Eine App. KI-Power."
subtitle="ManaMail vereint all deine E-Mail-Konten in einer eleganten App. Mit KI-Zusammenfassungen, Smart Replies und automatischer Kategorisierung sparst du jeden Tag Zeit."
variant="default"
primaryCta={{
text: 'Jetzt kostenlos starten',
href: '#download',
}}
secondaryCta={{
text: 'Features entdecken',
href: '#features',
variant: 'secondary',
}}
trustBadges={[
{ icon: '✓', text: 'Kostenlos testen' },
{ icon: '🔒', text: 'Ende-zu-Ende verschlüsselt' },
{ icon: '📱', text: 'iOS, Android & Web' },
]}
/>
<FeatureSection
id="features"
title="E-Mail-Management der nächsten Generation"
subtitle="ManaMail kombiniert klassische E-Mail-Funktionen mit modernster KI-Technologie für maximale Produktivität."
features={features}
columns={3}
variant="cards"
class="bg-[var(--color-background-card)]"
/>
<StepsSection
id="how-it-works"
title="In 3 Schritten loslegen"
subtitle="So einfach startest du mit ManaMail"
steps={steps}
showImages={false}
alternateLayout={true}
/>
<PricingSection
id="pricing"
title="Wähle deinen Plan"
subtitle="Starte kostenlos und upgrade, wenn du KI-Features brauchst"
plans={pricingPlans}
class="bg-[var(--color-background-card)]"
/>
<FAQSection
id="faq"
title="Häufig gestellte Fragen"
subtitle="Alles was du über ManaMail wissen musst"
faqs={faqs}
/>
<CTASection
id="download"
title="Bereit für smartere E-Mails?"
subtitle="Lade ManaMail jetzt herunter und erlebe E-Mail-Management mit KI-Unterstützung. Kostenlos und ohne Kreditkarte."
primaryCta={{ text: 'App herunterladen', href: '#' }}
variant="highlighted"
>
<!-- App Store Buttons -->
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
</a>
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
</a>
</div>
<!-- Trust Indicators -->
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clip-rule="evenodd"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Ende-zu-Ende verschlüsselt</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
</svg>
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
</div>
</div>
</CTASection>
</main>
<Footer />
</Layout>

View file

@ -0,0 +1,103 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ManaMail Theme CSS Variables - Indigo Blue */
:root {
/* Primary colors - Mail Indigo */
--color-primary: #6366f1;
--color-primary-hover: #818cf8;
--color-primary-glow: rgba(99, 102, 241, 0.3);
/* Text colors */
--color-text-primary: #f9fafb;
--color-text-secondary: #d1d5db;
--color-text-muted: #6b7280;
/* Background colors */
--color-background-page: #0c1425;
--color-background-card: #131c2e;
--color-background-card-hover: #1e2942;
/* Border colors */
--color-border: #1e2942;
--color-border-hover: #2d3a56;
}
/* Base styles */
html {
scroll-behavior: smooth;
}
body {
font-family: 'Inter', system-ui, sans-serif;
background-color: var(--color-background-page);
color: var(--color-text-primary);
line-height: 1.6;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-background-card);
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-hover);
}
/* Selection */
::selection {
background-color: var(--color-primary);
color: white;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Gradient text */
.text-gradient {
background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Animation utilities */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.6s ease-out forwards;
}
/* Button styles */
.btn-primary {
@apply inline-flex items-center justify-center px-6 py-3 bg-primary text-white font-semibold rounded-lg transition-all duration-200;
@apply hover:bg-primary-hover hover:shadow-lg hover:shadow-primary-glow;
}
.btn-secondary {
@apply inline-flex items-center justify-center px-6 py-3 border border-border text-text-primary font-semibold rounded-lg transition-all duration-200;
@apply hover:border-border-hover hover:bg-background-card;
}

View file

@ -0,0 +1,37 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'../../packages/shared-landing-ui/src/**/*.{astro,html,js,jsx,ts,tsx}',
],
theme: {
extend: {
colors: {
// ManaMail Indigo Theme
primary: {
DEFAULT: '#6366f1',
hover: '#818cf8',
glow: 'rgba(99, 102, 241, 0.3)',
},
background: {
page: '#0c1425',
card: '#131c2e',
'card-hover': '#1e2942',
},
text: {
primary: '#f9fafb',
secondary: '#d1d5db',
muted: '#6b7280',
},
border: {
DEFAULT: '#1e2942',
hover: '#2d3a56',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [require('@tailwindcss/typography')],
};

View file

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,6 @@
# Cloudflare Pages configuration for Mail Landing
# Deployed via GitHub Actions (Direct Upload)
name = "mail-landing"
compatibility_date = "2024-12-01"
pages_build_output_dir = "dist"

View file

@ -0,0 +1,58 @@
{
"expo": {
"name": "Mail",
"slug": "mail",
"version": "1.0.0",
"scheme": "mail",
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-web-browser",
"expo-font",
"expo-secure-store",
[
"expo-document-picker",
{
"iCloudContainerEnvironment": "Production"
}
]
],
"experiments": {
"typedRoutes": true,
"tsconfigPaths": true
},
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.manacore.mail",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.manacore.mail"
},
"extra": {
"router": {},
"eas": {
"projectId": ""
}
}
}
}

View file

@ -0,0 +1,207 @@
import { Drawer } from 'expo-router/drawer';
import { useTheme } from '@react-navigation/native';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
import { DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer';
function CustomDrawerContent(props: any) {
const { colors } = useTheme();
const router = useRouter();
const { user, signOut } = useAuth();
const { folders, selectedFolderId, selectFolder } = useEmailsStore();
const handleLogout = async () => {
await signOut();
router.replace('/auth/login');
};
return (
<View style={{ flex: 1 }}>
<DrawerContentScrollView {...props}>
{/* User info */}
<View style={[styles.userSection, { borderBottomColor: colors.border }]}>
<View style={[styles.avatar, { backgroundColor: colors.primary }]}>
<Text style={styles.avatarText}>{user?.email?.charAt(0).toUpperCase() || 'U'}</Text>
</View>
<Text style={[styles.userEmail, { color: colors.text }]} numberOfLines={1}>
{user?.email || 'User'}
</Text>
</View>
{/* Main navigation */}
<DrawerItemList {...props} />
{/* Folders section */}
{folders.length > 0 && (
<View style={styles.foldersSection}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>FOLDERS</Text>
{folders
.filter((f) => f.type === 'custom')
.map((folder) => (
<TouchableOpacity
key={folder.id}
style={[
styles.folderItem,
selectedFolderId === folder.id && { backgroundColor: colors.primary + '20' },
]}
onPress={() => {
selectFolder(folder.id);
router.push('/');
}}
>
<Ionicons name="folder-outline" size={20} color={colors.text} />
<Text style={[styles.folderName, { color: colors.text }]}>{folder.name}</Text>
{folder.unreadCount > 0 && (
<View style={[styles.badge, { backgroundColor: colors.primary }]}>
<Text style={styles.badgeText}>{folder.unreadCount}</Text>
</View>
)}
</TouchableOpacity>
))}
</View>
)}
</DrawerContentScrollView>
{/* Bottom actions */}
<View style={[styles.bottomSection, { borderTopColor: colors.border }]}>
<TouchableOpacity style={styles.bottomItem} onPress={() => router.push('/settings')}>
<Ionicons name="settings-outline" size={22} color={colors.text} />
<Text style={[styles.bottomItemText, { color: colors.text }]}>Settings</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.bottomItem} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={22} color="#ef4444" />
<Text style={[styles.bottomItemText, { color: '#ef4444' }]}>Sign Out</Text>
</TouchableOpacity>
</View>
</View>
);
}
export default function DrawerLayout() {
const { colors } = useTheme();
return (
<Drawer
drawerContent={(props) => <CustomDrawerContent {...props} />}
screenOptions={{
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
drawerStyle: { backgroundColor: colors.card },
drawerActiveTintColor: colors.primary,
drawerInactiveTintColor: colors.text,
}}
>
<Drawer.Screen
name="index"
options={{
title: 'Inbox',
drawerIcon: ({ color, size }) => <Ionicons name="mail" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="sent"
options={{
title: 'Sent',
drawerIcon: ({ color, size }) => <Ionicons name="send" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="drafts"
options={{
title: 'Drafts',
drawerIcon: ({ color, size }) => (
<Ionicons name="document-text" size={size} color={color} />
),
}}
/>
<Drawer.Screen
name="starred"
options={{
title: 'Starred',
drawerIcon: ({ color, size }) => <Ionicons name="star" size={size} color={color} />,
}}
/>
<Drawer.Screen
name="trash"
options={{
title: 'Trash',
drawerIcon: ({ color, size }) => <Ionicons name="trash" size={size} color={color} />,
}}
/>
</Drawer>
);
}
const styles = StyleSheet.create({
userSection: {
padding: 16,
borderBottomWidth: 1,
marginBottom: 8,
},
avatar: {
width: 50,
height: 50,
borderRadius: 25,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 8,
},
avatarText: {
color: '#fff',
fontSize: 20,
fontWeight: 'bold',
},
userEmail: {
fontSize: 14,
},
foldersSection: {
paddingTop: 16,
paddingHorizontal: 8,
},
sectionTitle: {
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 16,
marginBottom: 8,
},
folderItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
marginBottom: 4,
},
folderName: {
marginLeft: 12,
flex: 1,
fontSize: 15,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
},
badgeText: {
color: '#fff',
fontSize: 12,
fontWeight: '600',
},
bottomSection: {
borderTopWidth: 1,
paddingVertical: 8,
},
bottomItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 20,
},
bottomItemText: {
marginLeft: 12,
fontSize: 15,
},
});

View file

@ -0,0 +1,184 @@
import React, { useEffect } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useComposeStore } from '~/store/composeStore';
import { useEmailsStore } from '~/store/emailsStore';
export default function DraftsScreen() {
const { colors } = useTheme();
const router = useRouter();
const { getToken } = useAuth();
const { selectedAccountId } = useEmailsStore();
const { drafts, loading, fetchDrafts, openDraft, deleteDraft } = useComposeStore();
useEffect(() => {
const loadDrafts = async () => {
const token = await getToken();
if (token) {
await fetchDrafts(selectedAccountId || undefined, token);
}
};
loadDrafts();
}, [selectedAccountId]);
const handleOpenDraft = (draft: any) => {
openDraft(draft);
router.push('/compose');
};
const handleDeleteDraft = async (draftId: string) => {
Alert.alert('Delete Draft', 'Are you sure you want to delete this draft?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const token = await getToken();
if (token) {
await deleteDraft(draftId, token);
}
},
},
]);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const renderDraft = ({ item }: { item: any }) => (
<TouchableOpacity
style={[styles.draftItem, { backgroundColor: colors.card }]}
onPress={() => handleOpenDraft(item)}
onLongPress={() => handleDeleteDraft(item.id)}
>
<View style={styles.draftContent}>
<View style={styles.draftHeader}>
<Text style={[styles.toAddress, { color: colors.text }]} numberOfLines={1}>
{item.toAddresses?.length > 0
? `To: ${item.toAddresses[0].name || item.toAddresses[0].email}`
: 'Draft'}
</Text>
<Text style={[styles.date, { color: colors.text + '80' }]}>
{formatDate(item.updatedAt)}
</Text>
</View>
<Text style={[styles.subject, { color: colors.text }]} numberOfLines={1}>
{item.subject || '(No Subject)'}
</Text>
</View>
<TouchableOpacity style={styles.deleteButton} onPress={() => handleDeleteDraft(item.id)}>
<Ionicons name="trash-outline" size={20} color="#ef4444" />
</TouchableOpacity>
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={drafts}
renderItem={renderDraft}
keyExtractor={(item) => item.id}
ListEmptyComponent={
loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<View style={styles.emptyList}>
<Ionicons name="document-text-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyListText, { color: colors.text + '80' }]}>No drafts</Text>
</View>
)
}
/>
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={() => router.push('/compose')}
>
<Ionicons name="create" size={24} color="#fff" />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
paddingVertical: 40,
},
draftItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
draftContent: {
flex: 1,
},
draftHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
toAddress: {
fontSize: 15,
fontWeight: '500',
flex: 1,
marginRight: 8,
},
date: {
fontSize: 12,
},
subject: {
fontSize: 14,
},
deleteButton: {
padding: 8,
},
emptyList: {
alignItems: 'center',
paddingVertical: 60,
},
emptyListText: {
marginTop: 12,
fontSize: 15,
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
});

View file

@ -0,0 +1,362 @@
import React, { useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
import { useAppTheme } from '~/theme/ThemeProvider';
export default function InboxScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { getToken } = useAuth();
const {
accounts,
selectedAccountId,
folders,
emails,
loading,
syncing,
hasMore,
fetchAccounts,
fetchFolders,
fetchEmails,
syncAccount,
markAsRead,
toggleStar,
} = useEmailsStore();
// Initialize data
useEffect(() => {
const loadData = async () => {
const token = await getToken();
if (!token) return;
await fetchAccounts(token);
};
loadData();
}, []);
// Fetch folders when account changes
useEffect(() => {
const loadFolders = async () => {
if (!selectedAccountId) return;
const token = await getToken();
if (!token) return;
await fetchFolders(selectedAccountId, token);
};
loadFolders();
}, [selectedAccountId]);
// Fetch emails when folders loaded
useEffect(() => {
const loadEmails = async () => {
if (!folders.length) return;
const token = await getToken();
if (!token) return;
await fetchEmails(token, true);
};
loadEmails();
}, [folders]);
const handleRefresh = useCallback(async () => {
if (!selectedAccountId) return;
const token = await getToken();
if (!token) return;
await syncAccount(selectedAccountId, token);
}, [selectedAccountId]);
const handleLoadMore = useCallback(async () => {
if (loading || !hasMore) return;
const token = await getToken();
if (!token) return;
await fetchEmails(token);
}, [loading, hasMore]);
const handleEmailPress = async (emailId: string, isRead: boolean) => {
if (!isRead) {
const token = await getToken();
if (token) {
markAsRead(emailId, token);
}
}
router.push(`/email/${emailId}`);
};
const handleStarPress = async (emailId: string) => {
const token = await getToken();
if (token) {
toggleStar(emailId, token);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
const renderEmail = ({ item }: { item: any }) => (
<TouchableOpacity
style={[
styles.emailItem,
{ backgroundColor: colors.card },
!item.isRead && { backgroundColor: isDarkMode ? '#1a1a2e' : '#f0f4ff' },
]}
onPress={() => handleEmailPress(item.id, item.isRead)}
>
<View style={styles.emailLeft}>
<View style={[styles.avatar, { backgroundColor: colors.primary + '30' }]}>
<Text style={[styles.avatarText, { color: colors.primary }]}>
{(item.fromName || item.fromAddress)?.charAt(0).toUpperCase()}
</Text>
</View>
</View>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text
style={[styles.fromName, { color: colors.text }, !item.isRead && styles.unread]}
numberOfLines={1}
>
{item.fromName || item.fromAddress}
</Text>
<Text style={[styles.date, { color: colors.text + '80' }]}>
{formatDate(item.receivedAt || item.sentAt)}
</Text>
</View>
<Text
style={[styles.subject, { color: colors.text }, !item.isRead && styles.unread]}
numberOfLines={1}
>
{item.subject || '(No Subject)'}
</Text>
<Text style={[styles.snippet, { color: colors.text + '80' }]} numberOfLines={1}>
{item.snippet || ''}
</Text>
</View>
<TouchableOpacity style={styles.starButton} onPress={() => handleStarPress(item.id)}>
<Ionicons
name={item.isStarred ? 'star' : 'star-outline'}
size={20}
color={item.isStarred ? '#f59e0b' : colors.text + '60'}
/>
</TouchableOpacity>
</TouchableOpacity>
);
if (!accounts.length && loading) {
return (
<View style={[styles.centerContainer, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: colors.text }]}>Loading...</Text>
</View>
);
}
if (!accounts.length) {
return (
<View style={[styles.centerContainer, { backgroundColor: colors.background }]}>
<Ionicons name="mail-outline" size={64} color={colors.text + '40'} />
<Text style={[styles.emptyTitle, { color: colors.text }]}>No Email Accounts</Text>
<Text style={[styles.emptyText, { color: colors.text + '80' }]}>
Add an email account to get started
</Text>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.primary }]}
onPress={() => router.push('/accounts')}
>
<Text style={styles.addButtonText}>Add Account</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={emails}
renderItem={renderEmail}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
ListEmptyComponent={
loading ? (
<View style={styles.listLoader}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<View style={styles.emptyList}>
<Ionicons name="mail-open-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyListText, { color: colors.text + '80' }]}>
No emails yet
</Text>
</View>
)
}
ListFooterComponent={
loading && emails.length > 0 ? (
<View style={styles.footer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
) : null
}
/>
{/* FAB for compose */}
<TouchableOpacity
style={[styles.fab, { backgroundColor: colors.primary }]}
onPress={() => router.push('/compose')}
>
<Ionicons name="create" size={24} color="#fff" />
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
emptyTitle: {
fontSize: 20,
fontWeight: '600',
marginTop: 16,
},
emptyText: {
fontSize: 15,
marginTop: 8,
textAlign: 'center',
},
addButton: {
marginTop: 24,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
addButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
emailItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
emailLeft: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
fontWeight: '600',
},
emailContent: {
flex: 1,
},
emailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
},
fromName: {
fontSize: 15,
flex: 1,
marginRight: 8,
},
date: {
fontSize: 12,
},
subject: {
fontSize: 14,
marginBottom: 2,
},
snippet: {
fontSize: 13,
},
unread: {
fontWeight: '600',
},
starButton: {
padding: 8,
},
listLoader: {
paddingVertical: 40,
},
emptyList: {
alignItems: 'center',
paddingVertical: 60,
},
emptyListText: {
marginTop: 12,
fontSize: 15,
},
footer: {
paddingVertical: 16,
},
fab: {
position: 'absolute',
right: 16,
bottom: 16,
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
});

View file

@ -0,0 +1,168 @@
import React, { useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
export default function SentScreen() {
const { colors } = useTheme();
const router = useRouter();
const { getToken } = useAuth();
const {
folders,
emails,
loading,
syncing,
selectFolder,
fetchEmails,
syncAccount,
selectedAccountId,
} = useEmailsStore();
useEffect(() => {
const loadSent = async () => {
const sentFolder = folders.find((f) => f.type === 'sent');
if (sentFolder) {
selectFolder(sentFolder.id);
const token = await getToken();
if (token) {
await fetchEmails(token, true);
}
}
};
loadSent();
}, [folders]);
const handleRefresh = useCallback(async () => {
if (!selectedAccountId) return;
const token = await getToken();
if (token) {
await syncAccount(selectedAccountId, token);
}
}, [selectedAccountId]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
const renderEmail = ({ item }: { item: any }) => (
<TouchableOpacity
style={[styles.emailItem, { backgroundColor: colors.card }]}
onPress={() => router.push(`/email/${item.id}`)}
>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text style={[styles.toName, { color: colors.text }]} numberOfLines={1}>
To: {item.toAddresses?.[0]?.name || item.toAddresses?.[0]?.email || 'Unknown'}
</Text>
<Text style={[styles.date, { color: colors.text + '80' }]}>
{formatDate(item.sentAt)}
</Text>
</View>
<Text style={[styles.subject, { color: colors.text }]} numberOfLines={1}>
{item.subject || '(No Subject)'}
</Text>
<Text style={[styles.snippet, { color: colors.text + '80' }]} numberOfLines={1}>
{item.snippet || ''}
</Text>
</View>
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={emails}
renderItem={renderEmail}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<View style={styles.emptyList}>
<Ionicons name="send-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyListText, { color: colors.text + '80' }]}>
No sent emails
</Text>
</View>
)
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
paddingVertical: 40,
},
emailItem: {
padding: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
emailContent: {
flex: 1,
},
emailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
},
toName: {
fontSize: 15,
flex: 1,
marginRight: 8,
},
date: {
fontSize: 12,
},
subject: {
fontSize: 14,
marginBottom: 2,
},
snippet: {
fontSize: 13,
},
emptyList: {
alignItems: 'center',
paddingVertical: 60,
},
emptyListText: {
marginTop: 12,
fontSize: 15,
},
});

View file

@ -0,0 +1,187 @@
import React, { useEffect, useCallback, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { emailsApi } from '~/utils/api';
export default function StarredScreen() {
const { colors } = useTheme();
const router = useRouter();
const { getToken } = useAuth();
const [emails, setEmails] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchStarredEmails = async () => {
const token = await getToken();
if (!token) return;
const result = await emailsApi.list({ limit: 100 }, token);
if (result.data?.emails) {
// Filter starred emails client-side
setEmails(result.data.emails.filter((e: any) => e.isStarred));
}
setLoading(false);
setRefreshing(false);
};
useEffect(() => {
fetchStarredEmails();
}, []);
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchStarredEmails();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: 'short' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
};
const renderEmail = ({ item }: { item: any }) => (
<TouchableOpacity
style={[styles.emailItem, { backgroundColor: colors.card }]}
onPress={() => router.push(`/email/${item.id}`)}
>
<View style={styles.emailLeft}>
<View style={[styles.avatar, { backgroundColor: colors.primary + '30' }]}>
<Text style={[styles.avatarText, { color: colors.primary }]}>
{(item.fromName || item.fromAddress)?.charAt(0).toUpperCase()}
</Text>
</View>
</View>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text style={[styles.fromName, { color: colors.text }]} numberOfLines={1}>
{item.fromName || item.fromAddress}
</Text>
<Text style={[styles.date, { color: colors.text + '80' }]}>
{formatDate(item.receivedAt || item.sentAt)}
</Text>
</View>
<Text style={[styles.subject, { color: colors.text }]} numberOfLines={1}>
{item.subject || '(No Subject)'}
</Text>
<Text style={[styles.snippet, { color: colors.text + '80' }]} numberOfLines={1}>
{item.snippet || ''}
</Text>
</View>
<Ionicons name="star" size={20} color="#f59e0b" />
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={emails}
renderItem={renderEmail}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<View style={styles.emptyList}>
<Ionicons name="star-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyListText, { color: colors.text + '80' }]}>
No starred emails
</Text>
</View>
)
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
paddingVertical: 40,
},
emailItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
emailLeft: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
fontWeight: '600',
},
emailContent: {
flex: 1,
},
emailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 2,
},
fromName: {
fontSize: 15,
flex: 1,
marginRight: 8,
},
date: {
fontSize: 12,
},
subject: {
fontSize: 14,
marginBottom: 2,
},
snippet: {
fontSize: 13,
},
emptyList: {
alignItems: 'center',
paddingVertical: 60,
},
emptyListText: {
marginTop: 12,
fontSize: 15,
},
});

View file

@ -0,0 +1,183 @@
import React, { useEffect, useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
RefreshControl,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
export default function TrashScreen() {
const { colors } = useTheme();
const router = useRouter();
const { getToken } = useAuth();
const {
folders,
emails,
loading,
syncing,
selectFolder,
fetchEmails,
deleteEmail,
selectedAccountId,
syncAccount,
} = useEmailsStore();
useEffect(() => {
const loadTrash = async () => {
const trashFolder = folders.find((f) => f.type === 'trash');
if (trashFolder) {
selectFolder(trashFolder.id);
const token = await getToken();
if (token) {
await fetchEmails(token, true);
}
}
};
loadTrash();
}, [folders]);
const handleRefresh = useCallback(async () => {
if (!selectedAccountId) return;
const token = await getToken();
if (token) {
await syncAccount(selectedAccountId, token);
}
}, [selectedAccountId]);
const handlePermanentDelete = async (emailId: string) => {
Alert.alert(
'Permanently Delete',
'This email will be permanently deleted. This action cannot be undone.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const token = await getToken();
if (token) {
await deleteEmail(emailId, token);
}
},
},
]
);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const renderEmail = ({ item }: { item: any }) => (
<TouchableOpacity
style={[styles.emailItem, { backgroundColor: colors.card }]}
onPress={() => router.push(`/email/${item.id}`)}
onLongPress={() => handlePermanentDelete(item.id)}
>
<View style={styles.emailContent}>
<View style={styles.emailHeader}>
<Text style={[styles.fromName, { color: colors.text }]} numberOfLines={1}>
{item.fromName || item.fromAddress}
</Text>
<Text style={[styles.date, { color: colors.text + '80' }]}>
{formatDate(item.receivedAt || item.sentAt)}
</Text>
</View>
<Text style={[styles.subject, { color: colors.text }]} numberOfLines={1}>
{item.subject || '(No Subject)'}
</Text>
</View>
<TouchableOpacity style={styles.deleteButton} onPress={() => handlePermanentDelete(item.id)}>
<Ionicons name="close-circle" size={22} color="#ef4444" />
</TouchableOpacity>
</TouchableOpacity>
);
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<FlatList
data={emails}
renderItem={renderEmail}
keyExtractor={(item) => item.id}
refreshControl={
<RefreshControl
refreshing={syncing}
onRefresh={handleRefresh}
tintColor={colors.primary}
/>
}
ListEmptyComponent={
loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : (
<View style={styles.emptyList}>
<Ionicons name="trash-outline" size={48} color={colors.text + '40'} />
<Text style={[styles.emptyListText, { color: colors.text + '80' }]}>
Trash is empty
</Text>
</View>
)
}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
paddingVertical: 40,
},
emailItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
emailContent: {
flex: 1,
},
emailHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
fromName: {
fontSize: 15,
flex: 1,
marginRight: 8,
},
date: {
fontSize: 12,
},
subject: {
fontSize: 14,
},
deleteButton: {
padding: 8,
},
emptyList: {
alignItems: 'center',
paddingVertical: 60,
},
emptyListText: {
marginTop: 12,
fontSize: 15,
},
});

View file

@ -0,0 +1,73 @@
import '../global.css';
import { Stack, useRouter, useSegments } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider as NavigationThemeProvider } from '@react-navigation/native';
import { useAppTheme, ThemeProvider } from '../theme/ThemeProvider';
import { AuthProvider, useAuth } from '../context/AuthProvider';
import { useEffect } from 'react';
export const unstable_settings = {
initialRouteName: '(drawer)',
};
function Layout() {
const { theme } = useAppTheme();
return (
<NavigationThemeProvider value={theme}>
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack>
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="auth/register" options={{ headerShown: false }} />
<Stack.Screen name="auth/reset-password" options={{ headerShown: false }} />
<Stack.Screen name="email/[id]" options={{ headerShown: false }} />
<Stack.Screen
name="compose"
options={{
headerShown: false,
presentation: 'modal',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="accounts" options={{ headerShown: false }} />
</Stack>
</GestureHandlerRootView>
</NavigationThemeProvider>
);
}
// Auth guard component
function AuthGuard({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const segments = useSegments();
const router = useRouter();
useEffect(() => {
if (loading) return;
const inAuthGroup = segments[0] === 'auth';
if (!user && !inAuthGroup) {
router.replace('/auth/login');
} else if (user && inAuthGroup) {
router.replace('/');
}
}, [user, loading, segments]);
return <>{children}</>;
}
export default function RootLayout() {
return (
<ThemeProvider>
<AuthProvider>
<AuthGuard>
<Layout />
</AuthGuard>
</AuthProvider>
</ThemeProvider>
);
}

View file

@ -0,0 +1,415 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
ActivityIndicator,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
import { useAppTheme } from '~/theme/ThemeProvider';
import { accountsApi } from '~/utils/api';
export default function AccountsScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { getToken } = useAuth();
const { accounts, fetchAccounts, selectAccount } = useEmailsStore();
const [showAddForm, setShowAddForm] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
imapHost: '',
imapPort: '993',
smtpHost: '',
smtpPort: '587',
password: '',
});
const handleAddAccount = async () => {
if (!formData.email || !formData.password || !formData.imapHost) {
Alert.alert('Error', 'Please fill in all required fields');
return;
}
setLoading(true);
const token = await getToken();
if (!token) {
setLoading(false);
return;
}
const result = await accountsApi.create(
{
name: formData.name || formData.email.split('@')[0],
email: formData.email,
provider: 'imap',
imapHost: formData.imapHost,
imapPort: parseInt(formData.imapPort, 10),
smtpHost: formData.smtpHost || formData.imapHost.replace('imap', 'smtp'),
smtpPort: parseInt(formData.smtpPort, 10),
password: formData.password,
},
token
);
setLoading(false);
if (result.error) {
Alert.alert('Error', result.error.message);
return;
}
await fetchAccounts(token);
setShowAddForm(false);
setFormData({
name: '',
email: '',
imapHost: '',
imapPort: '993',
smtpHost: '',
smtpPort: '587',
password: '',
});
Alert.alert('Success', 'Account added successfully');
};
const handleDeleteAccount = async (id: string) => {
Alert.alert('Delete Account', 'Are you sure you want to remove this email account?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const token = await getToken();
if (!token) return;
const result = await accountsApi.delete(id, token);
if (result.error) {
Alert.alert('Error', result.error.message);
return;
}
await fetchAccounts(token);
},
},
]);
};
const handleSetDefault = async (id: string) => {
const token = await getToken();
if (!token) return;
const result = await accountsApi.setDefault(id, token);
if (result.error) {
Alert.alert('Error', result.error.message);
return;
}
selectAccount(id);
await fetchAccounts(token);
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: 'Email Accounts',
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
}}
/>
<ScrollView>
{/* Existing Accounts */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>YOUR ACCOUNTS</Text>
{accounts.map((account) => (
<View key={account.id} style={[styles.accountItem, { backgroundColor: colors.card }]}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Ionicons
name={
account.provider === 'gmail'
? 'logo-google'
: account.provider === 'outlook'
? 'logo-microsoft'
: 'mail'
}
size={24}
color={colors.primary}
/>
</View>
<View style={styles.accountInfo}>
<Text style={[styles.accountName, { color: colors.text }]}>{account.name}</Text>
<Text style={[styles.accountEmail, { color: colors.text + '80' }]}>
{account.email}
</Text>
<Text style={[styles.accountProvider, { color: colors.text + '60' }]}>
{account.provider.toUpperCase()} {account.syncEnabled ? 'Syncing' : 'Paused'}
</Text>
</View>
<View style={styles.accountActions}>
{!account.isDefault && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => handleSetDefault(account.id)}
>
<Ionicons name="star-outline" size={22} color={colors.text + '80'} />
</TouchableOpacity>
)}
{account.isDefault && <Ionicons name="star" size={22} color="#f59e0b" />}
<TouchableOpacity
style={styles.actionButton}
onPress={() => handleDeleteAccount(account.id)}
>
<Ionicons name="trash-outline" size={22} color="#ef4444" />
</TouchableOpacity>
</View>
</View>
))}
</View>
{/* Add Account Form */}
{showAddForm ? (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>
ADD IMAP ACCOUNT
</Text>
<View style={[styles.formContainer, { backgroundColor: colors.card }]}>
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { color: colors.text }]}>Display Name</Text>
<TextInput
style={[
styles.input,
{
color: colors.text,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
placeholder="My Email"
placeholderTextColor={colors.text + '60'}
value={formData.name}
onChangeText={(text) => setFormData({ ...formData, name: text })}
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { color: colors.text }]}>Email Address *</Text>
<TextInput
style={[
styles.input,
{
color: colors.text,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
placeholder="you@example.com"
placeholderTextColor={colors.text + '60'}
value={formData.email}
onChangeText={(text) => setFormData({ ...formData, email: text })}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { color: colors.text }]}>IMAP Host *</Text>
<TextInput
style={[
styles.input,
{
color: colors.text,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
placeholder="imap.example.com"
placeholderTextColor={colors.text + '60'}
value={formData.imapHost}
onChangeText={(text) => setFormData({ ...formData, imapHost: text })}
autoCapitalize="none"
/>
</View>
<View style={styles.inputGroup}>
<Text style={[styles.inputLabel, { color: colors.text }]}>Password *</Text>
<TextInput
style={[
styles.input,
{
color: colors.text,
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
placeholder="Password or App Password"
placeholderTextColor={colors.text + '60'}
value={formData.password}
onChangeText={(text) => setFormData({ ...formData, password: text })}
secureTextEntry
/>
</View>
<View style={styles.formActions}>
<TouchableOpacity
style={[styles.cancelButton, { borderColor: colors.border }]}
onPress={() => setShowAddForm(false)}
>
<Text style={[styles.cancelButtonText, { color: colors.text }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.submitButton, { backgroundColor: colors.primary }]}
onPress={handleAddAccount}
disabled={loading}
>
{loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.submitButtonText}>Add Account</Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
) : (
<View style={styles.section}>
<TouchableOpacity
style={[styles.addButton, { backgroundColor: colors.card }]}
onPress={() => setShowAddForm(true)}
>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Ionicons name="add" size={24} color={colors.primary} />
</View>
<Text style={[styles.addButtonText, { color: colors.primary }]}>
Add IMAP Account
</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
section: {
marginTop: 24,
},
sectionTitle: {
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 16,
marginBottom: 8,
},
accountItem: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
accountInfo: {
flex: 1,
},
accountName: {
fontSize: 16,
fontWeight: '600',
},
accountEmail: {
fontSize: 14,
marginTop: 2,
},
accountProvider: {
fontSize: 12,
marginTop: 4,
},
accountActions: {
flexDirection: 'row',
},
actionButton: {
padding: 8,
},
addButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
marginHorizontal: 16,
borderRadius: 12,
},
addButtonText: {
fontSize: 16,
fontWeight: '500',
},
formContainer: {
margin: 16,
padding: 16,
borderRadius: 12,
},
inputGroup: {
marginBottom: 16,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
formActions: {
flexDirection: 'row',
marginTop: 8,
},
cancelButton: {
flex: 1,
paddingVertical: 12,
borderWidth: 1,
borderRadius: 8,
alignItems: 'center',
marginRight: 8,
},
cancelButtonText: {
fontSize: 16,
fontWeight: '500',
},
submitButton: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
marginLeft: 8,
},
submitButtonText: {
color: '#fff',
fontSize: 16,
fontWeight: '500',
},
});

View file

@ -0,0 +1,11 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
<Stack.Screen name="register" />
<Stack.Screen name="reset-password" />
</Stack>
);
}

View file

@ -0,0 +1,255 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Link } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useAppTheme } from '~/theme/ThemeProvider';
export default function LoginScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleLogin = async () => {
if (!email || !password) {
Alert.alert('Error', 'Please enter your email and password.');
return;
}
try {
setLoading(true);
const { error } = await signIn(email, password);
if (error) {
Alert.alert('Login Failed', error.message || 'Unknown error');
} else {
router.replace('/');
}
} catch (error) {
console.error('Login error:', error);
Alert.alert('Error', 'An error occurred during login. Please try again.');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={[styles.container, { backgroundColor: colors.background }]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary }]}>
<Ionicons name="mail" size={40} color="#fff" />
</View>
<Text style={[styles.title, { color: colors.text }]}>Welcome to Mail</Text>
<Text style={[styles.subtitle, { color: colors.text + 'AA' }]}>
Sign in to access your email
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Email</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="you@example.com"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Password</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Password"
placeholderTextColor={colors.text + '60'}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="password"
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text + '80'}
/>
</TouchableOpacity>
</View>
</View>
<TouchableOpacity
style={styles.forgotPassword}
onPress={() => router.push('/auth/reset-password')}
>
<Text style={[styles.forgotPasswordText, { color: colors.primary }]}>
Forgot password?
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.loginButton,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 },
]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.loginButtonText}>Sign In</Text>
)}
</TouchableOpacity>
<View style={styles.signupContainer}>
<Text style={[styles.signupText, { color: colors.text + 'AA' }]}>
Don't have an account?
</Text>
<Link href="/auth/register" asChild>
<TouchableOpacity>
<Text style={[styles.signupLink, { color: colors.primary }]}>Sign Up</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 24,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: 40,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: 24,
},
forgotPasswordText: {
fontSize: 14,
fontWeight: '600',
},
loginButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
},
loginButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
signupContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
signupText: {
fontSize: 14,
marginRight: 4,
},
signupLink: {
fontSize: 14,
fontWeight: '600',
},
});

View file

@ -0,0 +1,274 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Link } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useAppTheme } from '~/theme/ThemeProvider';
export default function RegisterScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { signUp } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleRegister = async () => {
if (!email || !password || !confirmPassword) {
Alert.alert('Error', 'Please fill in all fields.');
return;
}
if (password !== confirmPassword) {
Alert.alert('Error', 'Passwords do not match.');
return;
}
if (password.length < 8) {
Alert.alert('Error', 'Password must be at least 8 characters.');
return;
}
try {
setLoading(true);
const { error } = await signUp(email, password);
if (error) {
Alert.alert('Registration Failed', error.message || 'Unknown error');
} else {
router.replace('/');
}
} catch (error) {
console.error('Registration error:', error);
Alert.alert('Error', 'An error occurred during registration. Please try again.');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={[styles.container, { backgroundColor: colors.background }]}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary }]}>
<Ionicons name="mail" size={40} color="#fff" />
</View>
<Text style={[styles.title, { color: colors.text }]}>Create Account</Text>
<Text style={[styles.subtitle, { color: colors.text + 'AA' }]}>
Sign up to start using Mail
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Email</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="you@example.com"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Password</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Password"
placeholderTextColor={colors.text + '60'}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
/>
<TouchableOpacity onPress={() => setShowPassword(!showPassword)}>
<Ionicons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text + '80'}
/>
</TouchableOpacity>
</View>
</View>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Confirm Password</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="lock-closed-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="Confirm Password"
placeholderTextColor={colors.text + '60'}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showPassword}
autoComplete="new-password"
/>
</View>
</View>
<TouchableOpacity
style={[
styles.registerButton,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 },
]}
onPress={handleRegister}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.registerButtonText}>Create Account</Text>
)}
</TouchableOpacity>
<View style={styles.loginContainer}>
<Text style={[styles.loginText, { color: colors.text + 'AA' }]}>
Already have an account?
</Text>
<Link href="/auth/login" asChild>
<TouchableOpacity>
<Text style={[styles.loginLink, { color: colors.primary }]}>Sign In</Text>
</TouchableOpacity>
</Link>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 24,
justifyContent: 'center',
},
header: {
alignItems: 'center',
marginBottom: 40,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 20,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
registerButton: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 24,
marginTop: 8,
},
registerButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
loginContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
loginText: {
fontSize: 14,
marginRight: 4,
},
loginLink: {
fontSize: 14,
fontWeight: '600',
},
});

View file

@ -0,0 +1,216 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
ScrollView,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useAppTheme } from '~/theme/ThemeProvider';
export default function ResetPasswordScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { resetPassword } = useAuth();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const handleResetPassword = async () => {
if (!email) {
Alert.alert('Error', 'Please enter your email address.');
return;
}
try {
setLoading(true);
const { error } = await resetPassword(email);
if (error) {
Alert.alert('Error', error.message || 'Unknown error');
} else {
setSent(true);
}
} catch (error) {
console.error('Reset password error:', error);
Alert.alert('Error', 'An error occurred. Please try again.');
} finally {
setLoading(false);
}
};
if (sent) {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.successContent}>
<View style={[styles.iconContainer, { backgroundColor: '#22c55e' }]}>
<Ionicons name="checkmark" size={40} color="#fff" />
</View>
<Text style={[styles.title, { color: colors.text }]}>Check Your Email</Text>
<Text style={[styles.subtitle, { color: colors.text + 'AA' }]}>
We've sent a password reset link to {email}
</Text>
<TouchableOpacity
style={[styles.button, { backgroundColor: colors.primary }]}
onPress={() => router.replace('/auth/login')}
>
<Text style={styles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={[styles.container, { backgroundColor: colors.background }]}
keyboardShouldPersistTaps="handled"
>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.header}>
<View style={[styles.iconContainer, { backgroundColor: colors.primary }]}>
<Ionicons name="key" size={40} color="#fff" />
</View>
<Text style={[styles.title, { color: colors.text }]}>Reset Password</Text>
<Text style={[styles.subtitle, { color: colors.text + 'AA' }]}>
Enter your email and we'll send you a reset link
</Text>
</View>
<View style={styles.form}>
<View style={styles.inputContainer}>
<Text style={[styles.label, { color: colors.text }]}>Email</Text>
<View
style={[
styles.inputWrapper,
{
backgroundColor: isDarkMode ? '#2C2C2E' : '#F2F2F7',
borderColor: isDarkMode ? '#3A3A3C' : '#E5E5EA',
},
]}
>
<Ionicons name="mail-outline" size={20} color={colors.text + '80'} />
<TextInput
style={[styles.input, { color: colors.text }]}
placeholder="you@example.com"
placeholderTextColor={colors.text + '60'}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
</View>
</View>
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: colors.primary },
loading && { opacity: 0.7 },
]}
onPress={handleResetPassword}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" size="small" />
) : (
<Text style={styles.buttonText}>Send Reset Link</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 24,
},
backButton: {
marginTop: 40,
marginBottom: 20,
},
header: {
alignItems: 'center',
marginBottom: 40,
},
iconContainer: {
width: 80,
height: 80,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
paddingHorizontal: 20,
},
form: {
width: '100%',
},
inputContainer: {
marginBottom: 24,
},
label: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 12,
},
input: {
flex: 1,
fontSize: 16,
marginLeft: 12,
},
button: {
height: 56,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
},
buttonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '600',
},
successContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});

View file

@ -0,0 +1,296 @@
import React, { useState } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ScrollView,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useComposeStore } from '~/store/composeStore';
import { useEmailsStore } from '~/store/emailsStore';
import { useAppTheme } from '~/theme/ThemeProvider';
export default function ComposeScreen() {
const { colors } = useTheme();
const { isDarkMode } = useAppTheme();
const router = useRouter();
const { getToken } = useAuth();
const { selectedAccountId, accounts } = useEmailsStore();
const {
subject,
toAddresses,
ccAddresses,
bodyHtml,
sending,
loading,
updateForm,
saveDraft,
send,
closeCompose,
} = useComposeStore();
const [showCc, setShowCc] = useState(ccAddresses.length > 0);
const [toInput, setToInput] = useState(toAddresses.map((a) => a.email).join(', '));
const [ccInput, setCcInput] = useState(ccAddresses.map((a) => a.email).join(', '));
const [bodyText, setBodyText] = useState(bodyHtml || '');
const selectedAccount = accounts.find((a) => a.id === selectedAccountId);
const parseEmailAddresses = (input: string) => {
return input
.split(',')
.map((email) => email.trim())
.filter((email) => email.length > 0)
.map((email) => ({ email }));
};
const handleSend = async () => {
const to = parseEmailAddresses(toInput);
if (to.length === 0) {
Alert.alert('Error', 'Please add at least one recipient');
return;
}
updateForm({
accountId: selectedAccountId || '',
toAddresses: to,
ccAddresses: parseEmailAddresses(ccInput),
subject,
bodyHtml: bodyText,
});
const token = await getToken();
if (!token) return;
const success = await send(token);
if (success) {
router.back();
}
};
const handleSaveDraft = async () => {
updateForm({
accountId: selectedAccountId || '',
toAddresses: parseEmailAddresses(toInput),
ccAddresses: parseEmailAddresses(ccInput),
subject,
bodyHtml: bodyText,
});
const token = await getToken();
if (!token) return;
const draft = await saveDraft(token);
if (draft) {
Alert.alert('Saved', 'Draft has been saved');
}
};
const handleClose = () => {
if (toInput || subject || bodyText) {
Alert.alert('Discard Draft?', 'Do you want to save this email as a draft?', [
{
text: 'Discard',
style: 'destructive',
onPress: () => {
closeCompose();
router.back();
},
},
{
text: 'Save Draft',
onPress: async () => {
await handleSaveDraft();
router.back();
},
},
{ text: 'Cancel', style: 'cancel' },
]);
} else {
closeCompose();
router.back();
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: 'New Message',
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
headerLeft: () => (
<TouchableOpacity onPress={handleClose} style={styles.headerButton}>
<Ionicons name="close" size={24} color={colors.text} />
</TouchableOpacity>
),
headerRight: () => (
<View style={styles.headerActions}>
<TouchableOpacity
onPress={handleSaveDraft}
style={styles.headerButton}
disabled={loading}
>
<Ionicons name="save-outline" size={24} color={colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={handleSend}
style={[styles.sendButton, { backgroundColor: colors.primary }]}
disabled={sending}
>
{sending ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={20} color="#fff" />
)}
</TouchableOpacity>
</View>
),
}}
/>
<ScrollView style={styles.content} keyboardShouldPersistTaps="handled">
{/* From */}
<View style={[styles.field, { borderBottomColor: colors.border }]}>
<Text style={[styles.fieldLabel, { color: colors.text + '80' }]}>From</Text>
<Text style={[styles.fromEmail, { color: colors.text }]}>
{selectedAccount?.email || 'Select account'}
</Text>
</View>
{/* To */}
<View style={[styles.field, { borderBottomColor: colors.border }]}>
<Text style={[styles.fieldLabel, { color: colors.text + '80' }]}>To</Text>
<TextInput
style={[styles.fieldInput, { color: colors.text }]}
placeholder="Recipients"
placeholderTextColor={colors.text + '60'}
value={toInput}
onChangeText={setToInput}
keyboardType="email-address"
autoCapitalize="none"
/>
{!showCc && (
<TouchableOpacity onPress={() => setShowCc(true)}>
<Text style={[styles.ccToggle, { color: colors.primary }]}>Cc</Text>
</TouchableOpacity>
)}
</View>
{/* Cc */}
{showCc && (
<View style={[styles.field, { borderBottomColor: colors.border }]}>
<Text style={[styles.fieldLabel, { color: colors.text + '80' }]}>Cc</Text>
<TextInput
style={[styles.fieldInput, { color: colors.text }]}
placeholder="Cc recipients"
placeholderTextColor={colors.text + '60'}
value={ccInput}
onChangeText={setCcInput}
keyboardType="email-address"
autoCapitalize="none"
/>
</View>
)}
{/* Subject */}
<View style={[styles.field, { borderBottomColor: colors.border }]}>
<Text style={[styles.fieldLabel, { color: colors.text + '80' }]}>Subject</Text>
<TextInput
style={[styles.fieldInput, { color: colors.text }]}
placeholder="Subject"
placeholderTextColor={colors.text + '60'}
value={subject}
onChangeText={(text) => updateForm({ subject: text })}
/>
</View>
{/* Body */}
<View style={styles.bodyContainer}>
<TextInput
style={[styles.bodyInput, { color: colors.text }]}
placeholder="Compose email"
placeholderTextColor={colors.text + '60'}
value={bodyText}
onChangeText={setBodyText}
multiline
textAlignVertical="top"
/>
</View>
</ScrollView>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
headerButton: {
padding: 8,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
sendButton: {
marginLeft: 12,
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
field: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
},
fieldLabel: {
width: 60,
fontSize: 14,
},
fieldInput: {
flex: 1,
fontSize: 16,
},
fromEmail: {
flex: 1,
fontSize: 16,
},
ccToggle: {
fontSize: 14,
fontWeight: '600',
paddingHorizontal: 8,
},
bodyContainer: {
flex: 1,
minHeight: 300,
},
bodyInput: {
flex: 1,
fontSize: 16,
padding: 16,
lineHeight: 24,
},
});

View file

@ -0,0 +1,389 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
useWindowDimensions,
} from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useLocalSearchParams, useRouter, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { WebView } from 'react-native-webview';
import { useAuth } from '~/context/AuthProvider';
import { useEmailsStore } from '~/store/emailsStore';
import { useComposeStore } from '~/store/composeStore';
export default function EmailDetailScreen() {
const { colors } = useTheme();
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { width } = useWindowDimensions();
const { getToken } = useAuth();
const { selectedEmail, loading, fetchEmail, toggleStar, deleteEmail, moveEmail, folders } =
useEmailsStore();
const { createReply, createReplyAll, createForward } = useComposeStore();
const [webViewHeight, setWebViewHeight] = useState(300);
useEffect(() => {
const loadEmail = async () => {
if (!id) return;
const token = await getToken();
if (token) {
await fetchEmail(id, token);
}
};
loadEmail();
}, [id]);
const handleStar = async () => {
if (!id) return;
const token = await getToken();
if (token) {
await toggleStar(id, token);
}
};
const handleDelete = async () => {
Alert.alert('Delete Email', 'Move this email to trash?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
const token = await getToken();
if (token && id) {
await deleteEmail(id, token);
router.back();
}
},
},
]);
};
const handleReply = async () => {
if (!id) return;
const token = await getToken();
if (token) {
await createReply(id, token);
router.push('/compose');
}
};
const handleReplyAll = async () => {
if (!id) return;
const token = await getToken();
if (token) {
await createReplyAll(id, token);
router.push('/compose');
}
};
const handleForward = async () => {
if (!id) return;
const token = await getToken();
if (token) {
await createForward(id, token);
router.push('/compose');
}
};
const handleMoveToFolder = () => {
const customFolders = folders.filter((f) => f.type !== 'inbox' && f.type !== 'trash');
const options = customFolders.map((f) => ({
text: f.name,
onPress: async () => {
const token = await getToken();
if (token && id) {
await moveEmail(id, f.id, token);
router.back();
}
},
}));
Alert.alert('Move to Folder', 'Select a folder', [
{ text: 'Cancel', style: 'cancel' },
...options,
]);
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString([], {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: ${colors.text};
margin: 0;
padding: 16px;
background-color: transparent;
}
img { max-width: 100%; height: auto; }
a { color: ${colors.primary}; }
pre { overflow-x: auto; white-space: pre-wrap; }
</style>
</head>
<body>
${selectedEmail?.bodyHtml || selectedEmail?.bodyPlain?.replace(/\n/g, '<br>') || ''}
</body>
</html>
`;
if (loading && !selectedEmail) {
return (
<View style={[styles.centerContainer, { backgroundColor: colors.background }]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
if (!selectedEmail) {
return (
<View style={[styles.centerContainer, { backgroundColor: colors.background }]}>
<Text style={{ color: colors.text }}>Email not found</Text>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: '',
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
headerRight: () => (
<View style={styles.headerActions}>
<TouchableOpacity style={styles.headerButton} onPress={handleStar}>
<Ionicons
name={selectedEmail.isStarred ? 'star' : 'star-outline'}
size={24}
color={selectedEmail.isStarred ? '#f59e0b' : colors.text}
/>
</TouchableOpacity>
<TouchableOpacity style={styles.headerButton} onPress={handleDelete}>
<Ionicons name="trash-outline" size={24} color={colors.text} />
</TouchableOpacity>
</View>
),
}}
/>
<ScrollView style={styles.content}>
{/* Subject */}
<Text style={[styles.subject, { color: colors.text }]}>
{selectedEmail.subject || '(No Subject)'}
</Text>
{/* From info */}
<View style={styles.senderSection}>
<View style={[styles.avatar, { backgroundColor: colors.primary + '30' }]}>
<Text style={[styles.avatarText, { color: colors.primary }]}>
{(selectedEmail.fromName || selectedEmail.fromAddress)?.charAt(0).toUpperCase()}
</Text>
</View>
<View style={styles.senderInfo}>
<Text style={[styles.senderName, { color: colors.text }]}>
{selectedEmail.fromName || selectedEmail.fromAddress}
</Text>
<Text style={[styles.senderEmail, { color: colors.text + '80' }]}>
{selectedEmail.fromAddress}
</Text>
<Text style={[styles.date, { color: colors.text + '60' }]}>
{formatDate(selectedEmail.receivedAt || selectedEmail.sentAt)}
</Text>
</View>
</View>
{/* Recipients */}
<View style={styles.recipientsSection}>
<Text style={[styles.recipientLabel, { color: colors.text + '80' }]}>To: </Text>
<Text style={[styles.recipientValue, { color: colors.text }]}>
{selectedEmail.toAddresses?.map((a) => a.name || a.email).join(', ')}
</Text>
</View>
{/* AI Summary */}
{selectedEmail.aiSummary && (
<View style={[styles.summarySection, { backgroundColor: colors.primary + '10' }]}>
<View style={styles.summaryHeader}>
<Ionicons name="sparkles" size={16} color={colors.primary} />
<Text style={[styles.summaryTitle, { color: colors.primary }]}>AI Summary</Text>
</View>
<Text style={[styles.summaryText, { color: colors.text }]}>
{selectedEmail.aiSummary}
</Text>
</View>
)}
{/* Body */}
<View style={[styles.bodySection, { minHeight: webViewHeight }]}>
<WebView
originWhitelist={['*']}
source={{ html: htmlContent }}
style={{ backgroundColor: 'transparent', width: width - 32 }}
scrollEnabled={false}
onMessage={(event) => {
const height = parseInt(event.nativeEvent.data, 10);
if (height > 0) {
setWebViewHeight(height + 50);
}
}}
injectedJavaScript={`
window.ReactNativeWebView.postMessage(document.body.scrollHeight.toString());
true;
`}
/>
</View>
</ScrollView>
{/* Action bar */}
<View
style={[styles.actionBar, { backgroundColor: colors.card, borderTopColor: colors.border }]}
>
<TouchableOpacity style={styles.actionButton} onPress={handleReply}>
<Ionicons name="arrow-undo" size={22} color={colors.text} />
<Text style={[styles.actionText, { color: colors.text }]}>Reply</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleReplyAll}>
<Ionicons name="arrow-undo-circle" size={22} color={colors.text} />
<Text style={[styles.actionText, { color: colors.text }]}>Reply All</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleForward}>
<Ionicons name="arrow-redo" size={22} color={colors.text} />
<Text style={[styles.actionText, { color: colors.text }]}>Forward</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionButton} onPress={handleMoveToFolder}>
<Ionicons name="folder" size={22} color={colors.text} />
<Text style={[styles.actionText, { color: colors.text }]}>Move</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
headerActions: {
flexDirection: 'row',
},
headerButton: {
padding: 8,
marginLeft: 8,
},
subject: {
fontSize: 22,
fontWeight: '600',
padding: 16,
paddingBottom: 8,
},
senderSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
fontWeight: '600',
},
senderInfo: {
marginLeft: 12,
flex: 1,
},
senderName: {
fontSize: 16,
fontWeight: '600',
},
senderEmail: {
fontSize: 14,
},
date: {
fontSize: 12,
marginTop: 2,
},
recipientsSection: {
flexDirection: 'row',
paddingHorizontal: 16,
paddingBottom: 12,
},
recipientLabel: {
fontSize: 14,
},
recipientValue: {
fontSize: 14,
flex: 1,
},
summarySection: {
margin: 16,
padding: 12,
borderRadius: 8,
},
summaryHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 6,
},
summaryTitle: {
fontSize: 14,
fontWeight: '600',
marginLeft: 6,
},
summaryText: {
fontSize: 14,
lineHeight: 20,
},
bodySection: {
paddingHorizontal: 16,
},
actionBar: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingVertical: 12,
borderTopWidth: 1,
},
actionButton: {
alignItems: 'center',
},
actionText: {
fontSize: 11,
marginTop: 4,
},
});

View file

@ -0,0 +1,213 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet, ScrollView, Switch, Alert } from 'react-native';
import { useTheme } from '@react-navigation/native';
import { useRouter, Stack } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '~/context/AuthProvider';
import { useAppTheme } from '~/theme/ThemeProvider';
import { useEmailsStore } from '~/store/emailsStore';
export default function SettingsScreen() {
const { colors } = useTheme();
const router = useRouter();
const { signOut } = useAuth();
const { isDarkMode, themeMode, setThemeMode, toggleTheme } = useAppTheme();
const { accounts } = useEmailsStore();
const handleLogout = async () => {
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
await signOut();
router.replace('/auth/login');
},
},
]);
};
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Stack.Screen
options={{
headerShown: true,
headerTitle: 'Settings',
headerStyle: { backgroundColor: colors.card },
headerTintColor: colors.text,
}}
/>
<ScrollView>
{/* Accounts Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>EMAIL ACCOUNTS</Text>
{accounts.map((account) => (
<TouchableOpacity
key={account.id}
style={[styles.item, { backgroundColor: colors.card }]}
onPress={() => router.push('/accounts')}
>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Ionicons
name={
account.provider === 'gmail'
? 'logo-google'
: account.provider === 'outlook'
? 'logo-microsoft'
: 'mail'
}
size={20}
color={colors.primary}
/>
</View>
<View style={styles.itemContent}>
<Text style={[styles.itemTitle, { color: colors.text }]}>{account.name}</Text>
<Text style={[styles.itemSubtitle, { color: colors.text + '80' }]}>
{account.email}
</Text>
</View>
{account.isDefault && (
<View style={[styles.defaultBadge, { backgroundColor: colors.primary + '20' }]}>
<Text style={[styles.defaultBadgeText, { color: colors.primary }]}>Default</Text>
</View>
)}
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
</TouchableOpacity>
))}
<TouchableOpacity
style={[styles.item, { backgroundColor: colors.card }]}
onPress={() => router.push('/accounts')}
>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Ionicons name="add" size={20} color={colors.primary} />
</View>
<Text style={[styles.itemTitle, { color: colors.primary }]}>Add Account</Text>
</TouchableOpacity>
</View>
{/* Appearance Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>APPEARANCE</Text>
<View style={[styles.item, { backgroundColor: colors.card }]}>
<View style={[styles.iconContainer, { backgroundColor: '#f59e0b20' }]}>
<Ionicons name="moon" size={20} color="#f59e0b" />
</View>
<Text style={[styles.itemTitle, { color: colors.text }]}>Dark Mode</Text>
<Switch
value={isDarkMode}
onValueChange={toggleTheme}
trackColor={{ false: '#767577', true: colors.primary + '80' }}
thumbColor={isDarkMode ? colors.primary : '#f4f3f4'}
/>
</View>
<TouchableOpacity
style={[styles.item, { backgroundColor: colors.card }]}
onPress={() => {
Alert.alert('Theme Mode', 'Choose theme mode', [
{ text: 'Light', onPress: () => setThemeMode('light') },
{ text: 'Dark', onPress: () => setThemeMode('dark') },
{ text: 'System', onPress: () => setThemeMode('system') },
{ text: 'Cancel', style: 'cancel' },
]);
}}
>
<View style={[styles.iconContainer, { backgroundColor: colors.primary + '20' }]}>
<Ionicons name="color-palette" size={20} color={colors.primary} />
</View>
<View style={styles.itemContent}>
<Text style={[styles.itemTitle, { color: colors.text }]}>Theme Mode</Text>
<Text style={[styles.itemSubtitle, { color: colors.text + '80' }]}>
{themeMode.charAt(0).toUpperCase() + themeMode.slice(1)}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.text + '60'} />
</TouchableOpacity>
</View>
{/* About Section */}
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: colors.text + '80' }]}>ABOUT</Text>
<View style={[styles.item, { backgroundColor: colors.card }]}>
<View style={[styles.iconContainer, { backgroundColor: '#22c55e20' }]}>
<Ionicons name="information-circle" size={20} color="#22c55e" />
</View>
<View style={styles.itemContent}>
<Text style={[styles.itemTitle, { color: colors.text }]}>Version</Text>
<Text style={[styles.itemSubtitle, { color: colors.text + '80' }]}>1.0.0</Text>
</View>
</View>
</View>
{/* Sign Out */}
<View style={styles.section}>
<TouchableOpacity
style={[styles.item, styles.dangerItem, { backgroundColor: colors.card }]}
onPress={handleLogout}
>
<View style={[styles.iconContainer, { backgroundColor: '#ef444420' }]}>
<Ionicons name="log-out" size={20} color="#ef4444" />
</View>
<Text style={[styles.itemTitle, { color: '#ef4444' }]}>Sign Out</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
section: {
marginTop: 24,
},
sectionTitle: {
fontSize: 12,
fontWeight: '600',
paddingHorizontal: 16,
marginBottom: 8,
},
item: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: 'rgba(128, 128, 128, 0.2)',
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
itemContent: {
flex: 1,
},
itemTitle: {
fontSize: 16,
},
itemSubtitle: {
fontSize: 13,
marginTop: 2,
},
defaultBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginRight: 8,
},
defaultBadgeText: {
fontSize: 12,
fontWeight: '500',
},
dangerItem: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: 'rgba(128, 128, 128, 0.2)',
},
});

View file

@ -0,0 +1,231 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { ActivityIndicator, View, Text } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
type UserData,
} from '@manacore/shared-auth';
// Mana Core Auth URL from environment
const MANA_AUTH_URL = process.env.EXPO_PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Create SecureStore adapter for React Native
const createSecureStoreAdapter = () => ({
async getItem<T>(key: string): Promise<T | null> {
try {
const value = await SecureStore.getItemAsync(key);
return value ? JSON.parse(value) : null;
} catch {
return null;
}
},
async setItem(key: string, value: unknown): Promise<void> {
await SecureStore.setItemAsync(key, JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
},
});
// Create device adapter for React Native
const createReactNativeDeviceAdapter = () => {
let deviceId: string | null = null;
return {
async getDeviceInfo() {
if (!deviceId) {
deviceId = await SecureStore.getItemAsync('@device/id');
if (!deviceId) {
deviceId = `rn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
await SecureStore.setItemAsync('@device/id', deviceId);
}
}
return {
deviceId,
deviceName: 'React Native Device',
platform: 'react-native',
};
},
async getStoredDeviceId() {
return deviceId || (await SecureStore.getItemAsync('@device/id'));
},
};
};
// Create network adapter
const createReactNativeNetworkAdapter = () => ({
async isConnected() {
return true;
},
async hasStableConnection() {
return true;
},
});
// Initialize adapters
setStorageAdapter(createSecureStoreAdapter());
setDeviceAdapter(createReactNativeDeviceAdapter());
setNetworkAdapter(createReactNativeNetworkAdapter());
// Create auth service
const authService = createAuthService({ baseUrl: MANA_AUTH_URL });
const tokenManager = createTokenManager(authService);
// Auth context type
type AuthContextType = {
user: UserData | null;
loading: boolean;
signIn: (email: string, password: string) => Promise<{ error: any | null }>;
signUp: (email: string, password: string) => Promise<{ error: any | null; data: any | null }>;
signOut: () => Promise<void>;
resetPassword: (email: string) => Promise<{ error: any | null }>;
getToken: () => Promise<string | null>;
};
// Create auth context
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Hook to access auth context
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
// AuthProvider component
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserData | null>(null);
const [loading, setLoading] = useState(true);
// Initialize auth state
useEffect(() => {
const initialize = async () => {
try {
setLoading(true);
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
setUser(userData);
}
} catch (error) {
console.error('Error initializing auth session:', error);
setUser(null);
} finally {
setLoading(false);
}
};
initialize();
}, []);
// Sign in with email and password
const signIn = async (email: string, password: string) => {
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { error: { message: result.error } };
}
const userData = await authService.getUserFromToken();
setUser(userData);
return { error: null };
} catch (error: any) {
return { error };
}
};
// Sign up with email and password
const signUp = async (email: string, password: string) => {
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { data: null, error: { message: result.error } };
}
const signInResult = await signIn(email, password);
if (signInResult.error) {
return { data: null, error: signInResult.error };
}
return { data: user, error: null };
} catch (error) {
return { data: null, error };
}
};
// Sign out
const signOut = async () => {
try {
await authService.signOut();
setUser(null);
} catch (error) {
console.error('Error signing out:', error);
}
};
// Reset password
const resetPassword = async (email: string) => {
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { error: { message: result.error } };
}
return { error: null };
} catch (error) {
return { error };
}
};
// Get token for API calls
const getToken = async () => {
try {
return await tokenManager.getAccessToken();
} catch {
return null;
}
};
// Show loading indicator during initialization
if (loading) {
return (
<View
style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' }}
>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={{ marginTop: 16, color: '#fff' }}>Loading...</Text>
</View>
);
}
return (
<AuthContext.Provider
value={{
user,
loading,
signIn,
signUp,
signOut,
resetPassword,
getToken,
}}
>
{children}
</AuthContext.Provider>
);
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,78 @@
{
"name": "@mail/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write"
},
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@manacore/shared-auth": "workspace:*",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.3",
"expo-secure-store": "~15.0.1",
"expo": "~54.0.9",
"expo-blur": "~15.0.7",
"expo-clipboard": "~8.0.7",
"expo-constants": "~18.0.0",
"expo-dev-client": "~6.0.12",
"expo-document-picker": "~14.0.7",
"expo-file-system": "~19.0.15",
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-linear-gradient": "~15.0.7",
"expo-linking": "~8.0.8",
"expo-localization": "^17.0.7",
"expo-router": "~6.0.8",
"expo-sharing": "~14.0.7",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"i18next": "^25.5.2",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^15.7.3",
"react-native": "0.81.4",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "4.1.0",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-webview": "^13.12.5",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@testing-library/jest-native": "^5.4.3",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^30.0.0",
"@types/node": "^24.5.2",
"@types/react": "^19.1.0",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"jest": "^30.2.0",
"jest-expo": "^54.0.12",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.9.2"
},
"private": true
}

View file

@ -0,0 +1,319 @@
import { create } from 'zustand';
import { composeApi } from '~/utils/api';
import type { EmailAddress } from './emailsStore';
export interface Draft {
id: string;
accountId: string;
subject?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
createdAt: string;
updatedAt: string;
}
interface ComposeState {
// Data
drafts: Draft[];
currentDraft: Draft | null;
// Form state
isComposeOpen: boolean;
accountId: string;
subject: string;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[];
bccAddresses: EmailAddress[];
bodyHtml: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
// UI state
loading: boolean;
sending: boolean;
error: string | null;
// Actions
openCompose: (accountId: string) => void;
closeCompose: () => void;
updateForm: (updates: Partial<ComposeState>) => void;
fetchDrafts: (accountId: string | undefined, token: string) => Promise<void>;
saveDraft: (token: string) => Promise<Draft | null>;
deleteDraft: (id: string, token: string) => Promise<boolean>;
openDraft: (draft: Draft) => void;
send: (token: string) => Promise<boolean>;
createReply: (emailId: string, token: string) => Promise<void>;
createReplyAll: (emailId: string, token: string) => Promise<void>;
createForward: (emailId: string, token: string) => Promise<void>;
clearError: () => void;
}
export const useComposeStore = create<ComposeState>((set, get) => ({
drafts: [],
currentDraft: null,
isComposeOpen: false,
accountId: '',
subject: '',
toAddresses: [],
ccAddresses: [],
bccAddresses: [],
bodyHtml: '',
replyToEmailId: undefined,
replyType: undefined,
loading: false,
sending: false,
error: null,
openCompose: (accountId) => {
set({
isComposeOpen: true,
accountId,
subject: '',
toAddresses: [],
ccAddresses: [],
bccAddresses: [],
bodyHtml: '',
replyToEmailId: undefined,
replyType: undefined,
currentDraft: null,
});
},
closeCompose: () => {
set({
isComposeOpen: false,
currentDraft: null,
});
},
updateForm: (updates) => {
set(updates);
},
fetchDrafts: async (accountId, token) => {
set({ loading: true, error: null });
const result = await composeApi.listDrafts(accountId, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
set({ drafts: result.data?.drafts || [], loading: false });
},
saveDraft: async (token) => {
const {
currentDraft,
accountId,
subject,
toAddresses,
ccAddresses,
bccAddresses,
bodyHtml,
replyToEmailId,
replyType,
} = get();
set({ loading: true, error: null });
if (currentDraft) {
const result = await composeApi.updateDraft(
currentDraft.id,
{ subject, toAddresses, ccAddresses, bccAddresses, bodyHtml },
token
);
if (result.error) {
set({ error: result.error.message, loading: false });
return null;
}
const draft = result.data?.draft;
if (draft) {
set((state) => ({
currentDraft: draft,
drafts: state.drafts.map((d) => (d.id === draft.id ? draft : d)),
loading: false,
}));
}
return draft || null;
} else {
const result = await composeApi.createDraft(
{
accountId,
subject,
toAddresses,
ccAddresses,
bccAddresses,
bodyHtml,
replyToEmailId,
replyType,
},
token
);
if (result.error) {
set({ error: result.error.message, loading: false });
return null;
}
const draft = result.data?.draft;
if (draft) {
set((state) => ({
currentDraft: draft,
drafts: [draft, ...state.drafts],
loading: false,
}));
}
return draft || null;
}
},
deleteDraft: async (id, token) => {
const result = await composeApi.deleteDraft(id, token);
if (result.error) {
set({ error: result.error.message });
return false;
}
set((state) => ({
drafts: state.drafts.filter((d) => d.id !== id),
currentDraft: state.currentDraft?.id === id ? null : state.currentDraft,
}));
return true;
},
openDraft: (draft) => {
set({
isComposeOpen: true,
currentDraft: draft,
accountId: draft.accountId,
subject: draft.subject || '',
toAddresses: draft.toAddresses || [],
ccAddresses: draft.ccAddresses || [],
bccAddresses: draft.bccAddresses || [],
bodyHtml: draft.bodyHtml || '',
replyToEmailId: draft.replyToEmailId,
replyType: draft.replyType,
});
},
send: async (token) => {
const {
currentDraft,
toAddresses,
accountId,
subject,
ccAddresses,
bccAddresses,
bodyHtml,
replyToEmailId,
replyType,
} = get();
if (toAddresses.length === 0) {
set({ error: 'Please add at least one recipient' });
return false;
}
set({ sending: true, error: null });
let result;
if (currentDraft) {
await get().saveDraft(token);
result = await composeApi.sendDraft(currentDraft.id, token);
} else {
result = await composeApi.send(
{
accountId,
subject,
toAddresses,
ccAddresses,
bccAddresses,
bodyHtml,
replyToEmailId,
replyType,
},
token
);
}
if (result.error) {
set({ error: result.error.message, sending: false });
return false;
}
if (currentDraft) {
set((state) => ({
drafts: state.drafts.filter((d) => d.id !== currentDraft.id),
}));
}
get().closeCompose();
set({ sending: false });
return true;
},
createReply: async (emailId, token) => {
set({ loading: true, error: null });
const result = await composeApi.createReply(emailId, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
if (result.data?.draft) {
get().openDraft(result.data.draft);
set((state) => ({
drafts: [result.data!.draft, ...state.drafts],
}));
}
set({ loading: false });
},
createReplyAll: async (emailId, token) => {
set({ loading: true, error: null });
const result = await composeApi.createReplyAll(emailId, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
if (result.data?.draft) {
get().openDraft(result.data.draft);
set((state) => ({
drafts: [result.data!.draft, ...state.drafts],
}));
}
set({ loading: false });
},
createForward: async (emailId, token) => {
set({ loading: true, error: null });
const result = await composeApi.createForward(emailId, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
if (result.data?.draft) {
get().openDraft(result.data.draft);
set((state) => ({
drafts: [result.data!.draft, ...state.drafts],
}));
}
set({ loading: false });
},
clearError: () => set({ error: null }),
}));

View file

@ -0,0 +1,284 @@
import { create } from 'zustand';
import { emailsApi, foldersApi, accountsApi } from '~/utils/api';
export interface EmailAddress {
email: string;
name?: string;
}
export interface Email {
id: string;
accountId: string;
folderId: string;
threadId?: string;
messageId: string;
subject: string;
fromAddress: string;
fromName?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
snippet?: string;
bodyPlain?: string;
bodyHtml?: string;
sentAt: string;
receivedAt: string;
isRead: boolean;
isStarred: boolean;
isDraft: boolean;
hasAttachments: boolean;
aiSummary?: string;
aiCategory?: string;
}
export interface Folder {
id: string;
accountId: string;
name: string;
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
path: string;
unreadCount: number;
totalCount: number;
isSystem: boolean;
}
export interface Account {
id: string;
name: string;
email: string;
provider: 'gmail' | 'outlook' | 'imap';
isDefault: boolean;
syncEnabled: boolean;
lastSyncAt?: string;
}
interface EmailsState {
// Data
accounts: Account[];
selectedAccountId: string | null;
folders: Folder[];
selectedFolderId: string | null;
emails: Email[];
selectedEmail: Email | null;
// UI state
loading: boolean;
syncing: boolean;
error: string | null;
page: number;
hasMore: boolean;
// Actions
fetchAccounts: (token: string) => Promise<void>;
selectAccount: (id: string) => void;
fetchFolders: (accountId: string, token: string) => Promise<void>;
selectFolder: (id: string) => void;
fetchEmails: (token: string, reset?: boolean) => Promise<void>;
fetchEmail: (id: string, token: string) => Promise<void>;
markAsRead: (id: string, token: string) => Promise<void>;
toggleStar: (id: string, token: string) => Promise<void>;
deleteEmail: (id: string, token: string) => Promise<void>;
moveEmail: (id: string, folderId: string, token: string) => Promise<void>;
syncAccount: (accountId: string, token: string) => Promise<void>;
clearError: () => void;
}
export const useEmailsStore = create<EmailsState>((set, get) => ({
accounts: [],
selectedAccountId: null,
folders: [],
selectedFolderId: null,
emails: [],
selectedEmail: null,
loading: false,
syncing: false,
error: null,
page: 1,
hasMore: true,
fetchAccounts: async (token) => {
set({ loading: true, error: null });
const result = await accountsApi.list(token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
const accounts = result.data?.accounts || [];
const defaultAccount = accounts.find((a) => a.isDefault) || accounts[0];
set({
accounts,
selectedAccountId: defaultAccount?.id || null,
loading: false,
});
},
selectAccount: (id) => {
set({
selectedAccountId: id,
selectedFolderId: null,
folders: [],
emails: [],
page: 1,
hasMore: true,
});
},
fetchFolders: async (accountId, token) => {
set({ loading: true, error: null });
const result = await foldersApi.list(accountId, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
const folders = result.data?.folders || [];
const inbox = folders.find((f) => f.type === 'inbox');
set({
folders,
selectedFolderId: inbox?.id || folders[0]?.id || null,
loading: false,
});
},
selectFolder: (id) => {
set({
selectedFolderId: id,
emails: [],
selectedEmail: null,
page: 1,
hasMore: true,
});
},
fetchEmails: async (token, reset = false) => {
const { selectedAccountId, selectedFolderId, page, emails } = get();
if (!selectedAccountId) return;
set({ loading: true, error: null });
const currentPage = reset ? 1 : page;
const result = await emailsApi.list(
{
accountId: selectedAccountId,
folderId: selectedFolderId || undefined,
page: currentPage,
limit: 20,
},
token
);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
const newEmails = result.data?.emails || [];
const total = result.data?.total || 0;
set({
emails: reset ? newEmails : [...emails, ...newEmails],
page: currentPage + 1,
hasMore: (reset ? newEmails.length : emails.length + newEmails.length) < total,
loading: false,
});
},
fetchEmail: async (id, token) => {
set({ loading: true, error: null });
const result = await emailsApi.get(id, token);
if (result.error) {
set({ error: result.error.message, loading: false });
return;
}
set({ selectedEmail: result.data?.email || null, loading: false });
},
markAsRead: async (id, token) => {
const result = await emailsApi.update(id, { isRead: true }, token);
if (result.error) {
set({ error: result.error.message });
return;
}
set((state) => ({
emails: state.emails.map((e) => (e.id === id ? { ...e, isRead: true } : e)),
selectedEmail:
state.selectedEmail?.id === id
? { ...state.selectedEmail, isRead: true }
: state.selectedEmail,
}));
},
toggleStar: async (id, token) => {
const { emails, selectedEmail } = get();
const email = emails.find((e) => e.id === id) || selectedEmail;
if (!email) return;
const newStarred = !email.isStarred;
const result = await emailsApi.update(id, { isStarred: newStarred }, token);
if (result.error) {
set({ error: result.error.message });
return;
}
set((state) => ({
emails: state.emails.map((e) => (e.id === id ? { ...e, isStarred: newStarred } : e)),
selectedEmail:
state.selectedEmail?.id === id
? { ...state.selectedEmail, isStarred: newStarred }
: state.selectedEmail,
}));
},
deleteEmail: async (id, token) => {
const result = await emailsApi.delete(id, token);
if (result.error) {
set({ error: result.error.message });
return;
}
set((state) => ({
emails: state.emails.filter((e) => e.id !== id),
selectedEmail: state.selectedEmail?.id === id ? null : state.selectedEmail,
}));
},
moveEmail: async (id, folderId, token) => {
const result = await emailsApi.move(id, folderId, token);
if (result.error) {
set({ error: result.error.message });
return;
}
set((state) => ({
emails: state.emails.filter((e) => e.id !== id),
selectedEmail: state.selectedEmail?.id === id ? null : state.selectedEmail,
}));
},
syncAccount: async (accountId, token) => {
set({ syncing: true, error: null });
const result = await accountsApi.sync(accountId, token);
if (result.error) {
set({ error: result.error.message, syncing: false });
return;
}
// Refresh emails after sync
await get().fetchEmails(token, true);
set({ syncing: false });
},
clearError: () => set({ error: null }),
}));

View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
};

View file

@ -0,0 +1,118 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { useColorScheme } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { DarkTheme, DefaultTheme, Theme } from '@react-navigation/native';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
isDarkMode: boolean;
themeMode: ThemeMode;
theme: Theme;
setThemeMode: (mode: ThemeMode) => void;
toggleTheme: () => void;
}
const THEME_STORAGE_KEY = '@mail/theme-mode';
// Custom themes with Mail brand colors
const MailDarkTheme: Theme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
primary: '#6366f1',
background: '#000000',
card: '#1c1c1e',
text: '#ffffff',
border: '#38383a',
notification: '#ff453a',
},
};
const MailLightTheme: Theme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: '#6366f1',
background: '#f2f2f7',
card: '#ffffff',
text: '#000000',
border: '#c6c6c8',
notification: '#ff3b30',
},
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useAppTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useAppTheme must be used within a ThemeProvider');
}
return context;
};
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const systemColorScheme = useColorScheme();
const [themeMode, setThemeModeState] = useState<ThemeMode>('system');
const [isLoaded, setIsLoaded] = useState(false);
// Load saved theme preference
useEffect(() => {
const loadTheme = async () => {
try {
const savedMode = await AsyncStorage.getItem(THEME_STORAGE_KEY);
if (savedMode && ['light', 'dark', 'system'].includes(savedMode)) {
setThemeModeState(savedMode as ThemeMode);
}
} catch (error) {
console.error('Error loading theme:', error);
} finally {
setIsLoaded(true);
}
};
loadTheme();
}, []);
// Determine if dark mode is active
const isDarkMode =
themeMode === 'dark' || (themeMode === 'system' && systemColorScheme === 'dark');
// Get the navigation theme
const theme = isDarkMode ? MailDarkTheme : MailLightTheme;
// Set theme mode and persist
const setThemeMode = async (mode: ThemeMode) => {
setThemeModeState(mode);
try {
await AsyncStorage.setItem(THEME_STORAGE_KEY, mode);
} catch (error) {
console.error('Error saving theme:', error);
}
};
// Toggle between light and dark
const toggleTheme = () => {
const newMode = isDarkMode ? 'light' : 'dark';
setThemeMode(newMode);
};
if (!isLoaded) {
return null;
}
return (
<ThemeContext.Provider
value={{
isDarkMode,
themeMode,
theme,
setThemeMode,
toggleTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}

View file

@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"~/*": ["*"]
},
"resolveJsonModule": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts",
"types/**/*.d.ts"
]
}

View file

@ -0,0 +1,169 @@
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3017';
interface ApiResponse<T> {
data?: T;
error?: { message: string; statusCode?: number };
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit & { token?: string } = {}
): Promise<ApiResponse<T>> {
const { token, ...fetchOptions } = options;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...fetchOptions.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
try {
const response = await fetch(`${BACKEND_URL}/api/v1${endpoint}`, {
...fetchOptions,
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
error: {
message: errorData.message || `HTTP error ${response.status}`,
statusCode: response.status,
},
};
}
const data = await response.json();
return { data };
} catch (error: any) {
return {
error: {
message: error.message || 'Network error',
},
};
}
}
// Email Account API
export const accountsApi = {
list: (token: string) => fetchApi<{ accounts: any[] }>('/accounts', { token }),
get: (id: string, token: string) => fetchApi<{ account: any }>(`/accounts/${id}`, { token }),
create: (data: any, token: string) =>
fetchApi<{ account: any }>('/accounts', { method: 'POST', body: JSON.stringify(data), token }),
update: (id: string, data: any, token: string) =>
fetchApi<{ account: any }>(`/accounts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
token,
}),
delete: (id: string, token: string) =>
fetchApi<void>(`/accounts/${id}`, { method: 'DELETE', token }),
sync: (id: string, token: string) =>
fetchApi<{ success: boolean }>(`/accounts/${id}/sync`, { method: 'POST', token }),
setDefault: (id: string, token: string) =>
fetchApi<{ account: any }>(`/accounts/${id}/default`, { method: 'POST', token }),
};
// Folders API
export const foldersApi = {
list: (accountId: string, token: string) =>
fetchApi<{ folders: any[] }>(`/folders?accountId=${accountId}`, { token }),
get: (id: string, token: string) => fetchApi<{ folder: any }>(`/folders/${id}`, { token }),
};
// Emails API
export const emailsApi = {
list: (
params: { accountId?: string; folderId?: string; page?: number; limit?: number },
token: string
) => {
const searchParams = new URLSearchParams();
if (params.accountId) searchParams.set('accountId', params.accountId);
if (params.folderId) searchParams.set('folderId', params.folderId);
if (params.page) searchParams.set('page', String(params.page));
if (params.limit) searchParams.set('limit', String(params.limit));
return fetchApi<{ emails: any[]; total: number; page: number; limit: number }>(
`/emails?${searchParams.toString()}`,
{ token }
);
},
get: (id: string, token: string) => fetchApi<{ email: any }>(`/emails/${id}`, { token }),
update: (id: string, data: { isRead?: boolean; isStarred?: boolean }, token: string) =>
fetchApi<{ email: any }>(`/emails/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
token,
}),
delete: (id: string, token: string) =>
fetchApi<void>(`/emails/${id}`, { method: 'DELETE', token }),
move: (id: string, folderId: string, token: string) =>
fetchApi<{ email: any }>(`/emails/${id}/move`, {
method: 'POST',
body: JSON.stringify({ folderId }),
token,
}),
search: (query: string, accountId: string | undefined, token: string) => {
const searchParams = new URLSearchParams({ q: query });
if (accountId) searchParams.set('accountId', accountId);
return fetchApi<{ emails: any[] }>(`/emails/search?${searchParams.toString()}`, { token });
},
};
// Compose/Drafts API
export const composeApi = {
listDrafts: (accountId: string | undefined, token: string) => {
const params = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ drafts: any[] }>(`/drafts${params}`, { token });
},
getDraft: (id: string, token: string) => fetchApi<{ draft: any }>(`/drafts/${id}`, { token }),
createDraft: (data: any, token: string) =>
fetchApi<{ draft: any }>('/drafts', { method: 'POST', body: JSON.stringify(data), token }),
updateDraft: (id: string, data: any, token: string) =>
fetchApi<{ draft: any }>(`/drafts/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
token,
}),
deleteDraft: (id: string, token: string) =>
fetchApi<void>(`/drafts/${id}`, { method: 'DELETE', token }),
sendDraft: (id: string, token: string) =>
fetchApi<{ success: boolean }>(`/drafts/${id}/send`, { method: 'POST', token }),
send: (data: any, token: string) =>
fetchApi<{ success: boolean }>('/send', { method: 'POST', body: JSON.stringify(data), token }),
createReply: (emailId: string, token: string) =>
fetchApi<{ draft: any }>(`/emails/${emailId}/reply`, { method: 'POST', token }),
createReplyAll: (emailId: string, token: string) =>
fetchApi<{ draft: any }>(`/emails/${emailId}/reply-all`, { method: 'POST', token }),
createForward: (emailId: string, token: string) =>
fetchApi<{ draft: any }>(`/emails/${emailId}/forward`, { method: 'POST', token }),
};
// Labels API
export const labelsApi = {
list: (token: string) => fetchApi<{ labels: any[] }>('/labels', { token }),
create: (data: { name: string; color: string }, token: string) =>
fetchApi<{ label: any }>('/labels', { method: 'POST', body: JSON.stringify(data), token }),
update: (id: string, data: { name?: string; color?: string }, token: string) =>
fetchApi<{ label: any }>(`/labels/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
token,
}),
delete: (id: string, token: string) =>
fetchApi<void>(`/labels/${id}`, { method: 'DELETE', token }),
addToEmail: (emailId: string, labelIds: string[], token: string) =>
fetchApi<void>(`/labels/email/${emailId}/add`, {
method: 'POST',
body: JSON.stringify({ labelIds }),
token,
}),
removeFromEmail: (emailId: string, labelIds: string[], token: string) =>
fetchApi<void>(`/labels/email/${emailId}/remove`, {
method: 'POST',
body: JSON.stringify({ labelIds }),
token,
}),
};

View file

@ -0,0 +1,47 @@
{
"name": "@mail/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-profile-ui": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,394 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
/* Mail-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Mail-specific */
--email-row-height: 56px;
--sidebar-width: 240px;
--detail-panel-width: 400px;
}
}
/* Email List Styles */
.email-row {
height: var(--email-row-height);
display: flex;
align-items: center;
padding: 0 var(--spacing-md);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.email-row:hover {
background-color: hsl(var(--color-muted) / 0.3);
}
.email-row.selected {
background-color: hsl(var(--color-primary) / 0.1);
}
.email-row.unread {
font-weight: 600;
}
.email-row.unread .email-subject {
color: hsl(var(--color-foreground));
}
/* Email Checkbox */
.email-checkbox {
width: 20px;
height: 20px;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.email-checkbox:checked {
background-color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
/* Star button */
.star-button {
padding: var(--spacing-xs);
border-radius: var(--radius-full);
transition: all var(--transition-fast);
color: hsl(var(--color-muted-foreground));
}
.star-button:hover {
background-color: hsl(var(--color-muted) / 0.5);
}
.star-button.starred {
color: hsl(45 93% 47%);
}
/* Email Avatar */
.email-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
color: white;
flex-shrink: 0;
}
/* Email Content */
.email-content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.email-subject {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--color-muted-foreground));
}
.email-snippet {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
/* Folder List */
.folder-item {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
color: hsl(var(--color-foreground));
}
.folder-item:hover {
background-color: hsl(var(--color-muted) / 0.5);
}
.folder-item.active {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.folder-badge {
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.625rem;
padding: 2px 6px;
border-radius: var(--radius-full);
font-weight: 600;
}
/* Compose Button */
.compose-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-md);
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-radius: var(--radius-lg);
font-weight: 600;
transition: all var(--transition-base);
}
.compose-button:hover {
background-color: hsl(var(--color-primary) / 0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
/* Email Detail Panel */
.email-detail {
background-color: hsl(var(--color-surface));
border-left: 1px solid hsl(var(--color-border));
height: 100%;
overflow-y: auto;
}
.email-detail-header {
padding: var(--spacing-lg);
border-bottom: 1px solid hsl(var(--color-border));
}
.email-detail-body {
padding: var(--spacing-lg);
}
/* AI Summary Card */
.ai-summary-card {
background: linear-gradient(135deg, hsl(var(--color-primary) / 0.1), hsl(var(--color-secondary) / 0.1));
border: 1px solid hsl(var(--color-primary) / 0.2);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.ai-summary-card .label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
margin-bottom: var(--spacing-xs);
}
/* Smart Reply Chips */
.smart-reply-chip {
display: inline-flex;
padding: var(--spacing-sm) var(--spacing-md);
background-color: hsl(var(--color-muted));
border-radius: var(--radius-full);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.smart-reply-chip:hover {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
/* Category Badge */
.category-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.category-badge.work {
background-color: hsl(217 91% 60% / 0.1);
color: hsl(217 91% 60%);
}
.category-badge.personal {
background-color: hsl(142 76% 36% / 0.1);
color: hsl(142 76% 36%);
}
.category-badge.newsletter {
background-color: hsl(262 83% 58% / 0.1);
color: hsl(262 83% 58%);
}
.category-badge.transactional {
background-color: hsl(31 97% 52% / 0.1);
color: hsl(31 97% 52%);
}
.category-badge.promotional {
background-color: hsl(350 89% 60% / 0.1);
color: hsl(350 89% 60%);
}
/* Label Chip */
.label-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
/* Card styles */
.card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
cursor: pointer;
border: none;
background: transparent;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--color-foreground));
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
.btn-icon {
padding: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Input styles */
.input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* Scrollbar styling */
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mail</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,99 @@
import { fetchApi } from './client';
export interface EmailAccount {
id: string;
userId: string;
name: string;
email: string;
provider: 'gmail' | 'outlook' | 'imap';
isDefault: boolean;
syncEnabled: boolean;
lastSyncAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateImapAccountDto {
name: string;
email: string;
password: string;
imapHost: string;
imapPort: number;
imapSecurity?: 'ssl' | 'starttls' | 'none';
smtpHost: string;
smtpPort: number;
smtpSecurity?: 'ssl' | 'starttls' | 'none';
}
export interface UpdateAccountDto {
name?: string;
syncEnabled?: boolean;
}
export const accountsApi = {
async list() {
return fetchApi<{ accounts: EmailAccount[] }>('/accounts');
},
async get(id: string) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}`);
},
async create(data: CreateImapAccountDto) {
return fetchApi<{ account: EmailAccount }>('/accounts', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateAccountDto) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
method: 'DELETE',
});
},
async setDefault(id: string) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}/default`, {
method: 'POST',
});
},
async testConnection(id: string) {
return fetchApi<{ success: boolean; error?: string }>(`/accounts/${id}/test`, {
method: 'POST',
});
},
async test(data: CreateImapAccountDto) {
return fetchApi<{ success: boolean; message?: string }>('/accounts/test', {
method: 'POST',
body: data,
});
},
async sync(id: string) {
return fetchApi<{ success: boolean; newEmails: number }>(`/sync/accounts/${id}`, {
method: 'POST',
});
},
// OAuth
async initGoogleOAuth() {
return fetchApi<{ authUrl: string }>('/oauth/google/init', {
method: 'POST',
});
},
async initMicrosoftOAuth() {
return fetchApi<{ authUrl: string }>('/oauth/microsoft/init', {
method: 'POST',
});
},
};

View file

@ -0,0 +1,65 @@
/**
* API Client for Mail Backend
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}

View file

@ -0,0 +1,119 @@
import { fetchApi } from './client';
import type { EmailAddress } from './emails';
export interface Draft {
id: string;
accountId: string;
userId: string;
replyToEmailId: string | null;
replyType: 'reply' | 'reply-all' | 'forward' | null;
subject: string | null;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[] | null;
bccAddresses: EmailAddress[] | null;
bodyHtml: string | null;
bodyPlain: string | null;
scheduledAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateDraftDto {
accountId: string;
subject?: string;
toAddresses?: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
scheduledAt?: string;
}
export interface UpdateDraftDto {
subject?: string;
toAddresses?: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
scheduledAt?: string;
}
export interface SendEmailDto {
accountId: string;
subject?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
}
export const composeApi = {
// Drafts
async listDrafts(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ drafts: Draft[]; total: number }>(`/drafts${query}`);
},
async getDraft(id: string) {
return fetchApi<{ draft: Draft }>(`/drafts/${id}`);
},
async createDraft(data: CreateDraftDto) {
return fetchApi<{ draft: Draft }>('/drafts', {
method: 'POST',
body: data,
});
},
async updateDraft(id: string, data: UpdateDraftDto) {
return fetchApi<{ draft: Draft }>(`/drafts/${id}`, {
method: 'PATCH',
body: data,
});
},
async deleteDraft(id: string) {
return fetchApi<{ success: boolean }>(`/drafts/${id}`, {
method: 'DELETE',
});
},
async sendDraft(id: string) {
return fetchApi<{ success: boolean; messageId?: string }>(`/drafts/${id}/send`, {
method: 'POST',
});
},
// Direct send
async send(data: SendEmailDto) {
return fetchApi<{ success: boolean; messageId?: string }>('/send', {
method: 'POST',
body: data,
});
},
// Reply/Forward
async createReply(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/reply`, {
method: 'POST',
});
},
async createReplyAll(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/reply-all`, {
method: 'POST',
});
},
async createForward(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/forward`, {
method: 'POST',
});
},
};

View file

@ -0,0 +1,140 @@
import { fetchApi } from './client';
export interface EmailAddress {
email: string;
name?: string;
}
export interface Email {
id: string;
accountId: string;
folderId: string | null;
userId: string;
threadId: string | null;
messageId: string;
externalId: string | null;
subject: string | null;
fromAddress: string | null;
fromName: string | null;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[] | null;
snippet: string | null;
bodyPlain: string | null;
bodyHtml: string | null;
sentAt: string | null;
receivedAt: string | null;
isRead: boolean;
isStarred: boolean;
isDraft: boolean;
hasAttachments: boolean;
aiSummary: string | null;
aiCategory: string | null;
aiPriority: string | null;
aiSuggestedReplies: { text: string; tone: string }[] | null;
createdAt: string;
updatedAt: string;
}
export interface EmailFilters {
accountId?: string;
folderId?: string;
isRead?: boolean;
isStarred?: boolean;
search?: string;
limit?: number;
offset?: number;
}
export interface UpdateEmailDto {
isRead?: boolean;
isStarred?: boolean;
folderId?: string;
}
export interface BatchOperation {
operation: 'markRead' | 'markUnread' | 'star' | 'unstar' | 'move' | 'delete';
emailIds: string[];
targetFolderId?: string;
}
export const emailsApi = {
async list(filters: EmailFilters = {}) {
const params = new URLSearchParams();
if (filters.accountId) params.set('accountId', filters.accountId);
if (filters.folderId) params.set('folderId', filters.folderId);
if (filters.isRead !== undefined) params.set('isRead', String(filters.isRead));
if (filters.isStarred !== undefined) params.set('isStarred', String(filters.isStarred));
if (filters.search) params.set('search', filters.search);
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.offset) params.set('offset', String(filters.offset));
const query = params.toString() ? `?${params.toString()}` : '';
return fetchApi<{ emails: Email[]; total: number }>(`/emails${query}`);
},
async search(query: string, accountId?: string) {
const params = new URLSearchParams({ q: query });
if (accountId) params.set('accountId', accountId);
return fetchApi<{ emails: Email[] }>(`/emails/search?${params.toString()}`);
},
async get(id: string) {
return fetchApi<{ email: Email }>(`/emails/${id}`);
},
async getThread(id: string) {
return fetchApi<{ emails: Email[] }>(`/emails/${id}/thread`);
},
async update(id: string, data: UpdateEmailDto) {
return fetchApi<{ email: Email }>(`/emails/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/emails/${id}`, {
method: 'DELETE',
});
},
async move(id: string, targetFolderId: string) {
return fetchApi<{ email: Email }>(`/emails/${id}/move`, {
method: 'POST',
body: { targetFolderId },
});
},
async batch(operation: BatchOperation) {
return fetchApi<{ affected: number }>('/emails/batch', {
method: 'POST',
body: operation,
});
},
// AI Features
async summarize(id: string) {
return fetchApi<{ summary: string; keyPoints?: string[] }>(`/emails/${id}/summarize`, {
method: 'POST',
});
},
async suggestReplies(id: string) {
return fetchApi<{ replies: { text: string; tone: string }[] }>(
`/emails/${id}/suggest-replies`,
{
method: 'POST',
}
);
},
async categorize(id: string) {
return fetchApi<{ category: string; confidence: number; priority: string }>(
`/emails/${id}/categorize`,
{
method: 'POST',
}
);
},
};

View file

@ -0,0 +1,68 @@
import { fetchApi } from './client';
export interface Folder {
id: string;
accountId: string;
userId: string;
name: string;
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
path: string;
unreadCount: number;
totalCount: number;
isSystem: boolean;
isHidden: boolean;
createdAt: string;
}
export interface CreateFolderDto {
accountId: string;
name: string;
type?: string;
}
export interface UpdateFolderDto {
name?: string;
}
export const foldersApi = {
async list(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ folders: Folder[] }>(`/folders${query}`);
},
async get(id: string) {
return fetchApi<{ folder: Folder }>(`/folders/${id}`);
},
async create(data: CreateFolderDto) {
return fetchApi<{ folder: Folder }>('/folders', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateFolderDto) {
return fetchApi<{ folder: Folder }>(`/folders/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/folders/${id}`, {
method: 'DELETE',
});
},
async toggleHide(id: string) {
return fetchApi<{ folder: Folder }>(`/folders/${id}/hide`, {
method: 'POST',
});
},
async sync(accountId: string, folderId: string) {
return fetchApi<{ emails: number }>(`/sync/accounts/${accountId}/folders/${folderId}`, {
method: 'POST',
});
},
};

View file

@ -0,0 +1,78 @@
import { fetchApi } from './client';
export interface Label {
id: string;
userId: string;
accountId: string | null;
name: string;
color: string;
createdAt: string;
}
export interface CreateLabelDto {
name: string;
color: string;
accountId?: string;
}
export interface UpdateLabelDto {
name?: string;
color?: string;
}
export const labelsApi = {
async list(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ labels: Label[] }>(`/labels${query}`);
},
async get(id: string) {
return fetchApi<{ label: Label }>(`/labels/${id}`);
},
async create(data: CreateLabelDto) {
return fetchApi<{ label: Label }>('/labels', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateLabelDto) {
return fetchApi<{ label: Label }>(`/labels/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/labels/${id}`, {
method: 'DELETE',
});
},
// Email-Label associations
async getEmailLabels(emailId: string) {
return fetchApi<{ labels: Label[] }>(`/labels/email/${emailId}`);
},
async addToEmail(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/add`, {
method: 'POST',
body: { labelIds },
});
},
async removeFromEmail(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/remove`, {
method: 'POST',
body: { labelIds },
});
},
async setEmailLabels(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/set`, {
method: 'POST',
body: { labelIds },
});
},
};

Some files were not shown because too many files have changed in this diff Show more