dwata icon indicating copy to clipboard operation
dwata copied to clipboard

Email Client Desktop App using Iced

Open brainless opened this issue 3 months ago • 0 comments

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:

  1. Database Models - All Rust structs:

    use dwata_db::{Account, Email, Contact, Folder, Label, Attachment};
    
  2. 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)?;
    
  3. 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:

  1. State: Application state struct holding all data
  2. Messages: Enum of all possible events/actions
  3. Update Logic: Handle messages and update state
  4. View Logic: Render UI based on current state

Async Operations with Tasks

  • Use Task for 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 Subscription for 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_list widget) 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
  • "Contacts": Navigate to contacts list
  • "Files": Browse all attachments

Visual Requirements:

  • Each navigation item shows unread count (if applicable)
  • Active/selected item highlighted (using Button with 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 Scrollable widget)

  • Each email item displays across two rows:

    First row:

    • Checkbox (using Checkbox widget, unchecked by default)
    • Star icon (☆ for unstarred, ★ for starred) - use Text or custom icon
    • Subject line (bold if unread using Text styling, regular weight if read)

    Second row (indented to align with subject):

    • Sender name/email (gray text, smaller font using Text styling)
    • 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 Rule widget)

    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)
  • 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 TextInput widget)

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 Text with 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 Button widgets):
      • Reply
      • Reply All
      • Forward
      • Delete
      • Archive
      • Star/Unstar
      • Add Label
  • Body Section:

    • Email body (plain text using Text or Scrollable with Text)
    • Scrollable if content is long
    • Preserve formatting
  • 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_blocking for 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 framework
    • rusqlite - SQLite interface
    • dirs - Platform-specific directory paths
    • serde / serde_json - Serialization
    • chrono - Date/time handling for timestamps
    • tokio - 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

brainless avatar Oct 11 '25 06:10 brainless