From bee63d1c60e98ffbdd6cd1e903a5cb6e29169304 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Sun, 8 Feb 2026 09:08:32 -0500 Subject: [PATCH] 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 --- blueprints/email/__init__.py | 16 ++++ blueprints/email/crypto_service.py | 68 +++++++++++++++++ blueprints/email/models.py | 116 +++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) create mode 100644 blueprints/email/__init__.py create mode 100644 blueprints/email/crypto_service.py create mode 100644 blueprints/email/models.py diff --git a/blueprints/email/__init__.py b/blueprints/email/__init__.py new file mode 100644 index 0000000..707c0d9 --- /dev/null +++ b/blueprints/email/__init__.py @@ -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 diff --git a/blueprints/email/crypto_service.py b/blueprints/email/crypto_service.py new file mode 100644 index 0000000..a0d00b3 --- /dev/null +++ b/blueprints/email/crypto_service.py @@ -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}") diff --git a/blueprints/email/models.py b/blueprints/email/models.py new file mode 100644 index 0000000..4d362db --- /dev/null +++ b/blueprints/email/models.py @@ -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)