use proc_macro2::TokenStream;
use quote::{format_ident, quote};
use syn::{ItemFn, LitStr, parse::Parser as _};
struct InboundMailAttrs {
to: Option<String>,
pattern_kind: Option<String>,
processing: Option<String>,
}
fn parse_attrs(attr: TokenStream) -> syn::Result<InboundMailAttrs> {
let mut result = InboundMailAttrs {
to: None,
pattern_kind: None,
processing: None,
};
syn::meta::parser(|meta| {
if meta.path.is_ident("to") {
let value: LitStr = meta.value()?.parse()?;
result.to = Some(value.value());
Ok(())
} else if meta.path.is_ident("pattern") {
let value: LitStr = meta.value()?.parse()?;
result.pattern_kind = Some(value.value());
Ok(())
} else if meta.path.is_ident("processing") {
let value: LitStr = meta.value()?.parse()?;
result.processing = Some(value.value());
Ok(())
} else {
Err(meta.error("unsupported attribute: expected `to`, `pattern`, or `processing`"))
}
})
.parse2(attr)?;
Ok(result)
}
fn detect_pattern(to: &str) -> TokenStream {
if to == "*" || to.is_empty() {
return quote! { ::autumn_web::inbound_mail::RecipientPattern::Any };
}
let (local_part, domain_part) = to.rfind('@').map_or((to, None), |at_pos| {
(&to[..at_pos], Some(&to[at_pos + 1..]))
});
if let Some(plus_pos) = local_part.find('+') {
let tag = &local_part[plus_pos + 1..];
if tag.starts_with('{') && tag.ends_with('}') {
let local = &local_part[..plus_pos];
let domain = match domain_part {
Some(d) if !d.is_empty() => {
let d = d.to_string();
quote! { Some(#d.to_string()) }
}
_ => quote! { None },
};
let l = local.to_string();
return quote! {
::autumn_web::inbound_mail::RecipientPattern::PlusAddress {
local: #l.to_string(),
domain: #domain,
}
};
}
}
if to.ends_with('*') {
let prefix = to.trim_end_matches('*').trim_end_matches('.');
let p = prefix.to_string();
return quote! {
::autumn_web::inbound_mail::RecipientPattern::LocalPrefix(#p.to_string())
};
}
let addr = to.to_string();
quote! {
::autumn_web::inbound_mail::RecipientPattern::Exact(#addr.to_string())
}
}
pub fn inbound_mail_macro(attr: TokenStream, item: TokenStream) -> TokenStream {
let attrs = match parse_attrs(attr) {
Ok(a) => a,
Err(e) => return e.to_compile_error(),
};
let input_fn: ItemFn = match syn::parse2(item) {
Ok(f) => f,
Err(e) => return e.to_compile_error(),
};
if input_fn.sig.asyncness.is_none() {
return syn::Error::new_spanned(
input_fn.sig.fn_token,
"#[inbound_mail] functions must be async",
)
.to_compile_error();
}
let fn_name = &input_fn.sig.ident;
let info_fn_name = format_ident!("{fn_name}_handler_info");
let handler_name = fn_name.to_string();
let pattern_ts = match attrs.pattern_kind.as_deref() {
Some("exact") => {
let addr = attrs.to.as_deref().unwrap_or("").to_string();
quote! { ::autumn_web::inbound_mail::RecipientPattern::Exact(#addr.to_string()) }
}
Some("prefix") => {
let prefix = attrs
.to
.as_deref()
.unwrap_or("")
.trim_end_matches('*')
.to_string();
quote! { ::autumn_web::inbound_mail::RecipientPattern::LocalPrefix(#prefix.to_string()) }
}
Some("any") => {
quote! { ::autumn_web::inbound_mail::RecipientPattern::Any }
}
None => attrs.to.as_ref().map_or_else(
|| quote! { ::autumn_web::inbound_mail::RecipientPattern::Any },
|to| detect_pattern(to),
),
Some(other) => {
return syn::Error::new(
proc_macro2::Span::call_site(),
format!("unknown pattern `{other}`; expected `exact`, `prefix`, or `any`"),
)
.to_compile_error();
}
};
let processing_ts = match attrs.processing.as_deref() {
None | Some("background") => {
quote! { ::autumn_web::inbound_mail::ProcessingMode::Background }
}
Some("sync") => quote! { ::autumn_web::inbound_mail::ProcessingMode::Sync },
Some(other) => {
return syn::Error::new(
proc_macro2::Span::call_site(),
format!("unknown processing mode `{other}`; expected `sync` or `background`"),
)
.to_compile_error();
}
};
let wrapper_name = format_ident!("__inbound_mail_wrapper_{fn_name}");
quote! {
#input_fn
fn #wrapper_name(
email: ::autumn_web::inbound_mail::InboundEmail,
) -> ::std::pin::Pin<Box<
dyn ::std::future::Future<
Output = ::autumn_web::AutumnResult<()>
> + Send + 'static
>> {
Box::pin(#fn_name(email))
}
#[must_use]
pub fn #info_fn_name() -> ::autumn_web::inbound_mail::InboundMailHandlerInfo {
::autumn_web::inbound_mail::InboundMailHandlerInfo {
name: #handler_name,
pattern: #pattern_ts,
processing: #processing_ts,
handler: #wrapper_name,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quote::quote;
#[test]
fn detect_exact_pattern() {
let ts = detect_pattern("support@company.com");
let s = ts.to_string();
assert!(s.contains("Exact"), "expected Exact, got: {s}");
assert!(s.contains("support@company.com"), "got: {s}");
}
#[test]
fn detect_plus_address_pattern() {
let ts = detect_pattern("replies+{token}@app.example");
let s = ts.to_string();
assert!(s.contains("PlusAddress"), "expected PlusAddress, got: {s}");
assert!(s.contains("replies"), "got: {s}");
assert!(s.contains("app.example"), "got: {s}");
}
#[test]
fn detect_plus_address_no_domain() {
let ts = detect_pattern("replies+{token}@");
let s = ts.to_string();
assert!(s.contains("PlusAddress"), "expected PlusAddress, got: {s}");
assert!(s.contains("None"), "expected None domain, got: {s}");
}
#[test]
fn detect_local_prefix_pattern() {
let ts = detect_pattern("ticket+*");
let s = ts.to_string();
assert!(s.contains("LocalPrefix"), "expected LocalPrefix, got: {s}");
}
#[test]
fn detect_any_pattern() {
let ts = detect_pattern("*");
let s = ts.to_string();
assert!(s.contains("Any"), "expected Any, got: {s}");
}
#[test]
fn parse_attrs_to() {
let attr = quote! { to = "support@company.com" };
let a = parse_attrs(attr).unwrap();
assert_eq!(a.to.as_deref(), Some("support@company.com"));
}
#[test]
fn parse_attrs_processing() {
let attr = quote! { to = "a@b.com", processing = "sync" };
let a = parse_attrs(attr).unwrap();
assert_eq!(a.processing.as_deref(), Some("sync"));
}
#[test]
fn parse_attrs_rejects_unknown() {
let attr = quote! { unknown = "value" };
let result = parse_attrs(attr);
assert!(result.is_err());
}
#[test]
fn macro_expands_on_valid_async_fn() {
let attr = quote! { to = "support@company.com" };
let item = quote! {
async fn handle_support(
email: ::autumn_web::inbound_mail::InboundEmail,
) -> ::autumn_web::AutumnResult<()> {
Ok(())
}
};
let expanded = inbound_mail_macro(attr, item);
let s = expanded.to_string();
assert!(
s.contains("handle_support_handler_info"),
"expected handler info fn, got: {s}"
);
assert!(s.contains("Exact"), "expected Exact pattern, got: {s}");
}
#[test]
fn macro_rejects_non_async_fn() {
let attr = quote! {};
let item = quote! {
fn not_async(email: InboundEmail) -> AutumnResult<()> {
Ok(())
}
};
let expanded = inbound_mail_macro(attr, item);
let s = expanded.to_string();
assert!(
s.contains("compile_error"),
"expected compile_error, got: {s}"
);
}
#[test]
fn macro_generates_plus_address_pattern() {
let attr = quote! { to = "replies+{token}@app.example" };
let item = quote! {
async fn handle_reply(
email: ::autumn_web::inbound_mail::InboundEmail,
) -> ::autumn_web::AutumnResult<()> {
Ok(())
}
};
let expanded = inbound_mail_macro(attr, item);
let s = expanded.to_string();
assert!(s.contains("PlusAddress"), "expected PlusAddress, got: {s}");
}
}