feat(01-01): create email blueprint with encrypted Tortoise ORM models

- Add EncryptedTextField for transparent Fernet encryption
- Create EmailAccount model with encrypted IMAP credentials
- Create EmailSyncStatus model for sync state tracking
- Create Email model with 30-day retention logic
- Follow existing blueprint patterns from users/conversation
This commit is contained in:
2026-02-08 09:08:32 -05:00
parent 800c6fef7f
commit bee63d1c60
3 changed files with 200 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
"""
Email blueprint for IMAP email ingestion.
Provides API endpoints for managing email accounts and querying email content.
Admin-only access enforced via lldap_admin group membership.
"""
from quart import Blueprint
# Import models for Tortoise ORM registration
from . import models # noqa: F401
# Create blueprint
email_blueprint = Blueprint("email", __name__, url_prefix="/api/email")
# Routes will be added in Phase 2

View File

@@ -0,0 +1,68 @@
"""
Encryption service for email credentials.
Provides transparent Fernet encryption for sensitive fields in the database.
"""
import os
from cryptography.fernet import Fernet
from tortoise import fields
class EncryptedTextField(fields.TextField):
"""
Custom Tortoise ORM field that transparently encrypts/decrypts text values.
Uses Fernet symmetric encryption with a key from FERNET_KEY environment variable.
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Load encryption key from environment
key = os.getenv("FERNET_KEY")
if not key:
raise ValueError(
"FERNET_KEY environment variable required for encrypted fields. "
'Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
try:
self.fernet = Fernet(key.encode())
except Exception as e:
raise ValueError(f"Invalid FERNET_KEY format: {e}")
def to_db_value(self, value: str, instance) -> str:
"""Encrypt value before storing in database."""
if value is None:
return None
# Encrypt and return as URL-safe base64 string
return self.fernet.encrypt(value.encode()).decode()
def to_python_value(self, value: str) -> str:
"""Decrypt value when loading from database."""
if value is None:
return None
# Decrypt Fernet token
return self.fernet.decrypt(value.encode()).decode()
def validate_fernet_key():
"""
Validate that FERNET_KEY is set and functional.
Raises:
ValueError: If key is missing or invalid
"""
key = os.getenv("FERNET_KEY")
if not key:
raise ValueError("FERNET_KEY environment variable not set")
try:
f = Fernet(key.encode())
# Test encryption/decryption cycle
test_value = b"test_encryption"
encrypted = f.encrypt(test_value)
decrypted = f.decrypt(encrypted)
if decrypted != test_value:
raise ValueError("Encryption/decryption test failed")
except Exception as e:
raise ValueError(f"FERNET_KEY validation failed: {e}")

116
blueprints/email/models.py Normal file
View File

@@ -0,0 +1,116 @@
"""
Database models for email ingestion.
Provides EmailAccount, EmailSyncStatus, and Email models for storing
IMAP account configuration, sync tracking, and email metadata.
"""
from datetime import datetime, timedelta
from tortoise.models import Model
from tortoise import fields
from .crypto_service import EncryptedTextField
class EmailAccount(Model):
"""
Email account configuration for IMAP connections.
Stores account credentials with encrypted password, connection settings,
and account status. Supports multiple accounts per user.
"""
id = fields.UUIDField(primary_key=True)
user = fields.ForeignKeyField("models.User", related_name="email_accounts")
# Account identification
email_address = fields.CharField(max_length=255, unique=True)
display_name = fields.CharField(max_length=255, null=True)
# IMAP connection settings
imap_host = fields.CharField(max_length=255) # e.g., imap.gmail.com
imap_port = fields.IntField(default=993)
imap_username = fields.CharField(max_length=255)
imap_password = EncryptedTextField() # Transparently encrypted
# Account status
is_active = fields.BooleanField(default=True)
last_error = fields.TextField(null=True)
# Timestamps
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "email_accounts"
class EmailSyncStatus(Model):
"""
Tracks sync progress and state per email account.
Maintains last sync timestamp, last processed message UID,
and failure tracking to support incremental sync and error handling.
"""
id = fields.UUIDField(primary_key=True)
account = fields.ForeignKeyField(
"models.EmailAccount", related_name="sync_status", unique=True
)
# Sync state tracking
last_sync_date = fields.DatetimeField(null=True)
last_message_uid = fields.IntField(default=0) # IMAP UID of last fetched message
message_count = fields.IntField(default=0) # Messages fetched in last sync
# Error tracking
consecutive_failures = fields.IntField(default=0)
last_failure_date = fields.DatetimeField(null=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "email_sync_status"
class Email(Model):
"""
Email message metadata and content.
Stores parsed email data with 30-day retention. Links to ChromaDB
for vector search capabilities.
"""
id = fields.UUIDField(primary_key=True)
account = fields.ForeignKeyField("models.EmailAccount", related_name="emails")
# Email metadata (RFC822 headers)
message_id = fields.CharField(
max_length=255, unique=True, index=True
) # RFC822 Message-ID
subject = fields.CharField(max_length=500)
from_address = fields.CharField(max_length=255)
to_address = fields.TextField() # May contain multiple recipients
date = fields.DatetimeField()
# Email body content
body_text = fields.TextField(null=True) # Plain text version
body_html = fields.TextField(null=True) # HTML version
# Vector store integration
chromadb_doc_id = fields.CharField(
max_length=255, null=True
) # Reference to ChromaDB document
# Retention management
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField() # Auto-set to created_at + 30 days
class Meta:
table = "emails"
async def save(self, *args, **kwargs):
"""Override save to auto-set expiration date if not provided."""
if not self.expires_at:
self.expires_at = datetime.now() + timedelta(days=30)
await super().save(*args, **kwargs)