- Fjernet "Kjære vaktmester"-krav: avsender-verifisering via auth_identities.email er tilstrekkelig spam-filter - Domene-alias: mottaker-username oppslås i auth_identities uavhengig av domene. vegard@synops.no, vegard@sidelinja.org, vegard@vegard.info ruter til samme bruker Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
378 lines
11 KiB
Rust
378 lines
11 KiB
Rust
// 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<String>,
|
|
|
|
/// Emne (--send)
|
|
#[arg(long)]
|
|
subject: Option<String>,
|
|
|
|
/// Brødtekst (--send, valgfritt — leses fra stdin om ikke oppgitt)
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
|
|
/// 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<String>,
|
|
|
|
/// Envelope-mottaker fra Postfix (--receive)
|
|
#[arg(long)]
|
|
recipient: Option<String>,
|
|
|
|
/// Envelope-avsender fra Postfix (--receive)
|
|
#[arg(long)]
|
|
sender: Option<String>,
|
|
|
|
}
|
|
|
|
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<uuid::Uuid> = 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<uuid::Uuid> = 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()
|
|
}
|