inbuxa:DlpSettings (singleton, urn:inbuxa:jmap): keepHeldDays, 1 to 90, 7 by default (settled answer 5 made it a setting). sysDlpPolicyGet reads it, sysDlpPolicyUpdate changes it, server-level, audited by the request layer. Each held message keeps the days it was given, and the sender's notices say that number. Privacy catalog entry; spec §2.6 updated. mail_rules_tests: 7 by default, 0 refused, 3 set and a message held afterwards expires 3 days after it was held, the expiry notice says 3.
181 lines
6.2 KiB
Rust
181 lines
6.2 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
//! inbuxa: mail held for review (dlp-and-mail-flow-rules spec, §2.6):
|
|
//! releasing it, rejecting it, rejecting what nobody reviewed in time, and
|
|
//! the notices the sender gets.
|
|
//!
|
|
//! A held message sits in the queue with its release [`HOLD_SECONDS`] off.
|
|
//! Releasing it undoes exactly that: each recipient due now, its next
|
|
//! notice as far from now as it was from its retry, its lifetime counted
|
|
//! from the release.
|
|
|
|
use crate::{
|
|
queue::{Message, MessageWrapper, Status, spool::SmtpSpool},
|
|
reporting::send::MtaReportSend,
|
|
};
|
|
use common::{
|
|
Server,
|
|
config::smtp::queue::{QueueExpiry, QueueName},
|
|
ipc::QueueEvent,
|
|
};
|
|
use inbuxa_features::{
|
|
audit::{Action, Actor, Outcome, Record, Target},
|
|
mailflow::held::{self, HOLD_SECONDS, Held},
|
|
};
|
|
use mail_builder::{
|
|
MessageBuilder,
|
|
headers::{HeaderType, address::Address},
|
|
};
|
|
use store::{ahash::AHashSet, write::now};
|
|
|
|
/// Puts a held message back on its way. False when it's no longer queued.
|
|
pub async fn release(server: &Server, queue_id: u64) -> trc::Result<bool> {
|
|
let Some(archive) = server.read_message_archive(queue_id).await? else {
|
|
held::delete(server.store(), queue_id).await?;
|
|
return Ok(false);
|
|
};
|
|
let mut message: Message = archive.to_unarchived::<Message>()?.deserialize()?;
|
|
let prev_events = message.next_events();
|
|
let at = now();
|
|
let mut modified = AHashSet::new();
|
|
for (idx, rcpt) in message.recipients.iter_mut().enumerate() {
|
|
if !matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) {
|
|
continue;
|
|
}
|
|
let notify_gap = rcpt.notify.due.saturating_sub(rcpt.retry.due);
|
|
rcpt.retry.due = at;
|
|
rcpt.notify.due = at + notify_gap;
|
|
if let QueueExpiry::Ttl(ttl) = rcpt.expires {
|
|
rcpt.expires = QueueExpiry::Ttl(
|
|
ttl.saturating_sub(HOLD_SECONDS) + at.saturating_sub(message.created),
|
|
);
|
|
}
|
|
modified.insert(idx);
|
|
}
|
|
let saved = MessageWrapper::new(message, queue_id, QueueName::default())
|
|
.save_registry_changes(server, prev_events, modified)
|
|
.await;
|
|
held::delete(server.store(), queue_id).await?;
|
|
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
|
|
Ok(saved)
|
|
}
|
|
|
|
/// Takes a held message out of the queue and tells its sender, with the
|
|
/// reviewer's note if there is one. False when it's no longer queued.
|
|
pub async fn reject(server: &Server, record: &Held, note: Option<&str>) -> trc::Result<bool> {
|
|
let removed = match server
|
|
.read_message(record.queue_id, QueueName::default())
|
|
.await
|
|
{
|
|
Some(message) => message.remove(server, None).await,
|
|
None => false,
|
|
};
|
|
held::delete(server.store(), record.queue_id).await?;
|
|
let mut text = format!(
|
|
"Your message \"{}\" to {} was held for review under this server's rules, and wasn't sent.\r\n",
|
|
record.subject,
|
|
record.recipients.join(", ")
|
|
);
|
|
match note {
|
|
Some(note) => text.push_str(&format!("\r\nThe reviewer's note: {note}\r\n")),
|
|
None => text.push_str(&format!(
|
|
"\r\nNobody reviewed it within {} days, so it was returned.\r\n",
|
|
record.keep_days
|
|
)),
|
|
}
|
|
notify(
|
|
server,
|
|
record,
|
|
&format!("Not sent: {}", record.subject),
|
|
text,
|
|
)
|
|
.await;
|
|
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Tells the sender their message is held (when the rule asks).
|
|
pub async fn notify_held(server: &Server, record: &Held) {
|
|
let notices = record
|
|
.rules
|
|
.iter()
|
|
.map(|r| r.notice.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
let text = format!(
|
|
"Your message \"{}\" to {} is held for review under this server's rules: {notices}\r\n\r\n\
|
|
It will be sent if a reviewer releases it, and returned otherwise within {} days.\r\n",
|
|
record.subject,
|
|
record.recipients.join(", "),
|
|
record.keep_days,
|
|
);
|
|
notify(
|
|
server,
|
|
record,
|
|
&format!("Held for review: {}", record.subject),
|
|
text,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn notify(server: &Server, record: &Held, subject: &str, text: String) {
|
|
let domain = record
|
|
.sender
|
|
.rsplit_once('@')
|
|
.map_or("localhost", |(_, d)| d);
|
|
let from = format!("postmaster@{domain}");
|
|
let message = MessageBuilder::new()
|
|
.from(Address::new_address(Some("Mail review"), from.clone()))
|
|
.to(Address::new_address(None::<String>, record.sender.clone()))
|
|
.subject(subject)
|
|
.header("Auto-Submitted", HeaderType::Text("auto-replied".into()))
|
|
.text_body(text)
|
|
.write_to_vec()
|
|
.unwrap_or_default();
|
|
server
|
|
.send_autogenerated(from, [record.sender.as_str()].into_iter(), message, None, 0)
|
|
.await;
|
|
}
|
|
|
|
/// Rejects every held message nobody reviewed in time (§2.6), each
|
|
/// recorded as the server's doing. Returns how many.
|
|
pub async fn expire(server: &Server) -> trc::Result<usize> {
|
|
let at = now();
|
|
let mut count = 0;
|
|
for record in held::all(server.store()).await? {
|
|
if !record.is_expired(at) {
|
|
continue;
|
|
}
|
|
reject(server, &record, None).await?;
|
|
count += 1;
|
|
server
|
|
.audit_note(Record {
|
|
at: at * 1000,
|
|
actor: Actor::system("DLP"),
|
|
via: None,
|
|
remote_ip: None,
|
|
action: Action::Destroy,
|
|
target: Target {
|
|
kind: "inbuxa:HeldMessage".into(),
|
|
id: Some(record.queue_id.to_string()),
|
|
name: Some(record.subject.clone()),
|
|
account_id: record.account_id,
|
|
tenant_id: record.tenant_id,
|
|
},
|
|
changes: vec![],
|
|
details: Some(format!(
|
|
"Rejected: nobody reviewed it within {} days; the sender was told",
|
|
record.keep_days
|
|
)),
|
|
reason: None,
|
|
outcome: Outcome::success(),
|
|
})
|
|
.await;
|
|
}
|
|
Ok(count)
|
|
}
|