diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4470e1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable + components: clippy, rustfmt + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo fmt --check + - run: cargo clippy -- -D warnings + - run: cargo test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..adee581 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,104 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: {} + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact: crabmail-linux-amd64 + - os: macos-latest + target: aarch64-apple-darwin + artifact: crabmail-darwin-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo build --release + - name: Package + run: | + tar czf ${{ matrix.artifact }}.tar.gz -C target/release crabmail + shasum -a 256 ${{ matrix.artifact }}.tar.gz > ${{ matrix.artifact }}.tar.gz.sha256 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ matrix.artifact }} + path: | + ${{ matrix.artifact }}.tar.gz + ${{ matrix.artifact }}.tar.gz.sha256 + + release: + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + merge-multiple: true + - name: Create release + uses: softprops/action-gh-release@3bb12739c298aeb8a4aeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: | + *.tar.gz + *.sha256 + generate_release_notes: true + + update-tap: + needs: release + runs-on: ubuntu-latest + steps: + - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: CyTechNomad + repositories: homebrew-tap + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + merge-multiple: true + - name: Get version and checksums + id: meta + run: | + VERSION="${GITHUB_REF_NAME#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "sha256_darwin=$(awk '{print $1}' crabmail-darwin-arm64.tar.gz.sha256)" >> "$GITHUB_OUTPUT" + echo "sha256_linux=$(awk '{print $1}' crabmail-linux-amd64.tar.gz.sha256)" >> "$GITHUB_OUTPUT" + - name: Update tap formula + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.meta.outputs.version }} + SHA256_DARWIN: ${{ steps.meta.outputs.sha256_darwin }} + SHA256_LINUX: ${{ steps.meta.outputs.sha256_linux }} + run: | + git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/" + git clone https://github.com/CyTechNomad/homebrew-tap.git tap + cd tap + + # Update main formula + sed -i "s/version \".*\"/version \"${VERSION}\"/" Formula/crabmail.rb + sed -i "0,/sha256 \".*\"/{s/sha256 \".*\"/sha256 \"${SHA256_DARWIN}\"/}" Formula/crabmail.rb + sed -i "/sha256 \"${SHA256_DARWIN}\"/!{0,/sha256 \".*\"/{s/sha256 \".*\"/sha256 \"${SHA256_LINUX}\"/}}" Formula/crabmail.rb + + # Create versioned formula + cp Formula/crabmail.rb "Formula/crabmail@${VERSION}.rb" + sed -i "s/class Crabmail ` | Switch account | -| `:add-account` | Add a new account | -| `:edit-account` | Edit active account | -| `:theme ` | Switch color theme | -| `:help` | Show keybind hints | +| Command | Action | +| ----------------- | ------------------- | +| `:q` / `:quit` | Quit | +| `:account ` | Switch account | +| `:add-account` | Add a new account | +| `:edit-account` | Edit active account | +| `:theme ` | Switch color theme | +| `:help` | Show keybind hints | ## Configuration diff --git a/src/action.rs b/src/action.rs index e925372..2d114d5 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,6 +1,9 @@ use crossterm::event::KeyEvent; +// Some variants are matched but not yet constructed — they exist as scaffolding +// for a future decoupled event dispatch architecture. #[derive(Debug, Clone)] +#[allow(dead_code)] pub enum Action { Quit, Noop, diff --git a/src/app.rs b/src/app.rs index 8e5ed0c..5d9261f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,6 @@ use std::time::{Duration, Instant}; use crate::action::Action; use crate::auth; -use secrecy::{ExposeSecret, SecretString}; use crate::components::Component; use crate::components::command_bar::{self, CommandBar}; use crate::components::composer::Composer; @@ -21,6 +20,7 @@ use crate::imap_client::ImapClient; use crate::mail; use crate::smtp_client; use crate::theme::Theme; +use secrecy::{ExposeSecret, SecretString}; #[derive(Debug, Clone, Copy, PartialEq)] pub enum Mode { @@ -113,11 +113,12 @@ impl App { } let action = self.handle_key(key); self.process_action(action).await; - } else if let Some(secs) = self.config.auto_refresh_seconds { - if secs > 0 && self.last_refresh.elapsed() >= Duration::from_secs(secs) { - self.last_refresh = Instant::now(); - self.process_action(Action::RefreshMailbox).await; - } + } else if let Some(secs) = self.config.auto_refresh_seconds + && secs > 0 + && self.last_refresh.elapsed() >= Duration::from_secs(secs) + { + self.last_refresh = Instant::now(); + self.process_action(Action::RefreshMailbox).await; } } Ok(()) @@ -141,7 +142,8 @@ impl App { self.pending_confirm.take().unwrap() } else { self.pending_confirm = None; - self.status_bar.set_temporary_status("Cancelled", Duration::from_secs(2)); + self.status_bar + .set_temporary_status("Cancelled", Duration::from_secs(2)); Action::Noop }; } @@ -409,7 +411,8 @@ impl App { match client.list_mailboxes().await { Ok(mailboxes) => { self.mailbox_list.set_mailboxes(mailboxes); - self.status_bar.set_temporary_status("Connected", Duration::from_secs(2)); + self.status_bar + .set_temporary_status("Connected", Duration::from_secs(2)); } Err(e) => { self.status_bar.error = format!("Failed to list mailboxes: {e}"); @@ -433,10 +436,8 @@ impl App { match imap.fetch_headers(50).await { Ok(msgs) => { self.message_list.set_messages(msgs); - self.status_bar.set_temporary_status( - "Refreshed", - Duration::from_secs(2), - ); + self.status_bar + .set_temporary_status("Refreshed", Duration::from_secs(2)); } Err(e) => self.status_bar.error = format!("Fetch error: {e}"), } @@ -460,16 +461,16 @@ impl App { } async fn mark_read(&mut self, uid: u32) { - if let Some(imap) = &mut self.imap { - if let Err(e) = imap.mark_read(uid).await { - self.status_bar.error = format!("Mark read failed: {e}"); - return; - } + if let Some(imap) = &mut self.imap + && let Err(e) = imap.mark_read(uid).await + { + self.status_bar.error = format!("Mark read failed: {e}"); + return; } - if let Some(msg) = self.message_list.messages.iter_mut().find(|m| m.uid == uid) { - if !msg.flags.iter().any(|f| f.contains("Seen")) { - msg.flags.push("Seen".to_string()); - } + if let Some(msg) = self.message_list.messages.iter_mut().find(|m| m.uid == uid) + && !msg.flags.iter().any(|f| f.contains("Seen")) + { + msg.flags.push("Seen".to_string()); } } @@ -493,7 +494,8 @@ impl App { .await { Ok(()) => { - self.status_bar.set_temporary_status("Message sent!", Duration::from_secs(2)); + self.status_bar + .set_temporary_status("Message sent!", Duration::from_secs(2)); self.composer.clear(); } Err(e) => self.status_bar.error = format!("Send failed: {e}"), @@ -504,7 +506,8 @@ impl App { let Some(imap) = &mut self.imap else { return }; match imap.delete_message(uid).await { Ok(()) => { - self.status_bar.set_temporary_status("Message deleted", Duration::from_secs(2)); + self.status_bar + .set_temporary_status("Message deleted", Duration::from_secs(2)); if self.mode == Mode::Reading { self.mode = Mode::Normal; self.reader.close(); @@ -523,7 +526,8 @@ impl App { match imap.search(query).await { Ok(uids) => { if uids.is_empty() { - self.status_bar.set_temporary_status("No results", Duration::from_secs(2)); + self.status_bar + .set_temporary_status("No results", Duration::from_secs(2)); self.message_list.set_messages(vec![]); return; } @@ -562,12 +566,12 @@ impl App { let account = self.setup_wizard.build_account(); let password = self.setup_wizard.password(); - if !password.is_empty() { - if let Err(e) = auth::store_password(&account.name, password) { - self.setup_wizard.clear_password(); - self.status_bar.error = format!("Failed to store password: {e}"); - return; - } + if !password.is_empty() + && let Err(e) = auth::store_password(&account.name, password) + { + self.setup_wizard.clear_password(); + self.status_bar.error = format!("Failed to store password: {e}"); + return; } self.setup_wizard.clear_password(); diff --git a/src/auth.rs b/src/auth.rs index 3ac2a84..966c133 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,6 +20,7 @@ pub fn get_password(account_name: &str) -> Result { .with_context(|| format!("No password found for {account_name}")) } +#[allow(dead_code)] // Will be used when account deletion is implemented pub fn delete_password(account_name: &str) -> Result<()> { let entry = Entry::new(SERVICE, account_name) .with_context(|| format!("Failed to create keyring entry for {account_name}"))?; diff --git a/src/components/message_list.rs b/src/components/message_list.rs index 26d0f67..8797950 100644 --- a/src/components/message_list.rs +++ b/src/components/message_list.rs @@ -149,7 +149,11 @@ impl Component for MessageList { fn truncate(s: &str, max: usize) -> String { if s.chars().count() > max { - let end = s.char_indices().nth(max - 1).map(|(i, _)| i).unwrap_or(s.len()); + let end = s + .char_indices() + .nth(max - 1) + .map(|(i, _)| i) + .unwrap_or(s.len()); format!("{}…", &s[..end]) } else { s.to_string() diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index c864155..c041e67 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -41,11 +41,11 @@ impl StatusBar { } pub fn tick(&mut self) { - if let Some(expires) = self.status_expires { - if Instant::now() >= expires { - self.status.clear(); - self.status_expires = None; - } + if let Some(expires) = self.status_expires + && Instant::now() >= expires + { + self.status.clear(); + self.status_expires = None; } } } @@ -74,7 +74,10 @@ impl Component for StatusBar { Mode::Command => " COMMAND ", Mode::Setup => " SETUP ", }; - let mode_span = Span::styled(mode_str, Style::default().fg(theme.mode_fg).bg(theme.accent)); + let mode_span = Span::styled( + mode_str, + Style::default().fg(theme.mode_fg).bg(theme.accent), + ); let acct = if self.account.is_empty() { "No account".to_string() } else { @@ -89,7 +92,10 @@ impl Component for StatusBar { let mb_span = Span::styled(mb, Style::default().fg(theme.dimmed)); let right = if !self.error.is_empty() { - Span::styled(format!(" {} ", self.error), Style::default().fg(theme.error)) + Span::styled( + format!(" {} ", self.error), + Style::default().fg(theme.error), + ) } else if !self.status.is_empty() { Span::styled( format!(" {} ", self.status), diff --git a/src/config.rs b/src/config.rs index a9a5603..0025a64 100644 --- a/src/config.rs +++ b/src/config.rs @@ -49,16 +49,14 @@ impl Config { pub fn load() -> Result { let path = Self::path(); // Migrate from old platform-specific location if needed - if !path.exists() { - if let Some(old) = dirs::config_dir().map(|d| d.join("crabmail").join("config.toml")) - { - if old.exists() { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(&old, &path)?; - } + if !path.exists() + && let Some(old) = dirs::config_dir().map(|d| d.join("crabmail").join("config.toml")) + && old.exists() + { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; } + fs::copy(&old, &path)?; } if !path.exists() { let config = Config::default(); diff --git a/src/imap_client.rs b/src/imap_client.rs index 969a9d8..388293c 100644 --- a/src/imap_client.rs +++ b/src/imap_client.rs @@ -40,6 +40,7 @@ pub struct MailboxInfo { #[derive(Debug, Clone)] pub struct MessageSummary { + #[allow(dead_code)] // Retained for future IMAP sequence-based operations pub seq: u32, pub uid: u32, pub from: String, diff --git a/src/theme.rs b/src/theme.rs index c6f5121..1be772e 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -29,53 +29,53 @@ impl Theme { fn dracula() -> Self { Self { - accent: Color::Rgb(189, 147, 249), // purple - dimmed: Color::Rgb(98, 114, 164), // comment - text: Color::Rgb(248, 248, 242), // foreground - success: Color::Rgb(80, 250, 123), // green - error: Color::Rgb(255, 85, 85), // red - search: Color::Rgb(241, 250, 140), // yellow - bar_bg: Color::Rgb(68, 71, 90), // current line - mode_fg: Color::Rgb(40, 42, 54), // background + accent: Color::Rgb(189, 147, 249), // purple + dimmed: Color::Rgb(98, 114, 164), // comment + text: Color::Rgb(248, 248, 242), // foreground + success: Color::Rgb(80, 250, 123), // green + error: Color::Rgb(255, 85, 85), // red + search: Color::Rgb(241, 250, 140), // yellow + bar_bg: Color::Rgb(68, 71, 90), // current line + mode_fg: Color::Rgb(40, 42, 54), // background } } fn gruvbox() -> Self { Self { - accent: Color::Rgb(215, 153, 33), // yellow - dimmed: Color::Rgb(146, 131, 116), // gray - text: Color::Rgb(235, 219, 178), // fg - success: Color::Rgb(152, 151, 26), // green - error: Color::Rgb(204, 36, 29), // red - search: Color::Rgb(69, 133, 136), // aqua - bar_bg: Color::Rgb(60, 56, 54), // bg1 - mode_fg: Color::Rgb(40, 40, 40), // bg0 + accent: Color::Rgb(215, 153, 33), // yellow + dimmed: Color::Rgb(146, 131, 116), // gray + text: Color::Rgb(235, 219, 178), // fg + success: Color::Rgb(152, 151, 26), // green + error: Color::Rgb(204, 36, 29), // red + search: Color::Rgb(69, 133, 136), // aqua + bar_bg: Color::Rgb(60, 56, 54), // bg1 + mode_fg: Color::Rgb(40, 40, 40), // bg0 } } fn nord() -> Self { Self { - accent: Color::Rgb(136, 192, 208), // frost - dimmed: Color::Rgb(76, 86, 106), // nord3 - text: Color::Rgb(216, 222, 233), // snow storm - success: Color::Rgb(163, 190, 140), // green - error: Color::Rgb(191, 97, 106), // red - search: Color::Rgb(235, 203, 139), // yellow - bar_bg: Color::Rgb(59, 66, 82), // nord1 - mode_fg: Color::Rgb(46, 52, 64), // nord0 + accent: Color::Rgb(136, 192, 208), // frost + dimmed: Color::Rgb(76, 86, 106), // nord3 + text: Color::Rgb(216, 222, 233), // snow storm + success: Color::Rgb(163, 190, 140), // green + error: Color::Rgb(191, 97, 106), // red + search: Color::Rgb(235, 203, 139), // yellow + bar_bg: Color::Rgb(59, 66, 82), // nord1 + mode_fg: Color::Rgb(46, 52, 64), // nord0 } } fn solarized() -> Self { Self { - accent: Color::Rgb(38, 139, 210), // blue - dimmed: Color::Rgb(88, 110, 117), // base01 - text: Color::Rgb(131, 148, 150), // base0 - success: Color::Rgb(133, 153, 0), // green - error: Color::Rgb(220, 50, 47), // red - search: Color::Rgb(181, 137, 0), // yellow - bar_bg: Color::Rgb(7, 54, 66), // base02 - mode_fg: Color::Rgb(0, 43, 54), // base03 + accent: Color::Rgb(38, 139, 210), // blue + dimmed: Color::Rgb(88, 110, 117), // base01 + text: Color::Rgb(131, 148, 150), // base0 + success: Color::Rgb(133, 153, 0), // green + error: Color::Rgb(220, 50, 47), // red + search: Color::Rgb(181, 137, 0), // yellow + bar_bg: Color::Rgb(7, 54, 66), // base02 + mode_fg: Color::Rgb(0, 43, 54), // base03 } } }