#[cfg(feature = "inbound-email")]
use tracing::info;
#[cfg(feature = "inbound-email")]
use super::{RunCx, ServeCx};
use crate::capability::Capability;
#[cfg(feature = "inbound-email")]
use crate::{
capability::Started,
server::provision::{BootCx, ProvisionStep},
};
#[derive(Debug)]
pub struct InboundEmail;
#[async_trait::async_trait]
impl Capability for InboundEmail {
#[cfg(feature = "inbound-email")]
fn configured(&self, cx: &ServeCx<'_>) -> bool {
cx.server.db_pool.is_some()
&& cx.server.config.mailbox.values().any(|mailbox| mailbox.imap.is_some())
}
#[cfg(feature = "inbound-email")]
fn provision(&self, cx: &BootCx<'_>) -> Vec<ProvisionStep> {
let server = cx.server;
let polled = server.config.mailbox.values().any(|mailbox| mailbox.imap.is_some());
match server.db_pool.as_ref() {
Some(pool) if polled => vec![ProvisionStep::new(
"inbound email",
pool,
&[
fraiseql_functions::migrations::inbound_migration_sql(),
fraiseql_observers::migrations::source_cursor_sql(),
fraiseql_functions::migrations::send_tracking_migration_sql(),
],
)],
_ => Vec::new(),
}
}
#[cfg(feature = "inbound-email")]
async fn start(&self, cx: &mut RunCx<'_>) -> crate::Result<Started> {
use crate::inbound::email;
let app_state = cx.app;
let server = &mut *cx.server;
let Some(db_pool) = server.db_pool.as_ref() else {
return Ok(Started::Running);
};
let tracker = std::sync::Arc::new(email::PgSendTracker::new(db_pool.clone()));
let correlator =
std::sync::Arc::clone(&tracker) as std::sync::Arc<dyn email::SendCorrelator>;
let address_hash_key = server.build_address_hash_key();
let sink = server.storage_state.as_ref().map(|state| {
std::sync::Arc::new(email::StorageAttachmentSink::new(state.backend.clone()))
as std::sync::Arc<dyn fraiseql_functions::host::live::storage::StorageBackend>
});
if server.config.send.verp_probe_on_start {
email::run_startup_probes(&server.config.mailbox, |name| std::env::var(name).ok())
.await;
}
let hooks = app_state.before_mutation_hooks.clone();
let query_executor_factory = hooks.as_ref().map(|_| {
crate::routes::after_mutation::make_query_executor_factory(
crate::query_bridge::ExecutorSource::of(app_state),
)
});
let pollers = email::build_pollers(
&server.config.mailbox,
db_pool,
hooks.as_ref(),
query_executor_factory.as_ref(),
sink.as_ref(),
Some(&correlator),
address_hash_key.as_ref(),
server.config.send.challenge_suppress_after,
server.config.inbound_dispatch.settings().map_or(
crate::inbound::dispatch_ledger::LedgerSettings::default().lease,
|settings| settings.lease,
),
|name| std::env::var(name).ok(),
);
let started = pollers.len();
for (poller, interval) in pollers {
server.tasks.spawn(async move { poller.poll_forever(interval).await });
}
info!(mailboxes = started, "poll-IMAP email sources started");
Ok(Started::Running)
}
#[cfg(feature = "inbound-email")]
fn routes(&self, cx: &ServeCx<'_>) -> Option<axum::Router> {
let server = cx.server;
let (Some(pool), Some(token), Some(key)) = (
server.db_pool.as_ref(),
server.config.admin_token.as_ref(),
server.build_address_hash_key(),
) else {
return None;
};
let tracker = std::sync::Arc::new(crate::inbound::email::PgSendTracker::new(pool.clone()));
let suppression_state =
std::sync::Arc::new(crate::inbound::email::SuppressionAdminState::new(tracker, key));
let auth_state = crate::server::BearerAuthState::with_max_failures(
token.clone(),
server.config.admin_auth_max_failures,
);
info!(
"Suppression admin API enabled (POST /api/email/suppress, POST \
/api/email/suppression; admin bearer token required)"
);
Some(crate::inbound::email::suppression_admin_router(suppression_state).route_layer(
axum::middleware::from_fn_with_state(auth_state, crate::server::bearer_auth_middleware),
))
}
}