pigeon-cli 0.4.0

Pigeon: authenticate, sink, and transform personal data from external services.
Documentation
use serde::{Deserialize, Serialize};

use crate::commands::keyring::email::provider::Provider;

/// A single authenticated email identity's non-secret metadata. The actual
/// secret (app/bridge password) lives in the OS keychain, keyed by `alias`
/// -- see `crate::core::keyring::credentials`. Metadata persistence itself
/// lives in `crate::commands::keyring::store` (ADR-0022) -- this struct is
/// kept here since `commands::job::email_sync` is its main consumer.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
    pub alias: String,
    pub email: String,
    pub provider: Provider,
    pub host: String,
    pub port: u16,
    /// Caps how many simultaneous IMAP connections `job run email-sync`
    /// (`commands::job::email_sync::worker`) will open to this identity,
    /// overriding that job's `--max-connections-per-identity` default for
    /// this identity only (ADR-0080). `None` (the default for every
    /// existing `keyring.toml` entry, via `#[serde(default)]`) means "use
    /// the job's default cap" -- set via `pigeon keyring add/modify email`
    /// once a provider is known to reject that default for this account.
    #[serde(default)]
    pub max_imap_connections: Option<u32>,
}

impl crate::core::keyring::KeyringEntry for Identity {
    fn alias(&self) -> &str {
        &self.alias
    }
    fn kind(&self) -> &'static str {
        "email"
    }
    fn detail(&self) -> String {
        match self.max_imap_connections {
            Some(cap) => format!("{} ({}, max {cap} IMAP conns)", self.email, self.provider),
            None => format!("{} ({})", self.email, self.provider),
        }
    }
}

/// Caps a sanitized segment's length so it can never blow past a
/// filesystem's per-component name limit (255 bytes on APFS/most Unix
/// filesystems) on its own -- e.g. an email subject line long enough to be
/// a whole paragraph. `sanitize_segment`'s output is always pure ASCII, so
/// a byte count is also a char count here.
const MAX_SEGMENT_LENGTH: usize = 100;

/// Sanitizes a single path/name segment: lowercase, non-alphanumeric runs
/// collapsed to a single hyphen, leading/trailing hyphens trimmed, capped to
/// `MAX_SEGMENT_LENGTH`. Shared by `sanitize_alias` and `sink`'s
/// per-mailbox directory naming.
pub fn sanitize_segment(input: &str) -> String {
    let mut segment = String::with_capacity(input.len());
    let mut last_was_hyphen = false;
    for ch in input.chars() {
        if ch.is_ascii_alphanumeric() {
            segment.push(ch.to_ascii_lowercase());
            last_was_hyphen = false;
        } else if !last_was_hyphen && !segment.is_empty() {
            segment.push('-');
            last_was_hyphen = true;
        }
    }
    segment.truncate(MAX_SEGMENT_LENGTH);
    if segment.ends_with('-') {
        segment.pop();
    }
    segment
}

/// Derives a default alias from an email address's local part, following
/// ADR-0001's file-naming scheme.
///
/// e.g. `first.last@example.com` -> `first-last`
pub fn sanitize_alias(email: &str) -> String {
    let local_part = email.split('@').next().unwrap_or(email);
    sanitize_segment(local_part)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sanitizes_dotted_local_part() {
        assert_eq!(sanitize_alias("first.last@example.com"), "first-last");
    }

    #[test]
    fn sanitizes_plus_addressing() {
        assert_eq!(sanitize_alias("jane+work@example.com"), "jane-work");
    }

    #[test]
    fn sanitize_segment_truncates_long_input_with_no_trailing_hyphen() {
        let long_subject = "word ".repeat(50); // far more than MAX_SEGMENT_LENGTH once hyphenated
        let segment = sanitize_segment(&long_subject);
        assert!(segment.len() <= MAX_SEGMENT_LENGTH);
        assert!(!segment.ends_with('-'));
    }
}