use imap::types::Fetch; use imap::Session; use mailparse::parse_mail; use native_tls::TlsConnector; use reqwest::blocking::Client; use rusqlite::{params, Connection}; use serde::{Deserialize}; use std::net::TcpStream; use clap::Parser; use mailparse::MailHeaderMap; use regex::Regex; #[derive(Parser)] #[command(name = "openproject-sync", version, about = "Migrates E-Mails to OpenProject tasks")] struct Args { #[arg(short, long, default_value = "config.json")] config: String, } #[derive(Debug, Deserialize)] struct Config { imap_server: String, imap_user: String, imap_pass: String, openproject_url: String, openproject_key: String, openproject_project_id: u64, map_mail_ticket: String, target_email: String, mailbox_tickets: String, mailboxes: Vec, } #[derive(Debug)] struct Email { message_id: String, in_reply_to: Option, subject: String, body: String, to: String, cc: String, } fn load_config(path: &str) -> Config { let text = std::fs::read_to_string(path).expect("ERROR: Config not found"); serde_json::from_str(&text).expect("ERROR: Invalid config") } fn connect_imap(cfg: &Config) -> Session> { let tls = TlsConnector::builder().build().unwrap(); let client = imap::connect((&cfg.imap_server[..], 993), &cfg.imap_server, &tls).unwrap(); client.login(&cfg.imap_user, &cfg.imap_pass).map_err(|e| e.0).unwrap() } fn parse_email(fetch: &Fetch) -> Option { let body = fetch.body()?; let raw_str = std::str::from_utf8(body).ok()?.to_string(); let parsed = parse_mail(body).ok()?; let headers = parsed.get_headers(); let message_id = headers.get_first_value("Message-ID")?; let subject = headers.get_first_value("Subject").unwrap_or_else(|| "(no subject)".into()); let in_reply_to = headers.get_first_value("In-Reply-To"); let to = headers.get_first_value("To").unwrap_or_default(); let cc = headers.get_first_value("Cc").unwrap_or_default(); let mut body_text = String::new(); if parsed.subparts.is_empty() { body_text = parsed.get_body().ok()?; } else { for part in &parsed.subparts { let content_type = part.ctype.mimetype.to_lowercase(); match content_type.as_str() { "text/plain" => { body_text = part.get_body().ok()?; break; } "text/html" if body_text.is_empty() => { body_text = part.get_body().ok()?; } "text/calendar" if body_text.is_empty() => { body_text = part.get_body().ok()?; } _ => {} } } } if body_text.trim().is_empty() { println!("WARNING: No body for E-Mail '{}'", subject); } let mut filtered_raw = String::new(); let mut in_attachment = false; let mut current_part_header = String::new(); for line in raw_str.lines() { if line.starts_with("--") { in_attachment = false; current_part_header.clear(); filtered_raw.push_str(line); filtered_raw.push('\n'); continue; } if line.to_lowercase().starts_with("content-type:") { current_part_header = line.to_lowercase(); if current_part_header.contains("application/") || current_part_header.contains("image/") || current_part_header.contains("audio/") || current_part_header.contains("octet-stream") { in_attachment = true; let filename = if line.to_lowercase().contains("name=") { line.split("name=").nth(1).unwrap_or("").trim_matches('"') } else { "" }; let notice = format!( "[Attachment stripped: {}; filename=\"{}\"]\n", current_part_header.trim(), filename ); filtered_raw.push_str(¬ice); } else { filtered_raw.push_str(line); filtered_raw.push('\n'); } } else if !in_attachment { filtered_raw.push_str(line); filtered_raw.push('\n'); } } let body_full = format!( "{}\n\n
\nShow source code\n\n```\n{}\n```\n
", body_text.trim(), filtered_raw.trim() ); Some(Email { message_id, in_reply_to, subject, body: body_full, to: to, cc: cc, }) } fn init_db(path: &str) -> Connection { let conn = Connection::open(path).unwrap(); conn.execute( "CREATE TABLE IF NOT EXISTS message_map ( message_id TEXT PRIMARY KEY, task_id INTEGER )", [], ).unwrap(); conn } fn is_processed(conn: &Connection, msg_id: &str) -> bool { conn.query_row( "SELECT EXISTS(SELECT 1 FROM message_map WHERE message_id = ?)", [msg_id], |row| row.get(0), ).unwrap_or(false) } fn save_mapping(conn: &Connection, msg_id: &str, task_id: u64) { conn.execute( "INSERT OR IGNORE INTO message_map (message_id, task_id) VALUES (?, ?)", params![msg_id, task_id], ).unwrap(); } fn get_task_for_message(conn: &Connection, msg_id: &str) -> Option { conn.query_row( "SELECT task_id FROM message_map WHERE message_id = ?", [msg_id], |row| row.get(0), ).ok() } fn create_task(client: &Client, email: &Email, cfg: &Config) -> u64 { let url = format!("{}/api/v3/work_packages", cfg.openproject_url); let resp = client.post(&url) .basic_auth("apikey", Some(&cfg.openproject_key)) .json(&serde_json::json!({ "subject": format!("E-Mail: {}", email.subject), "description": { "format": "markdown", "raw": email.body }, "scheduleManually": false, "ignoreNonWorkingDays": false, "_links": { "type": { "href": "/api/v3/types/1", "title": "Task" }, "priority": { "href": "/api/v3/priorities/8", "title": "Normal" }, "project": { "href": format!("/api/v3/projects/{}", cfg.openproject_project_id) }, "status": { "href": "/api/v3/statuses/1", "title": "New" }, "assignee": { "href": null }, "responsible": { "href": null }, "category": { "href": null }, "version": { "href": null }, "parent": { "href": null } } })) .send() .expect("ERROR: Creating task failed"); let json: serde_json::Value = resp.json().unwrap(); //eprintln!("INFO: OpenProject API answer:\n{}", serde_json::to_string_pretty(&json).unwrap()); json["id"].as_u64().unwrap() } fn add_comment(client: &Client, task_id: u64, email: &Email, cfg: &Config) { let url = format!("{}/api/v3/work_packages/{}/activities", cfg.openproject_url, task_id); client.post(&url) .basic_auth("apikey", Some(&cfg.openproject_key)) .json(&serde_json::json!({ "comment": { "raw": email.body } })) .send() .expect("ERROR: Adding comment failed"); } fn move_email_by_uid(session: &mut Session>, source_folder: &str, target_folder: &str, uid: u32) { session.select(source_folder).expect("ERROR: Unable to open folder to move by uid"); let uid_str = uid.to_string(); session.uid_copy(&uid_str, target_folder).expect("ERROR: Unable to copy message"); session.uid_store(&uid_str, "+FLAGS.SILENT (\\Deleted)").expect("ERROR: Unable to mark message as deleted"); session.expunge().expect("ERROR: Unable to delete message"); } fn process_mailbox(session: &mut Session>, mbox: &str, client: &Client, conn: &Connection, cfg: &Config, email_regex: &Regex) { session.select(mbox).unwrap(); let msgs = session.uid_fetch("1:*", "RFC822").unwrap(); println!("INFO: Found messages: {}", msgs.len()); for msg in msgs.iter() { let uid = match msg.uid { Some(uid) => uid, None => { println!("WARNING: No UID, skipping message"); continue; } }; if let Some(email) = parse_email(msg) { if is_processed(conn, &email.message_id) { println!("INFO: Message already merged: {}", email.subject); } else { println!("INFO: New message: {}", email.subject); let task_id = match email.in_reply_to.as_deref() { Some(parent_id) => { if let Some(existing_id) = get_task_for_message(conn, parent_id) { println!("INFO: Adding comment to task #{}", existing_id); add_comment(client, existing_id, &email, cfg); existing_id } else { println!("INFO: Creating new task (answer without original message)"); create_task(client, &email, cfg) } } None => { if !(email_regex.is_match(&email.to) || email_regex.is_match(&email.cc)) { println!("INFO: Ignoring unknown e-mail recipient"); continue; } println!("INFO: Creating new task"); create_task(client, &email, cfg) } }; println!("INFO: Mapping: {} -> Task #{}", email.message_id, task_id); save_mapping(conn, &email.message_id, task_id); } move_email_by_uid(session, mbox, &cfg.mailbox_tickets, uid); } } } fn main() { env_logger::init(); let args = Args::parse(); println!("INFO: Loading config: {}", args.config); let cfg = load_config(&args.config); let email_pattern = format!(r"(?i)\b{}\b", regex::escape(&cfg.target_email)); let email_regex = Regex::new(&email_pattern).expect("ERROR: Invalid target e-mail"); println!("INFO: Initializing OpenProject connection: {}", cfg.openproject_url); let client = Client::new(); println!("INFO: Inititalizing SQLite database: {}", cfg.map_mail_ticket); let conn = init_db(&cfg.map_mail_ticket); println!("INFO: Connecting to IMAP server: {}", cfg.imap_server); let mut session = connect_imap(&cfg); for folder in &cfg.mailboxes { println!("INFO: Processing folder: {}", folder); process_mailbox(&mut session, folder, &client, &conn, &cfg, &email_regex); } println!("INFO: Disconnecting from IMAP server"); session.logout().unwrap(); println!("INFO: Sync completed"); }