Email Client Desktop App using Iced
Email Client Desktop App using Iced
Overview
Create a desktop email client application using Rust and the Iced GUI framework. This app will display email data from a SQLite3 database (generated separately in #173).
Depends on: #173 (Fake Data Generator)
Project Structure
This should be implemented as a separate binary crate in the Cargo workspace:
Binary name: dwata-app-iced
Workspace structure:
dwata/
├── Cargo.toml # Workspace manifest
├── dwata-db/ # Shared library (from #173)
├── fake-data-generator/ # Binary from #173
├── dwata-app-egui/ # Binary from #174
└── dwata-app-iced/ # THIS BINARY (Issue #175)
├── Cargo.toml
└── src/
├── main.rs
└── ui/
Cargo.toml for dwata-app-iced:
[package]
name = "dwata-app-iced"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "dwata-app-iced"
path = "src/main.rs"
[dependencies]
dwata-db = { path = "../dwata-db" } # Shared database library
iced = { version = "0.12", features = ["tokio", "image"] }
tokio = { version = "1", features = ["full"] }
dirs = "5.0"
Usage:
# Build and run the Iced email client
cargo run --bin dwata-app-iced
# Or build in release mode
cargo build --release --bin dwata-app-iced
./target/release/dwata-app-iced
Using the Shared dwata-db Library
IMPORTANT: This application should use the shared dwata-db library crate for ALL database operations. Do NOT create your own db/ module or duplicate any database code.
What dwata-db Provides
The dwata-db library includes:
-
Database Models - All Rust structs:
use dwata_db::{Account, Email, Contact, Folder, Label, Attachment}; -
EmailRepository - All database query functions:
use dwata_db::EmailRepository; let repo = EmailRepository::new(db_path)?; // Available methods: repo.get_accounts()?; repo.get_emails_by_folder(folder_id)?; repo.get_email_by_id(email_id)?; repo.search_emails_by_subject(query)?; repo.get_contacts(account_id)?; repo.get_labels(account_id)?; repo.get_folders(account_id)?; repo.get_attachments(email_id)?; repo.get_all_attachments()?; repo.mark_as_read(email_id)?; repo.toggle_star(email_id)?; -
Schema Creation (if needed):
use dwata_db::create_tables;
Benefits
- No code duplication - Database logic defined once
- Type safety - Shared types across all binaries
- Consistent behavior - Same queries used by all apps
- Easy maintenance - Changes in one place
Code Structure (Simplified)
Since you're using the shared library, your code structure should be:
dwata-app-iced/src/
├── main.rs # Entry point, Iced application
├── ui/
│ ├── mod.rs
│ ├── app.rs # Main Application impl
│ ├── messages.rs # Message enum
│ ├── navigation.rs # Navigation panel view logic
│ ├── email_list.rs # Email list view logic
│ ├── email_detail.rs # Email detail view logic
│ ├── contacts.rs # Contact views
│ ├── files.rs # Files browser view
│ └── styles.rs # Custom styling
└── utils/
└── time.rs # Timestamp formatting helpers
Note: No db/ module needed - use dwata_db directly!
Using with Iced's Async Tasks
Wrap the repository in Arc<Mutex<>> for async access:
use dwata_db::EmailRepository;
use std::sync::{Arc, Mutex};
// In your app initialization
let repo = Arc::new(Mutex::new(EmailRepository::new(db_path)?));
// In async task
async fn fetch_emails_async(
repo: Arc<Mutex<EmailRepository>>,
folder_id: i64
) -> Result<Vec<Email>, String> {
tokio::task::spawn_blocking(move || {
let repo = repo.lock().unwrap();
repo.get_emails_by_folder(folder_id)
.map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())?
}
Database Connection
Database Location
The app will read from the SQLite database at:
- Linux/macOS:
~/.config/dwata/db.sqlite3 - Windows:
%APPDATA%\dwata\db.sqlite3
Example path: /home/username/.config/dwata/db.sqlite3
Database Requirements
- The app does NOT create the database - it must already exist (created by the generator from #173)
- On startup: Check if database file exists at expected location
- If database not found: Display error message and exit gracefully
- Error message should show expected database path
- Suggest running the fake data generator first
Example Implementation
use dwata_db::EmailRepository;
use iced::{Application, Settings};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
fn get_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
let config_dir = dirs::config_dir()
.ok_or("Could not determine config directory")?;
let db_path = config_dir.join("dwata").join("db.sqlite3");
if !db_path.exists() {
return Err(format!(
"Database not found at: {}\nPlease run the fake data generator first.",
db_path.display()
).into());
}
Ok(db_path)
}
fn main() -> iced::Result {
let db_path = get_db_path().expect("Failed to get database path");
EmailClientApp::run(Settings::with_flags(db_path))
}
Architecture
Iced Application Pattern
Iced follows The Elm Architecture with four main concepts:
- State: Application state struct holding all data
- Messages: Enum of all possible events/actions
- Update Logic: Handle messages and update state
- View Logic: Render UI based on current state
Async Operations with Tasks
- Use
Taskfor async database operations - Tasks are returned from
update()function - Task results are converted back into messages
- Database queries run asynchronously without blocking UI
Message Flow
User interaction → Message
→ update() function receives message
→ update() modifies state and returns Task (if needed)
→ Task executes async operation (e.g., DB query)
→ Task completion produces new Message
→ update() receives result message
→ State updated → view() re-renders
Example:
enum Message {
FetchEmails(i64), // folder_id
EmailsLoaded(Result<Vec<Email>, String>),
EmailSelected(i64),
EmailDetailLoaded(Result<Email, String>),
// ... more messages
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::FetchEmails(folder_id) => {
return Task::perform(
fetch_emails_async(folder_id),
Message::EmailsLoaded
);
}
Message::EmailsLoaded(Ok(emails)) => {
self.current_email_list = emails;
Task::none()
}
// ... handle other messages
}
}
Subscriptions (Optional)
- Use
Subscriptionfor listening to external events - Can be used for periodic refreshes, WebSocket connections, etc.
- Subscriptions produce a stream of messages
User Interface Layout
3-Column Layout
Column 1: Navigation Panel (Left side, ~200-250px fixed width)
Account Selector:
- Dropdown (using
pick_listwidget) showing current account email - Click to show list of all accounts
- Switch between accounts
Navigation Links:
- "All Inboxes": Unified inbox view across all accounts
- "Labels" section:
- Section header (expandable/collapsible using
Column+Button) - List of top 10 labels below header
- Each label shows name and unread count badge
- "View all labels" link if more than 10 labels exist
- Section header (expandable/collapsible using
- "Contacts": Navigate to contacts list
- "Files": Browse all attachments
Visual Requirements:
- Each navigation item shows unread count (if applicable)
- Active/selected item highlighted (using
Buttonwith custom styling) - Hover states for interactive elements
Implementation: Use Column widget with Button widgets for each navigation item
Column 2: List View (Middle, flexible width)
Email List View (when folder/label selected):
-
Scrollable list (using
Scrollablewidget) -
Each email item displays across two rows:
First row:
- Checkbox (using
Checkboxwidget, unchecked by default) - Star icon (☆ for unstarred, ★ for starred) - use
Textor custom icon - Subject line (bold if unread using
Textstyling, regular weight if read)
Second row (indented to align with subject):
- Sender name/email (gray text, smaller font using
Textstyling) - Humanized timestamp (right-aligned, gray text, smaller font):
- Time if today (e.g., "2:30 PM")
- Short date if this year (e.g., "Jan 15")
- Full date if older (e.g., "Jan 15, 2023")
Visual separators:
- 1px horizontal separator between each email item (using
Rulewidget)
Optional future enhancements (not required in initial implementation):
- Body snippet (first ~100 characters, plain text)
- Paperclip icon (if has attachments)
- Label tags/chips (if labeled)
- Checkbox (using
-
Click on item → show in Column 3
-
Sort controls (optional for future):
- Date (newest first / oldest first)
- Sender (alphabetical)
- Subject (alphabetical)
Implementation: Use Scrollable containing Column of custom email item widgets. Each item uses Row and Column for layout.
Contact List View (when Contacts selected):
- Scrollable list
- Each contact item displays:
- Contact name (or email if no name)
- Email address
- Last interaction date (optional)
- Click on contact → show in Column 3
- Search/filter input at top (using
TextInputwidget)
Hidden when "Files" is selected (see Files View below)
Column 3: Detail View (Right, flexible width)
Email Detail View:
-
Header Section:
- Subject (large, prominent text using
Textwith large size) - From: sender name and email
- To: recipient list (expandable if many recipients)
- Cc: (if any, expandable)
- Date and time (full format)
- Action buttons (using
Buttonwidgets):- Reply
- Reply All
- Forward
- Delete
- Archive
- Star/Unstar
- Add Label
- Subject (large, prominent text using
-
Body Section:
- Email body (plain text using
TextorScrollablewithText) - Scrollable if content is long
- Preserve formatting
- Email body (plain text using
-
Attachments Section (if any):
- List of attachments
- Each shows: icon (based on file type), filename, size
- Action: View/Download button
Contact Detail View:
-
Contact Info:
- Name (large text)
- Email address
- Additional fields (if extended later: phone, address)
- Action buttons: Compose Email, Edit, Delete
-
Recent Emails:
- List of last 10 emails with this contact
- Click to view email details
Hidden when "Files" is selected (see Files View below)
Files View (Special Layout)
When "Files" is selected from Column 1:
- Columns 2 and 3 merge into single wide area
- Display all attachments across all emails
Layout:
- Scrollable grid or list view
- Each attachment item shows:
- File type icon (PDF, image, doc, etc.)
- Filename
- File size
- Source email subject (truncated)
- Date received
- Controls:
- Filter by file type dropdown (All, Documents, Images, Archives, etc.)
- Sort by: Name, Size, Date
- Search by filename (optional for future)
Top Navigation Bar
Search Bar:
- Prominent position (center or right side)
- Search by email subject (Phase 1)
- Shows results as-you-type or on Enter key
- Results displayed in Column 2 (email list format)
- Clear button to exit search
Implementation: Use TextInput widget with event handling
App Actions:
- Settings icon/button
- Sync/Refresh icon
- Other utility buttons as needed
Iced-Specific Implementation Details
Main Application Structure
use iced::{Element, Task, Application, Settings, Theme};
struct EmailClientApp {
// State
selected_account_id: Option<i64>,
selected_folder_id: Option<i64>,
selected_email_id: Option<i64>,
// Data
accounts: Vec<Account>,
current_email_list: Vec<Email>,
current_email: Option<Email>,
labels: Vec<Label>,
// UI state
search_query: String,
is_loading: bool,
error_message: Option<String>,
// Database connection (wrapped in Arc<Mutex<>> for thread safety)
db_connection: Arc<Mutex<Connection>>,
}
#[derive(Debug, Clone)]
enum Message {
// Account messages
LoadAccounts,
AccountsLoaded(Result<Vec<Account>, String>),
AccountSelected(i64),
// Email messages
FetchEmails { folder_id: i64 },
EmailsLoaded(Result<Vec<Email>, String>),
EmailSelected(i64),
EmailDetailLoaded(Result<Email, String>),
// Actions
ToggleStar(i64),
MarkAsRead(i64),
OperationComplete(Result<(), String>),
// UI events
SearchQueryChanged(String),
SearchSubmitted,
// ... more messages
}
impl Application for EmailClientApp {
type Executor = iced::executor::Default;
type Message = Message;
type Theme = Theme;
type Flags = PathBuf; // Database path
fn new(db_path: PathBuf) -> (Self, Task<Message>) {
// Initialize app with database connection
let app = Self {
// ... initialize fields
};
// Return app and initial Task to load accounts
(app, Task::perform(load_accounts_async(db_path), Message::AccountsLoaded))
}
fn title(&self) -> String {
String::from("Dwata Email Client")
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::LoadAccounts => {
self.is_loading = true;
Task::perform(
load_accounts_from_db(self.db_connection.clone()),
Message::AccountsLoaded
)
}
Message::AccountsLoaded(Ok(accounts)) => {
self.accounts = accounts;
self.is_loading = false;
Task::none()
}
Message::FetchEmails { folder_id } => {
self.is_loading = true;
Task::perform(
fetch_emails_from_db(self.db_connection.clone(), folder_id),
Message::EmailsLoaded
)
}
// ... handle other messages
_ => Task::none()
}
}
fn view(&self) -> Element<Message> {
// Build UI layout
// Use Container, Row, Column, Scrollable, etc.
let content = Row::new()
.push(self.navigation_panel())
.push(self.list_view())
.push(self.detail_view());
Container::new(content).into()
}
}
Database Operations
Create async functions for database operations:
async fn fetch_emails_from_db(
conn: Arc<Mutex<Connection>>,
folder_id: i64
) -> Result<Vec<Email>, String> {
tokio::task::spawn_blocking(move || {
let conn = conn.lock().unwrap();
// Perform SQLite query
// Return results
})
.await
.map_err(|e| e.to_string())?
}
Layout Helpers
impl EmailClientApp {
fn navigation_panel(&self) -> Element<Message> {
let account_picker = pick_list(
&self.accounts,
self.selected_account_id,
Message::AccountSelected
);
let labels_list = Column::new()
.push(Text::new("Labels"))
.extend(self.labels.iter().take(10).map(|label| {
button(Text::new(&label.name))
.on_press(Message::LabelSelected(label.id.unwrap()))
.into()
}));
Column::new()
.push(account_picker)
.push(labels_list)
.into()
}
fn list_view(&self) -> Element<Message> {
let email_items: Vec<Element<Message>> = self.current_email_list
.iter()
.flat_map(|(idx, email)| {
vec![
if *idx > 0 { Some(Rule::horizontal(1).into()) } else { None },
Some(self.email_item(email))
]
})
.flatten()
.collect();
Scrollable::new(Column::with_children(email_items))
.into()
}
fn email_item(&self, email: &Email) -> Element<Message> {
let checkbox = Checkbox::new("", false);
let star = Text::new(if email.is_starred { "★" } else { "☆" });
let subject = Text::new(&email.subject)
.size(if email.is_read { 14 } else { 16 })
.weight(if email.is_read { Font::Weight::Normal } else { Font::Weight::Bold });
let first_row = Row::new()
.push(checkbox)
.push(star)
.push(subject);
let sender = Text::new(email.from_name.as_ref().unwrap_or(&email.from_email))
.size(12)
.color(Color::from_rgb(0.5, 0.5, 0.5));
let timestamp = Text::new(humanize_timestamp(email.received_at))
.size(12)
.color(Color::from_rgb(0.5, 0.5, 0.5));
let second_row = Row::new()
.push(Space::with_width(Length::Fixed(50.0))) // Indent
.push(sender)
.push(Space::with_width(Length::Fill))
.push(timestamp);
button(
Column::new()
.push(first_row)
.push(second_row)
)
.on_press(Message::EmailSelected(email.id.unwrap()))
.style(if self.selected_email_id == email.id {
theme::Button::Primary
} else {
theme::Button::Secondary
})
.into()
}
fn detail_view(&self) -> Element<Message> {
// Render email detail or "Select an email" message
// ...
}
}
State Management
Application State
All state is held in the main EmailClientApp struct:
- Current selections (account, folder, email, contact IDs)
- Current view enum (Emails, Contacts, Files)
- Data (accounts, email list, current email, labels, contacts)
- UI state (search query, loading state, error messages)
- Database connection (Arc<Mutex<Connection>> for thread safety)
Message Handling
All events are represented as Message enum variants:
- User interactions (clicks, text input, selections)
- Async operation results (data loaded, errors)
- Operations (mark read, star, delete)
Database Layer
Connection Management
- Create SQLite connection at startup
- Wrap in
Arc<Mutex<>>for thread-safe access from async tasks - Pass cloned Arc to async functions
Async Database Operations
- Use
tokio::task::spawn_blockingfor blocking SQLite queries - Wrap database queries in async functions
- Return
Result<T, String>from async functions
Example Repository Functions
async fn get_emails_by_folder(
conn: Arc<Mutex<Connection>>,
folder_id: i64
) -> Result<Vec<Email>, String> {
tokio::task::spawn_blocking(move || {
let conn = conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT * FROM emails WHERE folder_id = ?1 ORDER BY received_at DESC"
)?;
// ... query and return results
})
.await
.map_err(|e| e.to_string())?
}
async fn mark_as_read(
conn: Arc<Mutex<Connection>>,
email_id: i64
) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
let conn = conn.lock().unwrap();
conn.execute(
"UPDATE emails SET is_read = 1 WHERE id = ?1",
params![email_id]
)?;
Ok(())
})
.await
.map_err(|e| e.to_string())?
}
Use models from #173: Same Rust structs (Account, Email, Contact, etc.)
Features Checklist
Core Features
- [ ] Database existence check on startup
- [ ] Graceful error handling if database not found
- [ ] Account switching (dropdown in navigation)
- [ ] Folder/label navigation
- [ ] Email list with scrolling (Column 2)
- [ ] Email detail view (Column 3)
- [ ] Contact list and detail view
- [ ] Files/attachments browser (merged columns)
- [ ] Search by email subject
- [ ] Responsive layout (handle window resize)
Email Actions
- [ ] Mark email as read/unread
- [ ] Star/unstar emails
- [ ] Select and view email
- [ ] View attachments metadata
UI/UX
- [ ] Smooth scrolling
- [ ] Loading indicators during data fetch
- [ ] Smooth transitions between views
- [ ] Keyboard navigation (optional: arrow keys, Enter to open)
- [ ] Unread count badges
- [ ] Smart timestamp formatting
Code Structure
src/
├── main.rs # Entry point, Iced app initialization, DB path check
├── db/
│ ├── mod.rs
│ ├── models.rs # Rust structs (same as #173)
│ └── operations.rs # Async database operations
├── ui/
│ ├── mod.rs
│ ├── app.rs # Main Application impl
│ ├── messages.rs # Message enum
│ ├── navigation.rs # Navigation panel view logic
│ ├── email_list.rs # Email list view logic
│ ├── email_detail.rs # Email detail view logic
│ ├── contacts.rs # Contact views
│ ├── files.rs # Files browser view
│ └── styles.rs # Custom styling
└── utils/
└── time.rs # Timestamp formatting helpers
Implementation Phases
Phase 1: Foundation
- [ ] Project setup with Iced
- [ ] Database path detection and existence check
- [ ] Database connection with Arc<Mutex<>>
- [ ] Basic async database operations
- [ ] Message enum and basic update logic
Phase 2: Basic UI
- [ ] 3-column layout skeleton
- [ ] Navigation panel (Column 1)
- [ ] Email list (Column 2, simple scrollable version)
- [ ] Email detail view (Column 3)
- [ ] Account switching
Phase 3: Advanced Features
- [ ] Improved email list styling (two-row layout)
- [ ] Contact views (list + detail)
- [ ] Files browser
- [ ] Search functionality
- [ ] Email actions (mark read, star)
Phase 4: Polish
- [ ] Loading states and error handling
- [ ] Custom styling and themes
- [ ] UI refinements (colors, spacing, icons)
- [ ] Keyboard shortcuts (optional)
Performance Targets
- Startup time: <2 seconds (including DB connection)
- Email list rendering: Smooth scrolling through thousands of emails
- Search response: <100ms for subject search
- View switching: <50ms to switch between folders/views
- Memory usage: <200MB for app with full dataset
- Responsiveness: UI never freezes during database operations
Success Criteria
- [ ] App checks for database and handles missing DB gracefully
- [ ] All features from checklist implemented
- [ ] Email list scrolls smoothly with thousands of emails
- [ ] UI remains responsive during all database operations
- [ ] Search returns results quickly (<100ms)
- [ ] App handles window resize gracefully
- [ ] Clean separation between UI and database layers
- [ ] Code is well-structured for future modifications
Technical Stack
- Language: Rust (latest stable)
- GUI Framework: Iced (latest version)
- Database: SQLite3 (via rusqlite)
- Async Runtime: Tokio (required by Iced for async operations)
- Additional Crates:
iced- GUI frameworkrusqlite- SQLite interfacedirs- Platform-specific directory pathsserde/serde_json- Serializationchrono- Date/time handling for timestampstokio- Async runtime
Iced-Specific Dependencies
[dependencies]
iced = { version = "0.12", features = ["tokio", "image"] }
rusqlite = { version = "0.31", features = ["bundled"] }
tokio = { version = "1", features = ["full"] }
dirs = "5.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = "0.4"
Documentation
- [ ] README with setup instructions
- [ ] Instructions to run fake data generator first
- [ ] Database location documentation
- [ ] Code comments for complex logic
- [ ] Architecture overview (Elm Architecture pattern)
- [ ] Screenshots of the UI
- [ ] Performance benchmarks