๐๏ธ Architecture
The application follows a modular architecture with clear separation between different layers and components. The architecture is designed to support both the Thunderbird for Android and K-9 Mail applications while maximizing code reuse, maintainability and enable adoption of Kotlin Multiplatform in the future.
๐ Key Architectural Principles
- ๐ Multi platform Compatibility: The architecture is designed to support future Kotlin Multiplatform adoption
- ๐ฑ Offline-First: The application is designed to work offline with local data storage and synchronization with remote servers
- ๐งฉ Modularity: The application is divided into distinct modules with clear responsibilities
- ๐ Separation of Concerns: Each module focuses on a specific aspect of the application
- โฌ๏ธ Dependency Inversion: Higher-level modules do not depend on lower-level modules directly
- ๐ฏ Single Responsibility: Each component has a single responsibility
- ๐ API/Implementation Separation: Clear separation between public APIs and implementation details
- ๐งน Clean Architecture: Separation of UI, domain, and data layers
- ๐งช Testability: The architecture facilitates comprehensive testing at all levels
๐ Architecture Decision Records
The Architecture Decision Records document the architectural decisions made during the development of the project, providing context and rationale for key technical choices. Reading through these decisions will improve your contributions and ensure long-term maintainability of the project.
๐ฆ Module Structure
The application is organized into several module types:
- ๐ฑ App Modules:
app-thunderbird
andapp-k9mail
- Application entry points - ๐ App Common:
app-common
- Shared code between applications - โจ Feature Modules:
feature:*
- Independent feature modules - ๐งฐ Core Modules:
core:*
- Foundational components and utilities used across multiple features - ๐ Library Modules:
library:*
- Specific implementations for reuse - ๐ Legacy Modules: Legacy code being gradually migrated
For more details on the module organization and structure, see the Module Organization and Module Structure documents.
๐งฉ Architectural Patterns
The architecture follows several key patterns to ensure maintainability, testability, and separation of concerns:
๐ API/Implementation Separation
Each module should be split into two main parts: API and implementation. This separation provides clear boundaries between what a module exposes to other modules and how it implements its functionality internally:
- ๐ API: Public interfaces, models, and contracts
- โ๏ธ Implementation: Concrete implementations of the interfaces
This separation provides clear boundaries, improves testability, and enables flexibility.
See API Module and Implementation Module for more details.
Clean Architecture
Thunderbird for Android uses Clean Architecture with three main layers (UI, domain, and data) to break down complex feature implementation into manageable components. Each layer has a specific responsibility:
graph TD subgraph UI[UI Layer] UI_COMPONENTS[UI Components] VIEW_MODEL[ViewModels] end subgraph DOMAIN["Domain Layer"] USE_CASE[Use Cases] REPO[Repositories] end subgraph DATA[Data Layer] DATA_SOURCE[Data Sources] API[API Clients] DB[Local Database] end UI_COMPONENTS --> VIEW_MODEL VIEW_MODEL --> USE_CASE USE_CASE --> REPO REPO --> DATA_SOURCE DATA_SOURCE --> API DATA_SOURCE --> DB classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000 classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 linkStyle default stroke:#999,stroke-width:2px class UI ui_layer class UI_COMPONENTS,VIEW_MODEL ui_class class DOMAIN domain_layer class USE_CASE,REPO domain_class class DATA data_layer class DATA_SOURCE,API,DB data_class
๐ผ๏ธ UI Layer (Presentation)
The UI layer is responsible for displaying data to the user and handling user interactions.
Key Components:
- ๐จ Compose UI: Screen components built with Jetpack Compose
- ๐ง ViewModels: Manage UI state and handle UI events
- ๐ UI State: Immutable data classes representing the UI state
- ๐ฎ Events: User interactions or system events that trigger state changes
- ๐ Effects: One-time side effects like navigation or showing messages
Pattern: Model-View-Intent (MVI)
- ๐ Model: UI state representing the current state of the screen
- ๐๏ธ View: Compose UI that renders the state
- ๐ฎ Event: User interactions that trigger state changes (equivalent to โIntentโ in standard MVI)
- ๐ Effect: One-time side effects like navigation or notifications
๐ง Domain Layer (Business Logic)
The domain layer contains the business logic and rules of the application. It is independent of the UI and data layers, allowing for easy testing and reuse.
Key Components:
- โ๏ธ Use Cases: Encapsulate business logic operations
- ๐ Domain Models: Represent business entities
- ๐ Repository Interfaces: Define data access contracts
graph TB subgraph DOMAIN[Domain Layer] USE_CASE[Use Cases] MODEL[Domain Models] REPO_API[Repository Interfaces] end subgraph DATA[Data Layer] REPO_IMPL[Repository Implementations] end USE_CASE --> |uses| REPO_API USE_CASE --> |uses| MODEL REPO_API --> |uses| MODEL REPO_IMPL --> |implements| REPO_API REPO_IMPL --> |uses| MODEL classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 linkStyle default stroke:#999,stroke-width:2px class DOMAIN domain_layer class USE_CASE,REPO_API,MODEL domain_class class DATA data_layer class REPO_IMPL data_class
๐พ Data Layer
The data layer is responsible for data retrieval, storage, and synchronization.
Key Components:
- ๐ฆ Repository implementations: Implement repository interfaces from the domain layer
- ๐ Data Sources: Provide data from specific sources (API, database, preferences)
- ๐ Data Transfer Objects: Represent data at the data layer
Pattern: Data Source Pattern
- ๐ Abstracts data sources behind a clean API
- Maps data between domain models and data transfer objects
graph TD subgraph DOMAIN[Domain Layer] REPO_API[Repository] end subgraph DATA[Data Layer] REPO_IMPL[Repository implementations] RDS[Remote Data Sources] LDS[Local Data Sources] MAPPER[Data Mappers] DTO[Data Transfer Objects] end REPO_IMPL --> |implements| REPO_API REPO_IMPL --> RDS REPO_IMPL --> LDS REPO_IMPL --> MAPPER RDS --> MAPPER LDS --> MAPPER MAPPER --> DTO classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 linkStyle default stroke:#999,stroke-width:2px class DOMAIN domain_layer class REPO_API domain_class class DATA data_layer class REPO_IMPL,RDS,LDS,MAPPER,DTO data_class
๐ Immutability
Immutability means that once an object is created, it cannot be changed. Instead of modifying existing objects, new objects are created with the desired changes. In the context of UI state, this means that each state object represents a complete snapshot of the UI at a specific point in time.
Why is Immutability Important?
Immutability provides several benefits:
- Predictability: With immutable state, the UI can only change when a new state object is provided, making the flow of data more predictable and easier to reason about.
- Debugging: Each state change creates a new state object, making it easier to track changes and debug issues by comparing state objects.
- Concurrency: Immutable objects are thread-safe by nature, eliminating many concurrency issues.
- Performance: While creating new objects might seem inefficient, modern frameworks optimize this process, and the benefits of immutability often outweigh the costs.
- Time-travel debugging: Immutability enables storing previous states, allowing developers to โtime travelโ back to previous application states during debugging.
๐จ UI Architecture
The UI is built using Jetpack Compose with a component-based architecture following our modified Model-View-Intent (MVI) pattern. This architecture provides a unidirectional data flow, clear separation of concerns, and improved testability.
For detailed information about the UI architecture and theming, see the UI Architecture and Theme System documents.
๐ฑ Offline-First Approach
The application implements an offline-first Approach to provide a reliable user experience regardless of network conditions:
- ๐พ Local database as the single source of truth
- ๐ Background synchronization with remote servers
- ๐ Operation queueing for network operations
- ๐ Conflict resolution for data modified both locally and remotely
Implementation Approach
graph LR subgraph UI[UI Layer] VIEW_MODEL[ViewModel] end subgraph DOMAIN[Domain Layer] USE_CASE[Use Cases] end subgraph DATA[Data Layer] subgraph SYNC[Synchronization] SYNC_MANAGER[Sync Manager] SYNC_QUEUE[Sync Queue] end REPO[Repository] LOCAL[Local Data Source] REMOTE[Remote Data Source] end VIEW_MODEL --> USE_CASE USE_CASE --> REPO SYNC_MANAGER --> LOCAL SYNC_MANAGER --> REMOTE SYNC_MANAGER --> SYNC_QUEUE REPO --> LOCAL REPO --> REMOTE REPO --> SYNC_MANAGER REPO ~~~ SYNC classDef ui_layer fill:#d9e9ff,stroke:#000000,color:#000000 classDef ui_class fill:#4d94ff,stroke:#000000,color:#000000 classDef domain_layer fill:#d9ffd9,stroke:#000000,color:#000000 classDef domain_class fill:#33cc33,stroke:#000000,color:#000000 classDef data_layer fill:#ffe6cc,stroke:#000000,color:#000000 classDef data_class fill:#ffaa33,stroke:#000000,color:#000000 classDef sync_layer fill:#e6cce6,stroke:#000000,color:#000000 classDef sync_class fill:#cc99cc,stroke:#000000,color:#000000 linkStyle default stroke:#999,stroke-width:2px class UI ui_layer class VIEW_MODEL ui_class class DOMAIN domain_layer class USE_CASE domain_class class DATA data_layer class REPO,LOCAL,REMOTE data_class class SYNC sync_layer class SYNC_MANAGER,SYNC_API,SYNC_QUEUE sync_class
The offline-first approach is implemented across all layers of the application:
- ๐พ Data Layer:
- ๐ Local database as the primary data source
- ๐ Remote data source for server communication
- ๐ฆ Repository pattern to coordinate between data sources
- ๐ Synchronization manager to handle data syncing
- ๐ง Domain Layer:
- โ๏ธ Use cases handle both online and offline scenarios
- ๐ Business logic accounts for potential network unavailability
- ๐ Domain models represent data regardless of connectivity state
- ๐ผ๏ธ UI Layer:
- ๐ง ViewModels expose UI state that reflects connectivity status
- ๐ฆ UI components display appropriate indicators for offline mode
- ๐ User interactions are designed to work regardless of connectivity
๐ Dependency Injection
The application uses Koin for dependency injection, with modules organized by feature:
- ๐ฑ App Modules: Configure application-wide dependencies
- ๐ App Common: Shared dependencies between applications
- โจ Feature Modules: Configure feature-specific dependencies
- ๐งฐ Core Modules: Configure core dependencies
// Example Koin module for a feature
val featureModule = module {
viewModel { FeatureViewModel(get()) }
single<FeatureRepository> { FeatureRepositoryImpl(get(), get()) }
single<FeatureUseCase> { FeatureUseCaseImpl(get()) }
single<FeatureApiClient> { FeatureApiClientImpl() }
}
๐ Cross-Cutting Concerns
Cross-cutting concerns are aspects of the application that affect multiple features and cannot be cleanly handled individually for every feature. These concerns require consistent implementation throughout the codebase to ensure maintainability an reliability.
In Thunderbird for Android, several cross-cutting concerns are implemented as dedicated core modules to provide standardized solutions that can be reused across the application:
- โ ๏ธ Error Handling: Comprehensive error handling (
core/outcome
) transforms exceptions into domain-specific errors and provides user-friendly feedback. - ๐ Logging: Centralized logging system (
core/logging
) ensures consistent log formatting, levels, and storage. - ๐ Security: Modules like
core/security
handle encryption, authentication, and secure data storage.
Work in progress:
- ๐ Encryption: The
core/crypto
module provides encryption and decryption utilities for secure data handling. - ๐ฆ Feature Flags: The
core/feature-flags
module manages feature toggles and experimental features. - ๐ Synchronization: The
core/sync
module manages background synchronization, conflict resolution, and offline-first behavior. - ๐ ๏ธ Configuration Management: Centralized handling of application settings and environment-specific configurations.
By implementing these concerns as core modules, the application achieves a clean and modular architecture that is easier to maintain and extend.
โ ๏ธ Error Handling
The application implements a comprehensive error handling strategy across all layers. We favor using the Outcome pattern over exceptions for expected error conditions, while exceptions are reserved for truly exceptional situations that indicate programming errors or unrecoverable system failures.
- ๐ง Domain Errors: Encapsulate business logic errors as sealed classes, ensuring clear representation of specific error cases.
- ๐พ Data Errors: Transform network or database exceptions into domain-specific errors using result patterns in repository implementations.
- ๐ผ๏ธ UI Error Handling: Provide user-friendly error feedback by:
- Mapping domain errors to UI state in ViewModels.
- Displaying actionable error states in Compose UI components.
- Offering retry options for network connectivity issues.
note
Exceptions should be used sparingly. Favor the Outcome pattern and sealed classes for predictable error conditions to enhance maintainability and clarity.
๐ ๏ธ How to Implement Error Handling
When implementing error handling in your code:
-
Define domain-specific errors as sealed classes in your featureโs domain layer:
sealed class AccountError { data class AuthenticationFailed(val reason: String) : AccountError() data class NetworkError(val exception: Exception) : AccountError() data class ValidationError(val field: String, val message: String) : AccountError() }
-
Use result patterns (Outcome) instead of exceptions for error handling:
// Use the Outcome class for representing success or failure sealed class Outcome<out T, out E> { data class Success<T>(val value: T) : Outcome<T, Nothing>() data class Failure<E>(val error: E) : Outcome<Nothing, E>() }
-
Transform external errors into domain errors in your repositories using result patterns:
// Return Outcome instead of throwing exceptions fun authenticate(credentials: Credentials): Outcome<AuthResult, AccountError> { return try { val result = apiClient.authenticate(credentials) Outcome.Success(result) } catch (e: HttpException) { val error = when (e.code()) { 401 -> AccountError.AuthenticationFailed("Invalid credentials") else -> AccountError.NetworkError(e) } logger.error(e) { "Authentication failed: ${error::class.simpleName}" } Outcome.Failure(error) } catch (e: Exception) { logger.error(e) { "Authentication failed with unexpected error" } Outcome.Failure(AccountError.NetworkError(e)) } }
-
Handle errors in Use Cases by propagating the Outcome:
class LoginUseCase( private val accountRepository: AccountRepository, private val credentialValidator: CredentialValidator, ) { fun execute(credentials: Credentials): Outcome<AuthResult, AccountError> { // Validate input first val validationResult = credentialValidator.validate(credentials) if (validationResult is ValidationResult.Failure) { return Outcome.Failure( AccountError.ValidationError( field = validationResult.field, message = validationResult.message ) ) } // Proceed with authentication return accountRepository.authenticate(credentials) } }
-
Handle outcomes in ViewModels and transform them into UI state:
viewModelScope.launch { val outcome = loginUseCase.execute(credentials) when (outcome) { is Outcome.Success -> { _uiState.update { it.copy(isLoggedIn = true) } } is Outcome.Failure -> { val errorMessage = when (val error = outcome.error) { is AccountError.AuthenticationFailed -> stringProvider.getString(R.string.error_authentication_failed, error.reason) is AccountError.NetworkError -> stringProvider.getString(R.string.error_network, error.exception.message) is AccountError.ValidationError -> stringProvider.getString(R.string.error_validation, error.field, error.message) } _uiState.update { it.copy(error = errorMessage) } } } }
-
Always log errors for debugging purposes:
// Logging is integrated into the Outcome pattern fun fetchMessages(): Outcome<List<Message>, MessageError> { return try { val messages = messageService.fetchMessages() logger.info { "Successfully fetched ${messages.size} messages" } Outcome.Success(messages) } catch (e: Exception) { logger.error(e) { "Failed to fetch messages" } Outcome.Failure(MessageError.FetchFailed(e)) } }
-
Compose multiple operations that return Outcomes:
fun synchronizeAccount(): Outcome<SyncResult, SyncError> { // First operation val messagesOutcome = fetchMessages() if (messagesOutcome is Outcome.Failure) { return Outcome.Failure(SyncError.MessageSyncFailed(messagesOutcome.error)) } // Second operation using the result of the first val messages = messagesOutcome.getOrNull()!! val folderOutcome = updateFolders(messages) if (folderOutcome is Outcome.Failure) { return Outcome.Failure(SyncError.FolderUpdateFailed(folderOutcome.error)) } // Return success with combined results return Outcome.Success( SyncResult( messageCount = messages.size, folderCount = folderOutcome.getOrNull()!!.size ) ) }
๐ Logging
The application uses a structured logging system with a well-defined API:
- ๐ Logging Architecture:
- Core logging API (
core/logging/api
) defines interfaces likeLogger
andLogSink
- Multiple implementations (composite, console) allow for flexible logging targets
- Composite implementation enables logging to multiple sinks simultaneously
- Core logging API (
- ๐ Logger vs. Sink:
- Logger: The front-facing interface that application code interacts with to create log entries
- Provides methods for different log levels (verbose, debug, info, warn, error)
- Handles the creation of log events with appropriate metadata (timestamp, tag, etc.)
- Example:
DefaultLogger
implements theLogger
interface and delegates to aLogSink
- LogSink: The back-end component that receives log events and determines how to process them
- Defines where and how log messages are actually stored or displayed
- Filters log events based on configured log levels
- Can be implemented in various ways (console output, file storage, remote logging service)
- Multiple sinks can be used simultaneously via composite pattern
- Logger: The front-facing interface that application code interacts with to create log entries
- ๐ Log Levels:
VERBOSE
: Most detailed log level for debuggingDEBUG
: Detailed information for diagnosing problemsINFO
: General information about application flowWARN
: Potential issues that donโt affect functionalityERROR
: Issues that affect functionality but donโt crash the application
๐ ๏ธ How to Implement Logging
When adding logging to your code:
-
Inject a Logger into your class:
class AccountRepository( private val apiClient: ApiClient, private val logger: Logger, ) { // Repository implementation }
-
Choose the appropriate log level based on the importance of the information:
- Use
verbose
for detailed debugging information (only visible in debug builds) - Use
debug
for general debugging information - Use
info
for important events that should be visible in production - Use
warn
for potential issues that donโt affect functionality - Use
error
for issues that affect functionality
- Use
-
Use lambda syntax to avoid string concatenation when logging isnโt needed:
// Good - string is only created if this log level is enabled logger.debug { "Processing message with ID: $messageId" } // Avoid - string is always created even if debug logging is disabled logger.debug("Processing message with ID: " + messageId)
-
Include relevant context in log messages:
logger.info { "Syncing account: ${account.email}, folders: ${folders.size}" }
-
Log exceptions with the appropriate level and context:
try { apiClient.fetchMessages() } catch (e: Exception) { logger.error(e) { "Failed to fetch messages for account: ${account.email}" } throw MessageSyncError.FetchFailed(e) }
-
Use tags for better filtering when needed:
private val logTag = LogTag("AccountSync") fun syncAccount() { logger.info(logTag) { "Starting account sync for: ${account.email}" } }
๐ Security
Security is a critical aspect of an email client. The application implements:
- ๐ Data Encryption:
- End-to-end encryption using OpenPGP (via the
legacy/crypto-openpgp
module) - Classes like
EncryptionDetector
andOpenPgpEncryptionExtractor
handle encrypted emails - Local storage encryption for sensitive data like account credentials
- End-to-end encryption using OpenPGP (via the
- ๐ Authentication:
- Support for various authentication types (OAuth, password, client certificate)
- Secure token storage and management
- Authentication error handling and recovery
- ๐ก๏ธ Network Security:
- TLS for all network connections with certificate validation
- Certificate pinning for critical connections
- Protection against MITM attacks
note
This section is a work in progress. The security architecture is being developed and will be documented in detail as it evolves.
๐ ๏ธ How to Implement Security
When implementing security features in your code:
-
Never store sensitive data in plain text:
// Bad - storing password in plain text sharedPreferences.putString("password", password) // Good - use the secure credential storage val credentialStorage = get<CredentialStorage>() credentialStorage.storeCredentials(accountUuid, credentials)
-
Use encryption for sensitive data:
// For data that needs to be stored encrypted val encryptionManager = get<EncryptionManager>() val encryptedData = encryptionManager.encrypt(sensitiveData) database.storeEncryptedData(encryptedData)
-
Validate user input to prevent injection attacks:
// Validate input before using it if (!InputValidator.isValidEmailAddress(userInput)) { throw ValidationError("Invalid email address") }
-
Use secure network connections:
// The networking modules enforce TLS by default // Make sure to use the provided clients rather than creating your own val networkClient = get<NetworkClient>()
๐งช Testing Strategy
The architecture supports comprehensive testing:
- ๐ฌ Unit Tests: Test individual components in isolation
- ๐ Integration Tests: Test interactions between components
- ๐ฑ UI Tests: Test the UI behavior and user flows
See the Testing guide document for more details on how to write and run tests for the application.
๐ Legacy Integration
The application includes legacy code that is gradually being migrated to the new architecture:
- ๐ฆ Legacy Modules: Contain code from the original K-9 Mail application
- ๐ Migration Strategy: Gradual migration to the new architecture
- ๐ Integration Points: Clear interfaces between legacy and new code
For more details on the legacy integration, see the Legacy Integration document.
๐ User Flows
The User Flows provides visual representations of typical user flows through the application, helping to understand how different components interact.