# Phase 7 User Preferences API ## Overview Complete user preferences/settings management system for email client. Enables users to customize: - Theme (light/dark mode, accent colors, density) - Localization (timezone, locale, date/time formats) - Sync frequency and background sync options - Notification preferences (new mail alerts, quiet hours) - Privacy settings (read receipts, signature, vacation mode) - Default folders and auto-filing rules - Signature and template management - Storage quota and auto-cleanup policies - Accessibility options (screen reader, high contrast) - Advanced features (AI, threading, telemetry) ## Architecture ### Database Model **Table**: `user_preferences` ```python UserPreferences( id: str # UUID primary key tenant_id: str # Multi-tenant isolation user_id: str # User identification # Theme & UI theme: str # 'light', 'dark', 'auto' accent_color: str # Hex color #RRGGBB compact_mode: bool show_preview_pane: bool message_density: str # 'compact', 'normal', 'spacious' high_contrast_mode: bool font_size_percent: int # 80-150 reduce_animations: bool # Localization timezone: str # IANA timezone (e.g., America/New_York) locale: str # BCP 47 locale (e.g., en_US) date_format: str # e.g., MMM d, yyyy time_format: str # e.g., h:mm a use_12hr_clock: bool # Sync Settings sync_enabled: bool sync_frequency_minutes: int # 1-1440 background_sync: bool offline_mode_enabled: bool sync_scope: str # 'all', 'last_30', 'last_90', 'last_180' sync_days_back: int # 1-365 # Notifications notifications_enabled: bool notify_new_mail: bool notify_on_error: bool notify_sound: bool notify_desktop_alerts: bool smart_notifications: bool quiet_hours_enabled: bool quiet_hours_start: str # HH:MM format quiet_hours_end: str # HH:MM format notification_categories: dict # {promotions, newsletters, social, important} # Privacy & Security read_receipts_enabled: bool send_read_receipts: bool mark_as_read_delay_ms: int pgp_enabled: bool pgp_key_id: str (nullable) s_mime_enabled: bool s_mime_cert_id: str (nullable) # Signature use_signature: bool signature_text: str (nullable) signature_html: str (nullable) signature_include_in_replies: bool signature_include_in_forwards: bool # Vacation Mode vacation_mode_enabled: bool vacation_message: str (nullable) vacation_start_date: BigInt (nullable) # Unix milliseconds vacation_end_date: BigInt (nullable) # Unix milliseconds vacation_notify_sender: bool # Folders & Templates default_inbox_folder_id: str (nullable) default_sent_folder_id: str (nullable) default_drafts_folder_id: str (nullable) default_trash_folder_id: str (nullable) auto_file_rules: list # [{sender, folder_id}, ...] signature_templates: dict # {account_id: {name, text, html}} quick_reply_templates: list # [{name, text}, ...] forwarding_rules: list # [{from, to}, ...] # Storage storage_quota_bytes: BigInt (nullable) storage_warning_percent: int # 1-99 auto_delete_spam_days: int (nullable) auto_delete_trash_days: int (nullable) compress_attachments: bool # Accessibility screen_reader_enabled: bool # Advanced enable_ai_features: bool enable_threaded_view: bool enable_conversation_mode: bool conversation_threading_strategy: str # 'auto', 'refs', 'subjects' debug_mode: bool enable_telemetry: bool custom_settings: dict # Extensible key-value pairs # Metadata is_deleted: bool version: int # Optimistic locking created_at: BigInt # Unix milliseconds updated_at: BigInt # Unix milliseconds ) ``` ### API Endpoints #### 1. Get User Preferences ``` GET /api/v1/users/:id/preferences ``` **Authentication**: Required - `X-Tenant-ID` header - `X-User-ID` header (must match :id) **Response** (200): ```json { "status": "success", "data": { "id": "pref-uuid-123", "tenantId": "tenant-uuid", "userId": "user-uuid", "version": 1, "theme": { "mode": "light", "accentColor": "#1976d2", "compactMode": false, "messageDensity": "normal", "showPreviewPane": true, "highContrastMode": false, "fontSizePercent": 100, "reduceAnimations": false }, "localization": { "timezone": "UTC", "locale": "en_US", "dateFormat": "MMM d, yyyy", "timeFormat": "h:mm a", "use12hrClock": true }, "sync": { "enabled": true, "frequencyMinutes": 5, "backgroundSyncEnabled": true, "offlineModeEnabled": false, "scope": "all", "daysBack": 30 }, "notifications": { "enabled": true, "newMail": true, "onError": true, "soundEnabled": true, "desktopAlertsEnabled": true, "smartNotifications": false, "quietHoursEnabled": false, "quietHoursStart": null, "quietHoursEnd": null, "categories": { "promotions": false, "newsletters": false, "social": true, "important": true } }, "privacy": { "readReceiptsEnabled": false, "sendReadReceipts": false, "markAsReadDelayMs": 2000, "pgpEnabled": false, "pgpKeyId": null, "smimeEnabled": false, "smimeCertId": null, "vacationModeEnabled": false, "vacationMessage": null, "vacationStartDate": null, "vacationEndDate": null, "vacationNotifySender": true }, "signature": { "enabled": false, "text": null, "html": null, "includeInReplies": true, "includeInForwards": false }, "folders": { "defaultInboxFolderId": null, "defaultSentFolderId": null, "defaultDraftsFolderId": null, "defaultTrashFolderId": null, "autoFileRules": [] }, "templates": { "signatureTemplates": {}, "quickReplyTemplates": [], "forwardingRules": [] }, "storage": { "quotaBytes": null, "warningPercent": 80, "autoDeleteSpamDays": null, "autoDeleteTrashDays": null, "compressAttachments": false }, "accessibility": { "screenReaderEnabled": false }, "advanced": { "enableAiFeatures": true, "enableThreadedView": true, "enableConversationMode": true, "conversationThreadingStrategy": "auto", "debugMode": false, "enableTelemetry": true, "customSettings": {} }, "isDeleted": false, "createdAt": 1706049000000, "updatedAt": 1706049000000 } } ``` **Errors**: - `401 Unauthorized`: Missing/invalid authentication headers - `403 Forbidden`: Accessing another user's preferences - `500 Internal Server Error`: Server error **Behavior**: - Creates preferences with defaults if they don't exist - Returns existing preferences on subsequent calls - Isolates by tenant_id (multi-tenant) --- #### 2. Update User Preferences ``` PUT /api/v1/users/:id/preferences ``` **Authentication**: Required - `X-Tenant-ID` header - `X-User-ID` header (must match :id) - `Content-Type: application/json` **Request Body** (all fields optional, partial updates supported): ```json { "version": 1, "theme": { "mode": "dark", "accentColor": "#2196f3", "compactMode": true, "messageDensity": "compact", "fontSizePercent": 110 }, "localization": { "timezone": "America/New_York", "locale": "fr_FR", "dateFormat": "d/MM/yyyy", "timeFormat": "HH:mm", "use12hrClock": false }, "sync": { "enabled": false, "frequencyMinutes": 30, "backgroundSyncEnabled": false, "scope": "last_90", "daysBack": 90 }, "notifications": { "enabled": false, "newMail": false, "soundEnabled": false, "smartNotifications": true, "quietHoursEnabled": true, "quietHoursStart": "22:00", "quietHoursEnd": "07:00", "categories": { "promotions": true, "newsletters": false, "social": true, "important": true } }, "privacy": { "readReceiptsEnabled": true, "sendReadReceipts": true, "vacationModeEnabled": true, "vacationMessage": "I'm out until Jan 25.", "vacationStartDate": 1706049000000, "vacationEndDate": 1706135400000 }, "signature": { "enabled": true, "text": "Best regards,\nJohn Doe", "html": "

