diff --git a/proton-bridge/src/api.rs b/proton-bridge/src/api.rs new file mode 100644 index 0000000..7e1bff5 --- /dev/null +++ b/proton-bridge/src/api.rs @@ -0,0 +1,252 @@ +/// ProtonMail REST API client (Step 3). +/// +/// Wraps the endpoints tuimail needs: +/// - list messages in the inbox +/// - fetch a single message (with encrypted body) +/// - delete messages +/// - fetch a recipient's public keys (for encryption when sending) +use reqwest::Client; +use serde::{Deserialize, Serialize}; + +use crate::auth::Session; + +const API_BASE: &str = "https://mail.proton.me/api"; + +/// ProtonMail label IDs for well-known mailboxes. +pub const LABEL_INBOX: &str = "0"; + +// ── API response envelopes ──────────────────────────────────────────────────── + +/// Every ProtonMail API response wraps the payload in `{ "Code": 1000, ... }`. +/// We only check Code in places where we want a custom error message; elsewhere +/// `error_for_status()` on the HTTP layer is sufficient. +#[derive(Deserialize)] +struct Envelope { + #[serde(rename = "Code")] + code: u32, + #[serde(flatten)] + data: T, +} + +// ── Message list ───────────────────────────────────────────────────────────── + +/// Lightweight message metadata returned by `GET /mail/v4/messages`. +#[derive(Debug, Clone, Deserialize)] +pub struct MessageMeta { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "Subject")] + pub subject: String, + /// Sender display name + address. + #[serde(rename = "Sender")] + pub sender: EmailAddress, + /// Unix timestamp of the message. + #[serde(rename = "Time")] + pub time: i64, + /// 0 = unread, 1 = read. + #[serde(rename = "Unread")] + pub unread: u32, + /// Message size in bytes. + #[serde(rename = "Size")] + pub size: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct EmailAddress { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Address")] + pub address: String, +} + +#[derive(Deserialize)] +struct MessageListPayload { + #[serde(rename = "Messages")] + messages: Vec, + #[serde(rename = "Total")] + total: u32, +} + +// ── Full message ────────────────────────────────────────────────────────────── + +/// Full message with (still-encrypted) body. +#[derive(Debug, Clone, Deserialize)] +pub struct Message { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "Subject")] + pub subject: String, + #[serde(rename = "Sender")] + pub sender: EmailAddress, + #[serde(rename = "ToList")] + pub to_list: Vec, + #[serde(rename = "CCList")] + pub cc_list: Vec, + #[serde(rename = "Time")] + pub time: i64, + /// PGP-encrypted MIME body (decrypt in Step 4). + #[serde(rename = "Body")] + pub body: String, + /// MIME type of the decrypted body ("text/html" or "text/plain"). + #[serde(rename = "MIMEType")] + pub mime_type: String, + /// The session key is encrypted with the recipient's private key. + #[serde(rename = "Header")] + pub header: String, +} + +#[derive(Deserialize)] +struct MessagePayload { + #[serde(rename = "Message")] + message: Message, +} + +// ── Delete ──────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct DeleteRequest<'a> { + #[serde(rename = "IDs")] + ids: &'a [String], +} + +// ── Public keys ─────────────────────────────────────────────────────────────── + +/// One armored public key for a recipient. +#[derive(Debug, Clone, Deserialize)] +pub struct RecipientKey { + /// PGP armored public key. + #[serde(rename = "PublicKey")] + pub public_key: String, + /// Flags bitmap: bit 0 = can encrypt, bit 1 = can verify. + #[serde(rename = "Flags")] + pub flags: u32, +} + +#[derive(Deserialize)] +struct KeysPayload { + #[serde(rename = "Keys")] + keys: Vec, +} + +// ── API client ──────────────────────────────────────────────────────────────── + +/// Stateless API client. Pass a freshly-validated `Session` to each call. +pub struct ApiClient<'a> { + client: &'a Client, + session: &'a Session, +} + +impl<'a> ApiClient<'a> { + pub fn new(client: &'a Client, session: &'a Session) -> Self { + Self { client, session } + } + + fn auth_headers(&self) -> [(&'static str, String); 2] { + [ + ("x-pm-uid", self.session.uid.clone()), + ("Authorization", format!("Bearer {}", self.session.access_token)), + ] + } + + /// List messages in a label (use `LABEL_INBOX`). + /// + /// Returns `(messages, total)` where `total` is the full count (for paging). + /// `page` is zero-based; `page_size` is typically 50. + pub async fn list_messages( + &self, + label_id: &str, + page: u32, + page_size: u32, + ) -> Result<(Vec, u32), String> { + let [h0, h1] = self.auth_headers(); + let env: Envelope = self + .client + .get(format!("{}/mail/v4/messages", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .query(&[ + ("LabelID", label_id), + ("Page", &page.to_string()), + ("PageSize", &page_size.to_string()), + ("Sort", "Time"), + ("Desc", "1"), + ]) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + if env.code != 1000 { + return Err(format!("list_messages: API code {}", env.code)); + } + Ok((env.data.messages, env.data.total)) + } + + /// Fetch a single message with its (encrypted) body. + pub async fn get_message(&self, id: &str) -> Result { + let [h0, h1] = self.auth_headers(); + let env: Envelope = self + .client + .get(format!("{}/mail/v4/messages/{}", API_BASE, id)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + if env.code != 1000 { + return Err(format!("get_message: API code {}", env.code)); + } + Ok(env.data.message) + } + + /// Move messages to Trash (ProtonMail's delete is a soft-delete to Trash). + pub async fn delete_messages(&self, ids: &[String]) -> Result<(), String> { + let [h0, h1] = self.auth_headers(); + self.client + .put(format!("{}/mail/v4/messages/delete", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .json(&DeleteRequest { ids }) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Fetch the public keys for a recipient email address. + /// Used when composing outbound mail (Step 7). + pub async fn get_public_keys(&self, email: &str) -> Result, String> { + let [h0, h1] = self.auth_headers(); + let env: Envelope = self + .client + .get(format!("{}/core/v4/keys", API_BASE)) + .header(h0.0, h0.1) + .header(h1.0, h1.1) + .query(&[("Email", email)]) + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())? + .json() + .await + .map_err(|e| e.to_string())?; + + if env.code != 1000 { + return Err(format!("get_public_keys: API code {}", env.code)); + } + Ok(env.data.keys) + } +} diff --git a/proton-bridge/src/main.rs b/proton-bridge/src/main.rs index d9893ca..9f414b6 100644 --- a/proton-bridge/src/main.rs +++ b/proton-bridge/src/main.rs @@ -1,7 +1,10 @@ +mod api; mod auth; mod config; mod srp; +use api::{ApiClient, LABEL_INBOX}; + #[tokio::main] async fn main() { let config = match config::Config::load() { @@ -14,8 +17,26 @@ async fn main() { Err(e) => { eprintln!("Failed to build HTTP client: {}", e); std::process::exit(1); } }; - match auth::authenticate(&client, &config.proton).await { - Ok(session) => println!("Session UID: {}", session.uid), + let session = match auth::authenticate(&client, &config.proton).await { + Ok(s) => s, Err(e) => { eprintln!("Authentication failed: {}", e); std::process::exit(1); } + }; + println!("Session UID: {}", session.uid); + + // Step 3 smoke-test: list the first page of inbox messages. + let api = ApiClient::new(&client, &session); + match api.list_messages(LABEL_INBOX, 0, 10).await { + Ok((messages, total)) => { + println!("\nInbox ({total} total):"); + for m in &messages { + println!( + " [{:>5}] {:50} — {}", + if m.unread == 1 { "UNREAD" } else { " read" }, + m.subject, + m.sender.address, + ); + } + } + Err(e) => eprintln!("list_messages failed: {}", e), } } \ No newline at end of file