Using the library
The mercurio crate is the library behind the CLI: same URLs, same
configuration files, same services. This page is the crate’s own
documentation; every type and method is in the
API reference
(cargo doc -p mercurio --open).
Apprise-compatible Rust library, CLI and HTTP server to send notifications to 150+ services via a single URL.
Services are addressed by URL scheme and query parameters. They can be grouped in configuration files (TEXT and YAML) with tags and ${NAME} templates. The default API is async on tokio (AsyncMercurio); Mercurio is the blocking one. The core is sans-IO, so any AsyncTransport or Transport (another runtime, wasm, a proxy, a test double) can drive it.
Quick start
#![allow(unused)]
fn main() {
async fn run() -> Result<(), mercurio::Error> {
use mercurio::{AsyncMercurio, Message, NotifyType};
let mut m = AsyncMercurio::new();
m.add("ntfy://my-topic")?;
m.add("discord://webhook_id/webhook_token")?;
let msg = Message::new("Disk almost full").title("server-1").kind(NotifyType::Warning);
let report = m.notify(&msg).await;
for (entry, outcome) in report.failures() {
eprintln!("{}: {:?}", entry.label, outcome.result);
}
m.close().await;
Ok(()) }
}
AsyncMercurio works on any runtime and spawns nothing. One notify call sends to its targets concurrently inside the calling task (at most 128 sends in flight across the process), so it uses one thread; spawn several calls to use more cores. AsyncMercurio::new does its I/O on tokio and rustls with an in-house HTTP/1.1 client (TokioTransport, feature transport-tokio); AsyncMercurio::with_transport takes any AsyncTransport.
notify never panics and never returns early: every URL gets a ReportEntry in the Report, with one Outcome per target and every Attempt made (more than one with ?retry=). Report::status sums it up as a Status.
Architecture
- Parse.
parse()splits a URL into aParsedUrl(lenient: tokens may contain:,/or@), finds theServiceDeffor the scheme (service_for_scheme) and calls its parser. The parser validates the URL and returns a typedNotifyvalue, wrapped in aTarget. - Prepare.
Target::prepareruns the message pipeline: payload cap, escapes, format conversion (NotifyFormat), title merging and overflow handling (OverflowMode:upstream,truncate,split). The result is one or morePreparedpieces. - Plan.
Notify::planturns each piece into aPlan: HTTP requests with continuations that inspect the responses, socket sessions, datagrams, local commands. No I/O happens here (Target::plandoes steps 2 and 3). - Execute.
Target::send_asyncwalks the plan on anAsyncTransport(TokioTransportby default) andTarget::sendon a blockingTransport(BlockingTransport,testing::FakeTransportin tests, or your own), both with?retry=/?wait=handling based onErrorClass.Target::send_attemptsandTarget::send_attempts_asyncreturn every attempt.
Around this core:
- Runtime services: a per-notifier
Registry, withRegistry::register_schemefor a closure andRegistry::registerfor a fullServiceDef. - Configuration:
Configloads TEXT and YAML files from paths,http(s)://or memory, withinclude, tags andTemplateVars. - Tags:
TagandTagFilter(all,always, AND/OR groups). store: the per-targetPersistentStore(tokens, sessions, delivery tracking).- Attachments:
AttachmentSetloads files,http(s)://andmemory://sources intoAttachments. Asset: branding (app_id, colours, images) and pipeline defaults.
Sans-IO: plans without a network
A Target builds its Plan without sending anything. The plan is a plain value: inspect it or hand it to any transport. testing::FakeTransport records every request and answers from a script:
#![allow(unused)]
fn main() {
use mercurio::testing::FakeTransport;
use mercurio::{Asset, Message, service::Plan};
let target = mercurio::parse("json://localhost/hook")?;
let msg = Message::new("Backup done").title("cron");
// Build the plan: no I/O.
match target.plan(&msg, &Asset::default())? {
Plan::Http(req, _) => assert_eq!(req.url, "http://localhost/hook"),
other => panic!("unexpected plan {other:?}"),
}
// Run it on a fake transport.
let fake = FakeTransport::new();
fake.respond(204, "");
assert!(target.send(&msg, &fake, &Asset::default()).is_success());
let req = fake.last_request().unwrap();
assert!(String::from_utf8_lossy(&req.body).contains("Backup done"));
Ok::<(), mercurio::Error>(())
}
Services read the clock and randomness (timestamps, signatures, nonces, multipart boundaries) from the transport’s Env too, through Context::env. testing::FixedEnv freezes both, so two runs build byte-identical requests: FakeTransport::new().with_env(FixedEnv::new(1_700_000_000)).
Custom transport
Implement AsyncTransport (for AsyncMercurio) or Transport (for Mercurio) to add logging, tracing, metrics, a proxy or a test double. Only http, file and sleep are required; connect, datagram and command default to Error::Unsupported and env to SystemEnv, so a wrapper forwards them to keep e-mail, chat protocols, syslog and desktop notifications working:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::time::Duration;
use mercurio::{
transport::AsyncConn, AsyncMercurio, transport::AsyncTransport, transport::BoxFuture, transport::Command, transport::CommandOutput, transport::Datagram, transport::Endpoint, transport::FileOp,
transport::FileOutput, transport::Request, transport::Response, Result, transport::TokioTransport,
};
/// Logs every operation, then lets tokio do it.
struct Logged(TokioTransport);
impl AsyncTransport for Logged {
fn http<'a>(&'a self, req: &'a Request) -> BoxFuture<'a, Result<Response>> {
eprintln!("{} {}", req.method.as_str(), mercurio::redact_url(&req.url));
self.0.http(req)
}
fn connect<'a>(&'a self, ep: &'a Endpoint) -> BoxFuture<'a, Result<Box<dyn AsyncConn>>> {
eprintln!("connect {ep:?}");
self.0.connect(ep)
}
fn datagram<'a>(&'a self, dg: &'a Datagram) -> BoxFuture<'a, Result<()>> {
self.0.datagram(dg)
}
fn command<'a>(&'a self, cmd: &'a Command) -> BoxFuture<'a, Result<CommandOutput>> {
eprintln!("run {}", cmd.program);
self.0.command(cmd)
}
fn file<'a>(&'a self, op: &'a FileOp) -> BoxFuture<'a, Result<FileOutput>> {
self.0.file(op)
}
fn sleep(&self, d: Duration) -> BoxFuture<'_, ()> {
self.0.sleep(d)
}
}
let mut m = AsyncMercurio::with_transport(Arc::new(Logged(TokioTransport::new())));
m.add("mailto://user:pass@example.com")?;
Ok::<(), mercurio::Error>(())
}
A tracing wrapper does the same with each future wrapped in a span (Instrument::instrument).
Blocking API
Mercurio has the same targets, configuration and reports as AsyncMercurio without a runtime: notify blocks until every service has answered, sending on one thread per target. Mercurio::new uses BlockingTransport (ureq + rustls), which needs the transport-blocking feature:
mercurio = { version = "0.1", default-features = false, features = ["all-services", "transport-blocking"] }
#![allow(unused)]
fn main() {
use mercurio::{Mercurio, Message};
let mut m = Mercurio::new();
m.add("ntfy://my-topic")?;
let report = m.notify(&Message::new("Finished in 42s").title("Backup"));
Ok::<(), mercurio::Error>(())
}
Blocking puts a blocking Transport behind AsyncTransport; AsyncMercurio converts from a Mercurio.
Custom services
Registry::register_scheme adds a URL scheme at run time, to one notifier’s Registry (Mercurio::registry_mut). The closure gets each prepared piece of the message and a Hook (parsed URL, asset, store) and returns a Plan: requests for mercurio to send, or Plan::Done when it did the work itself.
#![allow(unused)]
fn main() {
use mercurio::Mercurio;
use mercurio::service::{Plan, Request};
let mut m = Mercurio::new();
m.registry_mut().register_scheme("bookhook", |msg, hook| {
let channel = hook.url.text("channel").unwrap_or_else(|| "general".into());
Plan::send(Request::post(format!("https://{}/notify", hook.url.host())).json(&serde_json::json!({
"channel": channel,
"text": format!("{}: {}", msg.title, msg.body),
})))
})?;
m.add("bookhook://hooks.internal?channel=ops")?;
Ok::<(), mercurio::Error>(())
}
Registry::register adds a full ServiceDef with its own URL parser, limits and Notify type, written like a built-in service. Built-in services always win. Each notifier has its own registry, so two notifiers in one process can map the same scheme to different handlers.
Configuration and tags
Mercurio::add_config loads a file or URL; Mercurio::add_config_text takes the text directly. The format (TEXT or YAML) is detected.
#![allow(unused)]
fn main() {
use std::sync::Arc;
use mercurio::testing::FakeTransport;
use mercurio::{Mercurio, Message, NotifyOptions, TagFilter};
let yaml = "
version: 1
urls:
- json://localhost/devops:
tag: devops, critical
- json://localhost/always:
tag: always
- json://localhost/untagged
";
let fake = Arc::new(FakeTransport::new());
let mut m = Mercurio::with_transport(fake.clone());
assert_eq!(m.add_config_text(yaml, None)?, 3);
// "devops" matches the devops entry; `always` joins every tagged run.
let report = m.notify_with(&Message::new("deploy failed"), NotifyOptions::new().tags(&TagFilter::parse("devops")));
assert_eq!(report.entries.len(), 2);
// Without a tag (the CLI default) only untagged entries are notified.
let report = m.notify_with(&Message::new("fyi"), NotifyOptions::new().tags(&TagFilter::untagged()));
assert_eq!(report.entries.len(), 1);
Ok::<(), mercurio::Error>(())
}
Testing your integration
testing::FakeTransport works with the whole stack, so application code that sends notifications can be tested without accounts or a network:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use mercurio::testing::FakeTransport;
use mercurio::{Mercurio, Message, Status};
let fake = Arc::new(FakeTransport::new());
fake.respond(500, "server error");
let mut m = Mercurio::with_transport(fake.clone());
m.add("json://localhost/hook")?;
let report = m.notify(&Message::new("hi"));
assert_eq!(report.status(), Status::Failure);
assert_eq!(fake.requests().len(), 1);
Ok::<(), mercurio::Error>(())
}
Cargo features
Every service is compiled by default (all-services). Each one also has its own feature, service-<id>, so a build can carry only the services it uses:
| Feature | Description | Turns on |
|---|---|---|
default | Every service, the async API on tokio and the bundled time zones. | all-services, transport-tokio, bundle-timezones |
all-services | Every service-* feature. | 162 service-* features |
bundle-timezones | The IANA time zone database inside the binary (about 256 KiB), so ?tz= zones resolve where the host has no /usr/share/zoneinfo, as in a scratch image. A system copy is still preferred when there is one. | jiff/tzdb-bundle-always |
transport-tokio | Tokio transport: TokioTransport and AsyncMercurio::new (tokio + rustls). | |
transport-blocking | Blocking transport: BlockingTransport and Mercurio::new (ureq). | |
testing | Compile the fixture runner (testing::fixtures); testing::FakeTransport is always available. | |
service-deltachat-pgp | OpenPGP signing and encryption (?pgp=) for Delta Chat. | service-deltachat |
service-email-pgp | OpenPGP signing and encryption (?pgp=) for e-mail. | service-email |
service-matrix-e2ee | Matrix end-to-end encrypted rooms (extra cryptography crates). | service-matrix |
For a slim build, disable default features and pick services: mercurio = { version = "0.1", default-features = false, features = ["service-telegram", "service-ntfy"] }. Add bundle-timezones to keep ?tz= zones working on hosts without a system time zone database.