diff options
Diffstat (limited to 'openproject-sync/src')
-rw-r--r-- | openproject-sync/src/main.rs | 338 |
1 files changed, 338 insertions, 0 deletions
diff --git a/openproject-sync/src/main.rs b/openproject-sync/src/main.rs new file mode 100644 index 0000000..64a9795 --- /dev/null +++ b/openproject-sync/src/main.rs @@ -0,0 +1,338 @@ +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<String>, +} + +#[derive(Debug)] +struct Email { + message_id: String, + in_reply_to: Option<String>, + 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<native_tls::TlsStream<TcpStream>> { + 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<Email> { + 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<details>\n<summary>Show source code</summary>\n\n```\n{}\n```\n</details>", + 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<u64> { + 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<native_tls::TlsStream<TcpStream>>, 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<native_tls::TlsStream<TcpStream>>, 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"); +} |