summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLeonard Kugis <leonard@kug.is>2025-04-13 04:52:49 +0200
committerLeonard Kugis <leonard@kug.is>2025-04-13 04:52:49 +0200
commit552442a52ed264de155b5dcc2269abba6ead5a99 (patch)
tree9582ebacbac7f9c842c21f72cbdc201f01436439
parentb0297b96b52042ed390092faa22b26ef516a259a (diff)
downloadscripts-552442a52ed264de155b5dcc2269abba6ead5a99.tar.gz
Implemented openproject-sync
-rw-r--r--.gitignore2
-rw-r--r--openproject-sync/Cargo.toml18
-rw-r--r--openproject-sync/src/main.rs338
3 files changed, 358 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index f3ab105..9e67e1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
config_backup.json
config_calendar.json
+config_openproject_sync.json
+map_mail_ticket.db
# Created by https://www.toptal.com/developers/gitignore/api/rust,visualstudiocode,linux
# Edit at https://www.toptal.com/developers/gitignore?templates=rust,visualstudiocode,linux
diff --git a/openproject-sync/Cargo.toml b/openproject-sync/Cargo.toml
new file mode 100644
index 0000000..6470dcb
--- /dev/null
+++ b/openproject-sync/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "openproject-sync"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+imap = "2.3"
+native-tls = "0.2"
+mailparse = "0.14"
+reqwest = { version = "0.11", features = ["json", "blocking", "rustls-tls"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+rusqlite = { version = "0.30.0", features = ["bundled"] }
+log = "0.4"
+env_logger = "0.10"
+tokio = { version = "1", features = ["full"] }
+clap = { version = "4.5", features = ["derive"] }
+regex = "1.10"
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(&notice);
+ } 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");
+}