26.5 ferdig: synops-mail --receive med avsender-verifisering og node-opprettelse
Implementert fullstendig epost-mottak pipeline: - Parser raw RFC 5322 epost fra stdin via mailparse - Sjekk 1: Envelope-sender matches auth_identities.email (case-insensitive) - Sjekk 2: Body starter med konfigurerbar aktiveringsfrase (default: "Kjære vaktmester") - Begge sjekker må bestå — ellers forkastes eposten stille (exit 0, ingen bounce) - Ved match: oppretter content-node med visibility=hidden, created_by=bruker - Metadata lagrer source=email, from, to, subject for sporbarhet - UTF-8 håndtering: prøver raw bytes som UTF-8 først, faller tilbake til mailparse charset - Aktiveringsfrase konfigurerbar via --phrase eller SYNOPS_MAIL_PHRASE env
This commit is contained in:
parent
ad8aa2181a
commit
25713c4482
4 changed files with 2507 additions and 40 deletions
3
tasks.md
3
tasks.md
|
|
@ -349,8 +349,7 @@ sidelinja.org, vegard.info) ruter til samme bruker basert på username.
|
|||
- [x] 26.2 msmtp oppsett: konfigurer utgående epost via SMTP-relay. Avsender: `vaktmester@synops.no`. Tilgjengelig som `synops-mail --send --to <epost> --subject <emne>` CLI-verktøy.
|
||||
- [x] 26.3 MX-records: sett opp MX for synops.no, sidelinja.org, vegard.info som peker til serveren.
|
||||
- [x] 26.4 Postfix minimal: installer Postfix som lokal MTA kun for mottak. Ingen relay, ingen kø for utgående. Pipe innkommende epost til `synops-mail --receive`.
|
||||
- [~] 26.5 `synops-mail --receive`: Rust CLI som leser raw epost fra stdin. Sjekk 1: avsender-epost matcher `auth_identities.email`? Sjekk 2: innhold starter med "Kjære vaktmester" (eller konfigurerbar frase)? Begge må matche. Opprett `content`-node i brukerens innboks med epostinnholdet. Alt annet → /dev/null, ingen bounce.
|
||||
> Påbegynt: 2026-03-19T01:47
|
||||
- [x] 26.5 `synops-mail --receive`: Rust CLI som leser raw epost fra stdin. Sjekk 1: avsender-epost matcher `auth_identities.email`? Sjekk 2: innhold starter med "Kjære vaktmester" (eller konfigurerbar frase)? Begge må matche. Opprett `content`-node i brukerens innboks med epostinnholdet. Alt annet → /dev/null, ingen bounce.
|
||||
- [ ] 26.6 Domene-alias: `vegard@synops.no`, `vegard@sidelinja.org`, `vegard@vegard.info` ruter alle til samme bruker via username-oppslag i PG. Domenet er irrelevant.
|
||||
- [ ] 26.7 Utgående varsler: vaktmesteren kan sende epost-varsler til brukere (ny oppgave tildelt, innsendt artikkel godkjent, etc.) via `synops-mail --send`. Konfigurerbart per bruker i metadata.preferences.
|
||||
|
||||
|
|
|
|||
2308
tools/synops-mail/Cargo.lock
generated
2308
tools/synops-mail/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,3 +9,10 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
synops-common = { path = "../synops-common" }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
mailparse = "0.15"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
//
|
||||
// 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"
|
||||
|
|
@ -15,31 +19,28 @@
|
|||
// 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, Subcommand};
|
||||
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";
|
||||
const DEFAULT_PHRASE: &str = "Kjære vaktmester";
|
||||
|
||||
/// Synops epost-verktøy: send og motta epost.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "synops-mail", about = "Send og motta epost for Synops")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
// --- Legacy flag-basert API (bakoverkompatibel) ---
|
||||
|
||||
/// Send-modus (legacy: bruk `send` subcommand i stedet)
|
||||
/// Send-modus
|
||||
#[arg(long)]
|
||||
send: bool,
|
||||
|
||||
/// Motta-modus (legacy: bruk `receive` subcommand i stedet)
|
||||
/// Motta-modus (fra Postfix pipe transport)
|
||||
#[arg(long)]
|
||||
receive: bool,
|
||||
|
||||
|
|
@ -70,18 +71,22 @@ struct Cli {
|
|||
/// Envelope-avsender fra Postfix (--receive)
|
||||
#[arg(long)]
|
||||
sender: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
// Subcommands kan legges til senere
|
||||
/// Aktiveringsfrase som epost-body må starte med (default: "Kjære vaktmester")
|
||||
#[arg(long, env = "SYNOPS_MAIL_PHRASE", default_value = DEFAULT_PHRASE)]
|
||||
phrase: String,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if cli.receive {
|
||||
run_receive(&cli);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +95,10 @@ fn main() {
|
|||
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);
|
||||
|
|
@ -100,8 +109,8 @@ fn main() {
|
|||
});
|
||||
|
||||
// Les body fra --body eller stdin
|
||||
let body = match cli.body {
|
||||
Some(b) => b,
|
||||
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| {
|
||||
|
|
@ -116,7 +125,7 @@ fn main() {
|
|||
}
|
||||
};
|
||||
|
||||
let config_path = cli.config.unwrap_or_else(|| DEFAULT_CONFIG.to_string());
|
||||
let config_path = cli.config.as_deref().unwrap_or(DEFAULT_CONFIG);
|
||||
|
||||
// Bygg RFC 5322-melding
|
||||
let message = format!(
|
||||
|
|
@ -130,14 +139,11 @@ fn main() {
|
|||
{body}",
|
||||
from_name = DEFAULT_FROM_NAME,
|
||||
from = cli.from,
|
||||
to = to,
|
||||
subject = subject,
|
||||
body = body,
|
||||
);
|
||||
|
||||
// Send via msmtp
|
||||
let mut child = Command::new("msmtp")
|
||||
.args(["--file", &config_path, "--from", &cli.from, to])
|
||||
.args(["--file", config_path, "--from", &cli.from, to])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
|
@ -147,7 +153,6 @@ fn main() {
|
|||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Skriv melding til msmtp stdin
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use std::io::Write;
|
||||
stdin.write_all(message.as_bytes()).unwrap_or_else(|e| {
|
||||
|
|
@ -172,27 +177,179 @@ fn main() {
|
|||
|
||||
/// Motta epost fra Postfix pipe transport.
|
||||
///
|
||||
/// Leser rå RFC 5322-epost fra stdin og logger mottaket.
|
||||
/// Faktisk prosessering (brukeroppslag, node-opprettelse) implementeres i oppgave 26.5.
|
||||
fn run_receive(cli: &Cli) {
|
||||
/// 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 = String::new();
|
||||
if let Err(e) = io::stdin().read_to_string(&mut raw_email) {
|
||||
eprintln!("synops-mail receive: feil ved lesing fra stdin: {e}");
|
||||
// Exit 0 slik at Postfix ikke prøver på nytt
|
||||
std::process::exit(0);
|
||||
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
|
||||
}
|
||||
|
||||
let size = raw_email.len();
|
||||
|
||||
// Logg mottaket (syslog via stderr som Postfix fanger)
|
||||
eprintln!(
|
||||
"synops-mail receive: mottatt epost fra={sender} til={recipient} størrelse={size} bytes"
|
||||
tracing::info!(
|
||||
from = sender,
|
||||
to = recipient,
|
||||
size = raw_email.len(),
|
||||
"epost mottatt"
|
||||
);
|
||||
|
||||
// Stub: aksepter og logg. Oppgave 26.5 implementerer brukeroppslag og node-opprettelse.
|
||||
// Exit 0 = levert OK (Postfix sletter fra kø)
|
||||
// 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");
|
||||
|
||||
// Sjekk 1: 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");
|
||||
id
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
sender = sender_email,
|
||||
"avsender ikke funnet i auth_identities — forkaster"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Sjekk 2: Body starter med aktiveringsfrasen?
|
||||
let body_trimmed = body.trim();
|
||||
if !body_trimmed.starts_with(&cli.phrase) {
|
||||
tracing::info!(
|
||||
phrase = %cli.phrase,
|
||||
body_start = &body_trimmed[..body_trimmed.len().min(50)],
|
||||
"epost mangler aktiveringsfrase — forkaster"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Begge sjekker bestått — 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(user_node_id)
|
||||
.execute(&db)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
node_id = %node_id,
|
||||
user = %user_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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue