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:
16
blueprints/email/__init__.py
Normal file
16
blueprints/email/__init__.py
Normal 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
|
||||
68
blueprints/email/crypto_service.py
Normal file
68
blueprints/email/crypto_service.py
Normal 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
116
blueprints/email/models.py
Normal 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)
|
||||
Reference in New Issue
Block a user