// synops-mail — Send og motta epost for Synops. // // Send-modus: sender epost via msmtp (Brevo SMTP-relay). // Motta-modus: tar imot epost fra Postfix via pipe transport. // - Sjekker at avsender-epost finnes i auth_identities // - Sjekker at innholdet starter med en konfigurerbar frase (default: "Kjære vaktmester") // - Oppretter content-node i brukerens innboks ved match // - Alt annet → /dev/null, ingen bounce // // Bruk: // synops-mail --send --to bruker@eksempel.no --subject "Emne" // echo "Brødtekst" | synops-mail --send --to bruker@eksempel.no --subject "Emne" // synops-mail --send --to bruker@eksempel.no --subject "Emne" --body "Tekst her" // cat raw_email.eml | synops-mail --receive --recipient bruker@synops.no --sender avsender@test.no // // Output: Bekreftelse til stdout ved suksess. // Feil: stderr + exit code != 0. // // Konfigurering: // msmtp-konfig: /srv/synops/config/msmtp/msmtprc // Miljøvariabel MSMTP_CONFIG kan overstyre config-sti. // Miljøvariabel SYNOPS_MAIL_PHRASE kan overstyre aktiveringsfrase (default: "Kjære vaktmester"). // // Ref: docs/retninger/unix_filosofi.md use clap::Parser; use std::io::{self, Read}; use std::process::{Command, Stdio}; const DEFAULT_FROM: &str = "vaktmester@synops.no"; const DEFAULT_FROM_NAME: &str = "Synops Vaktmester"; const DEFAULT_CONFIG: &str = "/srv/synops/config/msmtp/msmtprc"; // Frase-sjekk fjernet — avsender-verifisering via auth_identities er nok. /// Synops epost-verktøy: send og motta epost. #[derive(Parser)] #[command(name = "synops-mail", about = "Send og motta epost for Synops")] struct Cli { /// Send-modus #[arg(long)] send: bool, /// Motta-modus (fra Postfix pipe transport) #[arg(long)] receive: bool, /// Mottaker-epostadresse (--send) #[arg(long)] to: Option, /// Emne (--send) #[arg(long)] subject: Option, /// Brødtekst (--send, valgfritt — leses fra stdin om ikke oppgitt) #[arg(long)] body: Option, /// Avsender (--send default: vaktmester@synops.no, --receive: envelope sender) #[arg(long, default_value = DEFAULT_FROM)] from: String, /// msmtp config-fil (default: /srv/synops/config/msmtp/msmtprc) #[arg(long, env = "MSMTP_CONFIG")] config: Option, /// Envelope-mottaker fra Postfix (--receive) #[arg(long)] recipient: Option, /// Envelope-avsender fra Postfix (--receive) #[arg(long)] sender: Option, } fn main() { let cli = Cli::parse(); if cli.receive { // Motta-modus trenger async for database let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| { eprintln!("synops-mail: kunne ikke starte tokio runtime: {e}"); std::process::exit(0); }); rt.block_on(run_receive(&cli)); return; } if !cli.send { eprintln!("Feil: --send eller --receive er påkrevd"); std::process::exit(1); } run_send(&cli); } fn run_send(cli: &Cli) { let to = cli.to.as_deref().unwrap_or_else(|| { eprintln!("Feil: --to er påkrevd for --send"); std::process::exit(1); }); let subject = cli.subject.as_deref().unwrap_or_else(|| { eprintln!("Feil: --subject er påkrevd for --send"); std::process::exit(1); }); // Les body fra --body eller stdin let body = match &cli.body { Some(b) => b.clone(), None => { let mut buf = String::new(); io::stdin().read_to_string(&mut buf).unwrap_or_else(|e| { eprintln!("Feil ved lesing fra stdin: {e}"); std::process::exit(1); }); if buf.is_empty() { eprintln!("Feil: Ingen --body og ingen data på stdin"); std::process::exit(1); } buf } }; let config_path = cli.config.as_deref().unwrap_or(DEFAULT_CONFIG); // Bygg RFC 5322-melding let message = format!( "From: {from_name} <{from}>\r\n\ To: {to}\r\n\ Subject: {subject}\r\n\ MIME-Version: 1.0\r\n\ Content-Type: text/plain; charset=utf-8\r\n\ Content-Transfer-Encoding: 8bit\r\n\ \r\n\ {body}", from_name = DEFAULT_FROM_NAME, from = cli.from, ); // Send via msmtp let mut child = Command::new("msmtp") .args(["--file", config_path, "--from", &cli.from, to]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap_or_else(|e| { eprintln!("Kunne ikke starte msmtp: {e}"); std::process::exit(1); }); if let Some(mut stdin) = child.stdin.take() { use std::io::Write; stdin.write_all(message.as_bytes()).unwrap_or_else(|e| { eprintln!("Feil ved skriving til msmtp: {e}"); std::process::exit(1); }); } let output = child.wait_with_output().unwrap_or_else(|e| { eprintln!("Feil ved venting på msmtp: {e}"); std::process::exit(1); }); if output.status.success() { println!("Epost sendt til {}", to); } else { let stderr = String::from_utf8_lossy(&output.stderr); eprintln!("msmtp feilet (exit {}): {}", output.status, stderr); std::process::exit(output.status.code().unwrap_or(1)); } } /// Motta epost fra Postfix pipe transport. /// /// 1. Leser rå RFC 5322-epost fra stdin /// 2. Parser eposten for å ekstrahere body og subject /// 3. Sjekker at envelope-sender finnes i auth_identities.email /// 4. Sjekker at body starter med aktiveringsfrasen /// 5. Oppretter content-node med epostinnholdet /// /// Exit 0 alltid — Postfix skal aldri prøve på nytt (ingen bounce). async fn run_receive(cli: &Cli) { synops_common::logging::init("synops_mail"); let recipient = cli.recipient.as_deref().unwrap_or("(ukjent)"); let sender = cli.sender.as_deref().unwrap_or("(ukjent)"); // Les rå epost fra stdin let mut raw_email = Vec::new(); if let Err(e) = io::stdin().read_to_end(&mut raw_email) { tracing::error!("feil ved lesing fra stdin: {e}"); return; // exit 0 } tracing::info!( from = sender, to = recipient, size = raw_email.len(), "epost mottatt" ); // Parse eposten let parsed = match mailparse::parse_mail(&raw_email) { Ok(p) => p, Err(e) => { tracing::warn!("kunne ikke parse epost: {e}"); return; } }; // Ekstraher subject fra headers let subject = parsed .headers .iter() .find(|h| h.get_key().eq_ignore_ascii_case("subject")) .map(|h| h.get_value()) .unwrap_or_default(); // Ekstraher body (plain text) let body = extract_plain_text(&parsed); tracing::info!(subject = %subject, body_len = body.len(), "epost parset"); // Verifiser avsender: epost matcher auth_identities.email? let db = match synops_common::db::connect().await { Ok(pool) => pool, Err(e) => { tracing::error!("database-tilkobling feilet: {e}"); return; } }; let sender_email = sender.trim().to_lowercase(); let user_node_id: Option = match sqlx::query_scalar( "SELECT node_id FROM auth_identities WHERE LOWER(email) = $1", ) .bind(&sender_email) .fetch_optional(&db) .await { Ok(id) => id, Err(e) => { tracing::error!("database-spørring feilet: {e}"); return; } }; let user_node_id = match user_node_id { Some(id) => { tracing::info!(user_node_id = %id, "avsender verifisert via epost"); id } None => { tracing::info!( sender = sender_email, "avsender ikke funnet i auth_identities — forkaster" ); return; } }; // Domene-alias: mottaker-username oppslås uavhengig av domene. // vegard@synops.no, vegard@sidelinja.org, vegard@vegard.info // ruter alle til samme bruker. let recipient_username = recipient .split('@') .next() .unwrap_or("") .trim() .to_lowercase(); let target_node_id: Option = match sqlx::query_scalar( "SELECT node_id FROM auth_identities WHERE LOWER(username) = $1", ) .bind(&recipient_username) .fetch_optional(&db) .await { Ok(id) => id, Err(e) => { tracing::warn!("username-oppslag feilet: {e}"); None } }; // Hvis mottaker-username matcher en bruker, bruk den. // Ellers havner noden hos avsender (epost til seg selv / vaktmester). let owner_node_id = target_node_id.unwrap_or(user_node_id); if target_node_id.is_some() { tracing::info!( recipient_username = %recipient_username, owner = %owner_node_id, "mottaker funnet via username" ); } let body_trimmed = body.trim(); // Avsender verifisert — opprett content-node let node_id = uuid::Uuid::now_v7(); let title = if subject.is_empty() { format!("Epost fra {}", sender_email) } else { subject.clone() }; let metadata = serde_json::json!({ "source": "email", "from": sender_email, "to": recipient, "subject": subject, }); match sqlx::query( "INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) VALUES ($1, 'content', $2, $3, 'hidden', $4, $5)", ) .bind(node_id) .bind(&title) .bind(body_trimmed) .bind(&metadata) .bind(owner_node_id) .execute(&db) .await { Ok(_) => { tracing::info!( node_id = %node_id, owner = %owner_node_id, title = %title, "content-node opprettet fra epost" ); } Err(e) => { tracing::error!("kunne ikke opprette node: {e}"); } } } /// Ekstraher plain text-body fra en parset epost. /// Håndterer enkel body og multipart (tar første text/plain-del). /// Prøver raw UTF-8 først for å unngå mailparse sin latin-1-fallback. fn extract_plain_text(mail: &mailparse::ParsedMail) -> String { if mail.subparts.is_empty() { // Enkel (ikke-multipart) epost — prøv raw UTF-8 først return get_body_utf8(mail); } // Multipart — finn første text/plain for part in &mail.subparts { let ctype = part.ctype.mimetype.to_lowercase(); if ctype == "text/plain" { return get_body_utf8(part); } } // Fallback: prøv første del mail.subparts .first() .map(|p| get_body_utf8(p)) .unwrap_or_default() } /// Hent body fra en mail-del. Prøver raw bytes som UTF-8 først, /// faller tilbake til mailparse sin charset-konvertering. fn get_body_utf8(part: &mailparse::ParsedMail) -> String { // Hvis raw body-bytes er gyldig UTF-8, bruk det direkte if let Ok(raw) = part.get_body_raw() { if let Ok(s) = String::from_utf8(raw) { return s; } } // Ellers la mailparse håndtere charset-konvertering part.get_body().unwrap_or_default() }