mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
✨ 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:
parent
79dd56403e
commit
604727c8f9
127 changed files with 15653 additions and 0 deletions
300
apps/mail/CLAUDE.md
Normal file
300
apps/mail/CLAUDE.md
Normal 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
|
||||
15
apps/mail/apps/backend/drizzle.config.ts
Normal file
15
apps/mail/apps/backend/drizzle.config.ts
Normal 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,
|
||||
});
|
||||
10
apps/mail/apps/backend/nest-cli.json
Normal file
10
apps/mail/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false
|
||||
}
|
||||
}
|
||||
67
apps/mail/apps/backend/package.json
Normal file
67
apps/mail/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
129
apps/mail/apps/backend/src/account/account.controller.ts
Normal file
129
apps/mail/apps/backend/src/account/account.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
10
apps/mail/apps/backend/src/account/account.module.ts
Normal file
10
apps/mail/apps/backend/src/account/account.module.ts
Normal 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 {}
|
||||
179
apps/mail/apps/backend/src/account/account.service.ts
Normal file
179
apps/mail/apps/backend/src/account/account.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
107
apps/mail/apps/backend/src/account/dto/account.dto.ts
Normal file
107
apps/mail/apps/backend/src/account/dto/account.dto.ts
Normal 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;
|
||||
}
|
||||
30
apps/mail/apps/backend/src/ai/ai.controller.ts
Normal file
30
apps/mail/apps/backend/src/ai/ai.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
apps/mail/apps/backend/src/ai/ai.module.ts
Normal file
10
apps/mail/apps/backend/src/ai/ai.module.ts
Normal 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 {}
|
||||
270
apps/mail/apps/backend/src/ai/ai.service.ts
Normal file
270
apps/mail/apps/backend/src/ai/ai.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
36
apps/mail/apps/backend/src/app.module.ts
Normal file
36
apps/mail/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
10
apps/mail/apps/backend/src/attachment/attachment.module.ts
Normal file
10
apps/mail/apps/backend/src/attachment/attachment.module.ts
Normal 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 {}
|
||||
195
apps/mail/apps/backend/src/attachment/attachment.service.ts
Normal file
195
apps/mail/apps/backend/src/attachment/attachment.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
45
apps/mail/apps/backend/src/attachment/dto/attachment.dto.ts
Normal file
45
apps/mail/apps/backend/src/attachment/dto/attachment.dto.ts
Normal 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;
|
||||
}
|
||||
108
apps/mail/apps/backend/src/compose/compose.controller.ts
Normal file
108
apps/mail/apps/backend/src/compose/compose.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
apps/mail/apps/backend/src/compose/compose.module.ts
Normal file
13
apps/mail/apps/backend/src/compose/compose.module.ts
Normal 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 {}
|
||||
363
apps/mail/apps/backend/src/compose/compose.service.ts
Normal file
363
apps/mail/apps/backend/src/compose/compose.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
161
apps/mail/apps/backend/src/compose/dto/compose.dto.ts
Normal file
161
apps/mail/apps/backend/src/compose/dto/compose.dto.ts
Normal 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;
|
||||
}
|
||||
38
apps/mail/apps/backend/src/db/connection.ts
Normal file
38
apps/mail/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
apps/mail/apps/backend/src/db/database.module.ts
Normal file
28
apps/mail/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
25
apps/mail/apps/backend/src/db/schema/attachments.schema.ts
Normal file
25
apps/mail/apps/backend/src/db/schema/attachments.schema.ts
Normal 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;
|
||||
33
apps/mail/apps/backend/src/db/schema/drafts.schema.ts
Normal file
33
apps/mail/apps/backend/src/db/schema/drafts.schema.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
88
apps/mail/apps/backend/src/db/schema/emails.schema.ts
Normal file
88
apps/mail/apps/backend/src/db/schema/emails.schema.ts
Normal 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;
|
||||
33
apps/mail/apps/backend/src/db/schema/folders.schema.ts
Normal file
33
apps/mail/apps/backend/src/db/schema/folders.schema.ts
Normal 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;
|
||||
6
apps/mail/apps/backend/src/db/schema/index.ts
Normal file
6
apps/mail/apps/backend/src/db/schema/index.ts
Normal 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';
|
||||
30
apps/mail/apps/backend/src/db/schema/labels.schema.ts
Normal file
30
apps/mail/apps/backend/src/db/schema/labels.schema.ts
Normal 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;
|
||||
98
apps/mail/apps/backend/src/email/dto/email.dto.ts
Normal file
98
apps/mail/apps/backend/src/email/dto/email.dto.ts
Normal 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[];
|
||||
}
|
||||
172
apps/mail/apps/backend/src/email/email.controller.ts
Normal file
172
apps/mail/apps/backend/src/email/email.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
12
apps/mail/apps/backend/src/email/email.module.ts
Normal file
12
apps/mail/apps/backend/src/email/email.module.ts
Normal 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 {}
|
||||
358
apps/mail/apps/backend/src/email/email.service.ts
Normal file
358
apps/mail/apps/backend/src/email/email.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
57
apps/mail/apps/backend/src/folder/dto/folder.dto.ts
Normal file
57
apps/mail/apps/backend/src/folder/dto/folder.dto.ts
Normal 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;
|
||||
}
|
||||
88
apps/mail/apps/backend/src/folder/folder.controller.ts
Normal file
88
apps/mail/apps/backend/src/folder/folder.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/mail/apps/backend/src/folder/folder.module.ts
Normal file
10
apps/mail/apps/backend/src/folder/folder.module.ts
Normal 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 {}
|
||||
200
apps/mail/apps/backend/src/folder/folder.service.ts
Normal file
200
apps/mail/apps/backend/src/folder/folder.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
apps/mail/apps/backend/src/health/health.controller.ts
Normal file
13
apps/mail/apps/backend/src/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/mail/apps/backend/src/health/health.module.ts
Normal file
7
apps/mail/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
45
apps/mail/apps/backend/src/label/dto/label.dto.ts
Normal file
45
apps/mail/apps/backend/src/label/dto/label.dto.ts
Normal 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[];
|
||||
}
|
||||
107
apps/mail/apps/backend/src/label/label.controller.ts
Normal file
107
apps/mail/apps/backend/src/label/label.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
10
apps/mail/apps/backend/src/label/label.module.ts
Normal file
10
apps/mail/apps/backend/src/label/label.module.ts
Normal 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 {}
|
||||
194
apps/mail/apps/backend/src/label/label.service.ts
Normal file
194
apps/mail/apps/backend/src/label/label.service.ts
Normal 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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
apps/mail/apps/backend/src/main.ts
Normal file
40
apps/mail/apps/backend/src/main.ts
Normal 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();
|
||||
146
apps/mail/apps/backend/src/oauth/google-oauth.service.ts
Normal file
146
apps/mail/apps/backend/src/oauth/google-oauth.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
190
apps/mail/apps/backend/src/oauth/microsoft-oauth.service.ts
Normal file
190
apps/mail/apps/backend/src/oauth/microsoft-oauth.service.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
150
apps/mail/apps/backend/src/oauth/oauth.controller.ts
Normal file
150
apps/mail/apps/backend/src/oauth/oauth.controller.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/mail/apps/backend/src/oauth/oauth.module.ts
Normal file
13
apps/mail/apps/backend/src/oauth/oauth.module.ts
Normal 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 {}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
307
apps/mail/apps/backend/src/sync/providers/gmail.provider.ts
Normal file
307
apps/mail/apps/backend/src/sync/providers/gmail.provider.ts
Normal 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+/),
|
||||
};
|
||||
}
|
||||
}
|
||||
304
apps/mail/apps/backend/src/sync/providers/imap.provider.ts
Normal file
304
apps/mail/apps/backend/src/sync/providers/imap.provider.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
242
apps/mail/apps/backend/src/sync/providers/outlook.provider.ts
Normal file
242
apps/mail/apps/backend/src/sync/providers/outlook.provider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
38
apps/mail/apps/backend/src/sync/sync.controller.ts
Normal file
38
apps/mail/apps/backend/src/sync/sync.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
16
apps/mail/apps/backend/src/sync/sync.module.ts
Normal file
16
apps/mail/apps/backend/src/sync/sync.module.ts
Normal 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 {}
|
||||
425
apps/mail/apps/backend/src/sync/sync.service.ts
Normal file
425
apps/mail/apps/backend/src/sync/sync.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
25
apps/mail/apps/backend/tsconfig.json
Normal file
25
apps/mail/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
apps/mail/apps/landing/astro.config.mjs
Normal file
8
apps/mail/apps/landing/astro.config.mjs
Normal 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()],
|
||||
});
|
||||
26
apps/mail/apps/landing/package.json
Normal file
26
apps/mail/apps/landing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
95
apps/mail/apps/landing/src/components/Footer.astro
Normal file
95
apps/mail/apps/landing/src/components/Footer.astro
Normal 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">
|
||||
© {currentYear} ManaMail. Alle Rechte vorbehalten.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💜 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
101
apps/mail/apps/landing/src/components/Navigation.astro
Normal file
101
apps/mail/apps/landing/src/components/Navigation.astro
Normal 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>
|
||||
50
apps/mail/apps/landing/src/layouts/Layout.astro
Normal file
50
apps/mail/apps/landing/src/layouts/Layout.astro
Normal 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>
|
||||
278
apps/mail/apps/landing/src/pages/index.astro
Normal file
278
apps/mail/apps/landing/src/pages/index.astro
Normal 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>
|
||||
103
apps/mail/apps/landing/src/styles/global.css
Normal file
103
apps/mail/apps/landing/src/styles/global.css
Normal 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;
|
||||
}
|
||||
37
apps/mail/apps/landing/tailwind.config.mjs
Normal file
37
apps/mail/apps/landing/tailwind.config.mjs
Normal 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')],
|
||||
};
|
||||
9
apps/mail/apps/landing/tsconfig.json
Normal file
9
apps/mail/apps/landing/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6
apps/mail/apps/landing/wrangler.toml
Normal file
6
apps/mail/apps/landing/wrangler.toml
Normal 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"
|
||||
58
apps/mail/apps/mobile/app.json
Normal file
58
apps/mail/apps/mobile/app.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
207
apps/mail/apps/mobile/app/(drawer)/_layout.tsx
Normal file
207
apps/mail/apps/mobile/app/(drawer)/_layout.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
184
apps/mail/apps/mobile/app/(drawer)/drafts.tsx
Normal file
184
apps/mail/apps/mobile/app/(drawer)/drafts.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
362
apps/mail/apps/mobile/app/(drawer)/index.tsx
Normal file
362
apps/mail/apps/mobile/app/(drawer)/index.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
168
apps/mail/apps/mobile/app/(drawer)/sent.tsx
Normal file
168
apps/mail/apps/mobile/app/(drawer)/sent.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
187
apps/mail/apps/mobile/app/(drawer)/starred.tsx
Normal file
187
apps/mail/apps/mobile/app/(drawer)/starred.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
183
apps/mail/apps/mobile/app/(drawer)/trash.tsx
Normal file
183
apps/mail/apps/mobile/app/(drawer)/trash.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
73
apps/mail/apps/mobile/app/_layout.tsx
Normal file
73
apps/mail/apps/mobile/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
415
apps/mail/apps/mobile/app/accounts.tsx
Normal file
415
apps/mail/apps/mobile/app/accounts.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
11
apps/mail/apps/mobile/app/auth/_layout.tsx
Normal file
11
apps/mail/apps/mobile/app/auth/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
apps/mail/apps/mobile/app/auth/login.tsx
Normal file
255
apps/mail/apps/mobile/app/auth/login.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
274
apps/mail/apps/mobile/app/auth/register.tsx
Normal file
274
apps/mail/apps/mobile/app/auth/register.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
216
apps/mail/apps/mobile/app/auth/reset-password.tsx
Normal file
216
apps/mail/apps/mobile/app/auth/reset-password.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
296
apps/mail/apps/mobile/app/compose.tsx
Normal file
296
apps/mail/apps/mobile/app/compose.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
389
apps/mail/apps/mobile/app/email/[id].tsx
Normal file
389
apps/mail/apps/mobile/app/email/[id].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
213
apps/mail/apps/mobile/app/settings.tsx
Normal file
213
apps/mail/apps/mobile/app/settings.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
231
apps/mail/apps/mobile/context/AuthProvider.tsx
Normal file
231
apps/mail/apps/mobile/context/AuthProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
apps/mail/apps/mobile/global.css
Normal file
3
apps/mail/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
78
apps/mail/apps/mobile/package.json
Normal file
78
apps/mail/apps/mobile/package.json
Normal 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
|
||||
}
|
||||
319
apps/mail/apps/mobile/store/composeStore.ts
Normal file
319
apps/mail/apps/mobile/store/composeStore.ts
Normal 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 }),
|
||||
}));
|
||||
284
apps/mail/apps/mobile/store/emailsStore.ts
Normal file
284
apps/mail/apps/mobile/store/emailsStore.ts
Normal 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 }),
|
||||
}));
|
||||
10
apps/mail/apps/mobile/tailwind.config.js
Normal file
10
apps/mail/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
118
apps/mail/apps/mobile/theme/ThemeProvider.tsx
Normal file
118
apps/mail/apps/mobile/theme/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
apps/mail/apps/mobile/tsconfig.json
Normal file
20
apps/mail/apps/mobile/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
169
apps/mail/apps/mobile/utils/api.ts
Normal file
169
apps/mail/apps/mobile/utils/api.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
47
apps/mail/apps/web/package.json
Normal file
47
apps/mail/apps/web/package.json
Normal 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"
|
||||
}
|
||||
394
apps/mail/apps/web/src/app.css
Normal file
394
apps/mail/apps/web/src/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/mail/apps/web/src/app.html
Normal file
13
apps/mail/apps/web/src/app.html
Normal 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>
|
||||
99
apps/mail/apps/web/src/lib/api/accounts.ts
Normal file
99
apps/mail/apps/web/src/lib/api/accounts.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
65
apps/mail/apps/web/src/lib/api/client.ts
Normal file
65
apps/mail/apps/web/src/lib/api/client.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
119
apps/mail/apps/web/src/lib/api/compose.ts
Normal file
119
apps/mail/apps/web/src/lib/api/compose.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
140
apps/mail/apps/web/src/lib/api/emails.ts
Normal file
140
apps/mail/apps/web/src/lib/api/emails.ts
Normal 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',
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
68
apps/mail/apps/web/src/lib/api/folders.ts
Normal file
68
apps/mail/apps/web/src/lib/api/folders.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
78
apps/mail/apps/web/src/lib/api/labels.ts
Normal file
78
apps/mail/apps/web/src/lib/api/labels.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue