diff --git a/crates/integration-tests/spf-basic.lua b/crates/integration-tests/spf-basic.lua index 3dc2912e..f812bebb 100644 --- a/crates/integration-tests/spf-basic.lua +++ b/crates/integration-tests/spf-basic.lua @@ -70,11 +70,7 @@ end) kumo.on('smtp_server_message_received', function(msg, conn_meta) msg:set_meta('queue', 'maildir') - local result = kumo.spf.check_host( - msg:sender().domain, - conn_meta, - tostring(msg:sender()) - ) + local result = kumo.spf.check_msg(msg) local results = { result.result, } diff --git a/crates/integration-tests/src/test/spf_basic.rs b/crates/integration-tests/src/test/spf_basic.rs index c58be2d1..172028fc 100644 --- a/crates/integration-tests/src/test/spf_basic.rs +++ b/crates/integration-tests/src/test/spf_basic.rs @@ -70,6 +70,7 @@ async fn spf_basic() -> anyhow::Result<()> { let headers = entry.headers()?; let from = headers.from().unwrap().unwrap().0; let results = headers.authentication_results().unwrap().unwrap().results; + eprintln!("{results:#?}"); match from[0].address.domain.as_str() { "example.com" => { assert_eq!(results.len(), 1); @@ -79,6 +80,10 @@ async fn spf_basic() -> anyhow::Result<()> { results[0].reason.as_deref().unwrap(), "no SPF records found for example.com" ); + assert_eq!( + results[0].props.get("smtp.mailfrom").unwrap(), + "sender@example.com" + ); } "allowed.localhost" => { assert_eq!(results.len(), 1); @@ -88,6 +93,10 @@ async fn spf_basic() -> anyhow::Result<()> { results[0].reason.as_deref().unwrap(), "matched 'all' directive" ); + assert_eq!( + results[0].props.get("smtp.mailfrom").unwrap(), + "foo@allowed.localhost" + ); } _ => unreachable!(), } diff --git a/crates/kumod/src/spf.rs b/crates/kumod/src/spf.rs index 1d744f46..48386271 100644 --- a/crates/kumod/src/spf.rs +++ b/crates/kumod/src/spf.rs @@ -1,7 +1,8 @@ use crate::smtp_server::ConnectionMetaData; -use config::{get_or_create_sub_module, serialize_options}; +use config::{any_err, get_or_create_sub_module, serialize_options}; use kumo_spf::{CheckHostParams, SpfDisposition}; use mailparsing::AuthenticationResult; +use message::Message; use mlua::{Lua, LuaSerdeExt, UserDataRef}; use serde::Serialize; use std::collections::BTreeMap; @@ -17,49 +18,104 @@ pub struct CheckHostOutput { pub fn register<'lua>(lua: &'lua Lua) -> anyhow::Result<()> { let spf_mod = get_or_create_sub_module(lua, "spf")?; + fn build_from_domain_meta_sender( + domain: String, + meta: &ConnectionMetaData, + sender: Option, + ) -> anyhow::Result { + let addr: SocketAddr = meta + .get_meta("received_from") + .and_then(|v| v.as_str().map(SocketAddr::from_str)) + .transpose() + .map_err(any_err)? + .ok_or_else(|| anyhow::anyhow!("received_from missing from metadata"))?; + + let ehlo_domain = if sender.is_some() { + meta.get_meta_string("ehlo_domain") + } else { + Some(domain.clone()) + }; + let relaying_host_name = meta.get_meta_string("hostname"); + + Ok(CheckHostParams { + domain, + sender, + client_ip: addr.ip(), + ehlo_domain, + relaying_host_name, + }) + } + + fn build_from_msg(msg: &Message) -> anyhow::Result { + let addr: SocketAddr = msg + .get_meta("received_from") + .map(|v| v.as_str().map(SocketAddr::from_str)) + .map_err(any_err)? + .transpose() + .map_err(any_err)? + .ok_or_else(|| anyhow::anyhow!("received_from missing from metadata"))?; + + let ehlo_domain = msg.get_meta_string("ehlo_domain").map_err(any_err)?; + let relaying_host_name = msg.get_meta_string("hostname").map_err(any_err)?; + + let sender = msg.sender().map_err(any_err)?; + let domain = sender.domain().to_string(); + + Ok(CheckHostParams { + domain, + sender: Some(sender.to_string()), + client_ip: addr.ip(), + ehlo_domain, + relaying_host_name, + }) + } + + async fn do_check(lua: &mlua::Lua, params: CheckHostParams) -> mlua::Result { + let resolver = dns_resolver::get_resolver(); + + let sender = params.sender.clone(); + let result = params.check(&**resolver).await; + + let mut props = BTreeMap::default(); + + if let Some(sender) = sender { + props.insert("smtp.mailfrom".to_string(), sender); + } + + lua.to_value_with( + &CheckHostOutput { + disposition: result.disposition, + result: AuthenticationResult { + method: "spf".to_string(), + method_version: None, + result: result.disposition.to_string(), + reason: Some(result.context), + props, + }, + }, + serialize_options(), + ) + } + spf_mod.set( "check_host", lua.create_async_function( |lua, (domain, meta, sender): (String, UserDataRef, Option)| async move { - let addr = meta - .get_meta("received_from") - .and_then(|v| SocketAddr::from_str(v.as_str()?).ok()) - .expect("`received_from` is always set, and always to a value representing a `SocketAddr`"); + let params = build_from_domain_meta_sender(domain, &meta, sender).map_err(any_err)?; - let ehlo_domain = if sender.is_some() { - meta.get_meta_string("ehlo_domain") - } else { - Some(domain.clone()) - }; - let relaying_host_name = meta.get_meta_string("hostname"); + do_check(&lua, params).await - let resolver = dns_resolver::get_resolver(); - let result = CheckHostParams { - domain, - sender, - client_ip: addr.ip(), - ehlo_domain, - relaying_host_name, - } - .check(&**resolver) - .await; - - Ok(lua.to_value_with( - &CheckHostOutput { - disposition: result.disposition, - result: AuthenticationResult { - method: "spf".to_string(), - method_version: None, - result: result.disposition.to_string(), - reason: Some(result.context), - props: BTreeMap::default(), - }, - }, - serialize_options(), - )) }, )?, )?; + spf_mod.set( + "check_msg", + lua.create_async_function(|lua, msg: Message| async move { + let params = build_from_msg(&msg).map_err(any_err)?; + do_check(&lua, params).await + })?, + )?; + Ok(()) } diff --git a/docs/changelog/main.md b/docs/changelog/main.md index 6069f695..827c6fa5 100644 --- a/docs/changelog/main.md +++ b/docs/changelog/main.md @@ -57,6 +57,9 @@ records produced by the smtp listener. The service key is the listener address and port, and there is a `total` key that represents the total across all listeners. + * new [kumo.spf.check_msg](../reference/kumo.spf/check_msg.md) convenience + function for checking SPF and producing Authentication-Results once the + data has been received. ## Fixes @@ -78,3 +81,4 @@ quoted in the display name of a mailbox. #428 * Incorrectly treated a 552 as a transient failure for non-RCPT-TO 552 responses. #431 + * spf: we now populate `smtp.mailfrom` in the Authentication-Results props map. diff --git a/docs/reference/kumo.spf/check_msg.md b/docs/reference/kumo.spf/check_msg.md new file mode 100644 index 00000000..e4151fb2 --- /dev/null +++ b/docs/reference/kumo.spf/check_msg.md @@ -0,0 +1,48 @@ +# kumo.spf.check_msg + +```lua +kumo.spf.check_msg(MESSAGE) +``` + +{{since('dev')}} + +This function will check SPF records from DNS for the provided message. +It will extract the appropriate domain and sender information from the metadata and message. + +It will return an object containing the SPF `disposition` string and a `result` +of type `authenticationresult` for use with `msg:add_authentication_results()`. + +## Example: checking the SPF policy + +```lua +kumo.on('smtp_server_message_received', function(msg, conn_meta) + -- Check the SPF policy for the domain and return the results. + local result = kumo.spf.check_msg(msg) + print('spf', kumo.json_encode_pretty(result)) + if result.disposition ~= 'pass' then + kumo.reject(420, 'go away') + end +end) +``` + +might print something like this to the diagnostic log: + +``` +spf [ + "disposition": "pass", + { + "result": "pass", + "method": "spf", + "reason": "matched 'all' directive", + "method_version": null, + "props": { + "smtp.mailfrom": "sender@example.com" + } + } +] +``` + +## See Also: + +* [msg:add_authentication_results()](../message/add_authentication_results.md) +