Best regards,
John Doe

", "includeInReplies": true, "includeInForwards": false }, "folders": { "defaultInboxFolderId": "folder-123", "autoFileRules": [ {"sender": "noreply@company.com", "folder_id": "folder-456"} ] }, "templates": { "quickReplyTemplates": [ {"name": "thanks", "text": "Thanks!"}, {"name": "meeting", "text": "Let's schedule a meeting."} ] }, "storage": { "quotaBytes": 16000000000, "warningPercent": 75, "autoDeleteSpamDays": 30, "autoDeleteTrashDays": 7, "compressAttachments": true }, "advanced": { "enableAiFeatures": false, "conversationThreadingStrategy": "refs", "enableTelemetry": false } } ``` **Response** (200): ```json { "status": "success", "data": { "id": "pref-uuid-123", "version": 2, "theme": { ... }, ... } } ``` **Errors**: - `400 Bad Request`: Invalid request payload or validation failed - `401 Unauthorized`: Missing/invalid authentication headers - `403 Forbidden`: Accessing another user's preferences - `404 Not Found`: User not found - `409 Conflict`: Version mismatch (optimistic locking) - `500 Internal Server Error`: Server error **Validation Rules**: | Field | Rule | Example | |-------|------|---------| | `theme.mode` | 'light' \| 'dark' \| 'auto' | `"dark"` | | `theme.accentColor` | Hex color format | `"#2196f3"` | | `theme.messageDensity` | 'compact' \| 'normal' \| 'spacious' | `"compact"` | | `theme.fontSizePercent` | Integer 80-150 | `110` | | `localization.timezone` | IANA timezone string | `"America/New_York"` | | `localization.locale` | BCP 47 locale | `"en_US"` | | `sync.frequencyMinutes` | Integer 1-1440 | `15` | | `sync.scope` | 'all' \| 'last_30' \| 'last_90' \| 'last_180' | `"last_90"` | | `sync.daysBack` | Integer 1-365 | `90` | | `notifications.quietHoursEnabled` | Boolean | `true` | | `notifications.quietHoursStart` | HH:MM format string | `"22:00"` | | `notifications.quietHoursEnd` | HH:MM format string | `"07:00"` | | `signature.enabled` | If true, text or html required | `true` | | `privacy.vacationModeEnabled` | If true, message + dates required | `true` | | `privacy.vacationStartDate` | Unix milliseconds | `1706049000000` | | `privacy.vacationEndDate` | Unix ms (must be > start) | `1706135400000` | | `storage.warningPercent` | Integer 1-99 | `75` | | `storage.autoDeleteSpamDays` | Positive integer or null | `30` | | `advanced.conversationThreadingStrategy` | 'auto' \| 'refs' \| 'subjects' | `"refs"` | **Features**: - Partial updates supported (only send changed fields) - Optimistic locking with version field - Increments version automatically on save - Soft delete support (is_deleted flag) --- #### 3. Reset Preferences to Defaults ``` POST /api/v1/users/:id/preferences/reset ``` **Authentication**: Required - `X-Tenant-ID` header - `X-User-ID` header (must match :id) **Response** (200): ```json { "status": "success", "data": { "id": "pref-uuid-new", "version": 1, "theme": { "mode": "light", ... }, ... }, "message": "Preferences reset to defaults" } ``` **Behavior**: - Soft-deletes existing preferences - Creates new preferences with defaults - New ID and version = 1 --- #### 4. Validate Preferences Payload ``` POST /api/v1/users/:id/preferences/validate ``` **Purpose**: Validate preferences update without saving **Authentication**: Required - `X-Tenant-ID` header - `X-User-ID` header (must match :id) **Request Body**: Same as PUT /preferences **Response** (200 - valid): ```json { "status": "success", "valid": true, "message": "Preferences payload is valid" } ``` **Response** (200 - invalid): ```json { "status": "success", "valid": false, "error": "theme.accentColor must be hex color (e.g., #1976d2)" } ``` **Use Cases**: - Client-side validation before submit - Batch validation of multiple payloads - Testing validation rules --- ## Multi-Tenant Isolation All endpoints enforce multi-tenant isolation: 1. **Header-based tenant identification**: `X-Tenant-ID` required on all requests 2. **User scope limitation**: Users can only access/modify their own preferences 3. **Database isolation**: Unique constraint `(tenant_id, user_id, is_deleted)` 4. **Query filtering**: All DB queries filter by `tenant_id` and `user_id` Example: ```python # This will only return preferences for the authenticated user in their tenant preferences = UserPreferences.get_by_user(user_id, tenant_id) ``` --- ## Optimistic Locking Prevents lost updates in concurrent scenarios: 1. Client retrieves preferences with `version: 1` 2. Client modifies and sends update with `version: 1` 3. Server checks: if actual version != 1, return 409 Conflict 4. Server increments version on successful update (1 → 2) 5. Client handles conflict and retries with new version ```python if preferences.version != data['version']: return { 'error': 'Conflict', 'message': f'Version mismatch: expected {preferences.version}, got {data["version"]}' }, 409 ``` --- ## Soft Delete & Recovery Preferences can be recovered after "deletion": 1. Reset endpoint marks old preferences as `is_deleted: true` 2. Queries filter out deleted preferences 3. Deleted preferences can be recovered by database admin if needed 4. No permanent data loss (complies with GDPR requirements) --- ## Authentication & Authorization ### Multi-Level Security ``` Request → Authentication Check → Authorization Check → Business Logic ↓ ↓ Check headers exist Check user_id matches X-Tenant-ID authenticated user_id X-User-ID ``` ### Error Responses | Status | Scenario | Example | |--------|----------|---------| | 401 | Missing `X-Tenant-ID` or `X-User-ID` | User not authenticated | | 403 | `X-User-ID` != URL user_id | User accessing other's preferences | | 404 | User/preferences not found | Unusual (should have defaults) | --- ## Database Indexes Optimized for common queries: ```sql CREATE INDEX idx_user_preferences_tenant ON user_preferences(tenant_id); CREATE INDEX idx_user_preferences_user ON user_preferences(user_id); CREATE INDEX idx_user_preferences_tenant_user ON user_preferences(tenant_id, user_id); CREATE UNIQUE INDEX uq_user_preferences_tenant_user ON user_preferences(tenant_id, user_id, is_deleted); ``` --- ## Testing ### Test Coverage **Files**: `tests/test_preferences.py` **Test Classes**: 1. `TestGetPreferences` (9 tests) - Default creation - Retrieval of existing - Missing headers - Forbidden access - Multi-tenant isolation 2. `TestUpdatePreferences` (24 tests) - Theme, localization, sync, notifications - Signature, privacy, storage, templates - Advanced settings - Validation errors - Version conflicts - Partial updates 3. `TestResetPreferences` (2 tests) - Reset to defaults - Forbidden access 4. `TestValidatePreferences` (4 tests) - Valid payloads - Invalid payloads - Missing body - Forbidden access 5. `TestPreferencesMultiTenant` (1 test) - Tenant isolation 6. `TestPreferencesValidationRules` (13 parametrized tests) - Theme modes, colors - Sync frequencies, scopes - All validation rules **Total**: 53+ test cases ### Running Tests ```bash # All preference tests pytest tests/test_preferences.py -v # Specific test class pytest tests/test_preferences.py::TestGetPreferences -v # Specific test pytest tests/test_preferences.py::TestGetPreferences::test_get_preferences_creates_defaults -v # With coverage pytest tests/test_preferences.py --cov=src.routes.preferences --cov=src.models.preferences # Show coverage report pytest tests/test_preferences.py --cov --cov-report=html ``` --- ## Integration Examples ### JavaScript/React Example ```javascript // Get preferences const response = await fetch('/api/v1/users/user-123/preferences', { headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId, } }); const { data: preferences } = await response.json(); // Update theme const update = { theme: { mode: 'dark', accentColor: '#2196f3', } }; const updateResponse = await fetch('/api/v1/users/user-123/preferences', { method: 'PUT', headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId, 'Content-Type': 'application/json', }, body: JSON.stringify(update) }); // Handle version conflict if (updateResponse.status === 409) { const { data: latest } = await updateResponse.json(); // Retry with new version } // Validate before submit const validateResponse = await fetch('/api/v1/users/user-123/preferences/validate', { method: 'POST', headers: { 'X-Tenant-ID': tenantId, 'X-User-ID': userId, 'Content-Type': 'application/json', }, body: JSON.stringify(update) }); const { valid, error } = await validateResponse.json(); if (!valid) { console.error('Validation failed:', error); } ``` ### Python Example ```python import requests tenant_id = 'tenant-123' user_id = 'user-123' # Get preferences response = requests.get( f'http://localhost:5000/api/v1/users/{user_id}/preferences', headers={ 'X-Tenant-ID': tenant_id, 'X-User-ID': user_id, } ) preferences = response.json()['data'] # Update preferences update = { 'theme': { 'mode': 'dark', }, 'sync': { 'frequencyMinutes': 15, } } response = requests.put( f'http://localhost:5000/api/v1/users/{user_id}/preferences', json=update, headers={ 'X-Tenant-ID': tenant_id, 'X-User-ID': user_id, } ) if response.status_code == 409: # Version conflict - retry with new version latest = response.json()['data'] print(f"Conflict: version {latest['version']}") ``` --- ## Future Enhancements 1. **Preferences Profiles**: Save multiple settings profiles 2. **Sync across devices**: Cloud-based preferences sync 3. **Admin overrides**: Tenant admins override user settings 4. **Audit logging**: Track preference changes 5. **AI recommendations**: Suggest settings based on usage 6. **Backup/export**: Allow users to export settings 7. **Template sharing**: Share templates with colleagues 8. **Group policies**: IT policies enforce certain settings --- ## Performance Considerations ### Query Performance - Indexed lookups: O(1) by `(tenant_id, user_id)` - No full table scans - Soft delete handled by unique constraint ### Storage - ~2KB per user preferences record - Negligible database bloat even with millions of users ### Caching (Future) ```python # Cache invalidation on update cache.delete(f'prefs:{tenant_id}:{user_id}') ``` --- ## Deployment Checklist - [ ] Database migration: `UserPreferences` table created - [ ] Flask blueprint registered in `app.py` - [ ] Environment variables configured - [ ] Tests passing: `pytest tests/test_preferences.py` - [ ] Documentation reviewed - [ ] Authentication headers validated - [ ] Multi-tenant isolation verified - [ ] Validation rules tested - [ ] Rate limiting configured (if needed) - [ ] Monitoring/alerting configured --- ## Files | File | Purpose | |------|---------| | `src/models/preferences.py` | SQLAlchemy model definition | | `src/routes/preferences.py` | Flask route handlers | | `tests/test_preferences.py` | 53+ comprehensive test cases | | `PHASE_7_PREFERENCES_API.md` | This documentation | --- **Status**: Phase 7 Implementation Complete **Created**: 2026-01-24 **Last Updated**: 2026-01-24