20 KiB
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
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-IDheaderX-User-IDheader (must match :id)
Response (200):
{
"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 headers403 Forbidden: Accessing another user's preferences500 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-IDheaderX-User-IDheader (must match :id)Content-Type: application/json
Request Body (all fields optional, partial updates supported):
{
"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": "<p>Best regards,<br>John Doe</p>",
"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):
{
"status": "success",
"data": {
"id": "pref-uuid-123",
"version": 2,
"theme": { ... },
...
}
}
Errors:
400 Bad Request: Invalid request payload or validation failed401 Unauthorized: Missing/invalid authentication headers403 Forbidden: Accessing another user's preferences404 Not Found: User not found409 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-IDheaderX-User-IDheader (must match :id)
Response (200):
{
"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-IDheaderX-User-IDheader (must match :id)
Request Body: Same as PUT /preferences
Response (200 - valid):
{
"status": "success",
"valid": true,
"message": "Preferences payload is valid"
}
Response (200 - invalid):
{
"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:
- Header-based tenant identification:
X-Tenant-IDrequired on all requests - User scope limitation: Users can only access/modify their own preferences
- Database isolation: Unique constraint
(tenant_id, user_id, is_deleted) - Query filtering: All DB queries filter by
tenant_idanduser_id
Example:
# 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:
- Client retrieves preferences with
version: 1 - Client modifies and sends update with
version: 1 - Server checks: if actual version != 1, return 409 Conflict
- Server increments version on successful update (1 → 2)
- Client handles conflict and retries with new version
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":
- Reset endpoint marks old preferences as
is_deleted: true - Queries filter out deleted preferences
- Deleted preferences can be recovered by database admin if needed
- 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:
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:
-
TestGetPreferences(9 tests)- Default creation
- Retrieval of existing
- Missing headers
- Forbidden access
- Multi-tenant isolation
-
TestUpdatePreferences(24 tests)- Theme, localization, sync, notifications
- Signature, privacy, storage, templates
- Advanced settings
- Validation errors
- Version conflicts
- Partial updates
-
TestResetPreferences(2 tests)- Reset to defaults
- Forbidden access
-
TestValidatePreferences(4 tests)- Valid payloads
- Invalid payloads
- Missing body
- Forbidden access
-
TestPreferencesMultiTenant(1 test)- Tenant isolation
-
TestPreferencesValidationRules(13 parametrized tests)- Theme modes, colors
- Sync frequencies, scopes
- All validation rules
Total: 53+ test cases
Running Tests
# 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
// 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
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
- Preferences Profiles: Save multiple settings profiles
- Sync across devices: Cloud-based preferences sync
- Admin overrides: Tenant admins override user settings
- Audit logging: Track preference changes
- AI recommendations: Suggest settings based on usage
- Backup/export: Allow users to export settings
- Template sharing: Share templates with colleagues
- 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)
# Cache invalidation on update
cache.delete(f'prefs:{tenant_id}:{user_id}')
Deployment Checklist
- Database migration:
UserPreferencestable 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