Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Parse. parse() splits a URL into a ParsedUrl (lenient: tokens may contain :, / or @), finds the ServiceDef for the scheme (service_for_scheme) and calls its parser. The parser validates the URL and returns a typed Notify value, wrapped in a Target.
  2. Prepare. Target::prepare runs the message pipeline: payload cap, escapes, format conversion (NotifyFormat), title merging and overflow handling (OverflowMode: upstream, truncate, split). The result is one or more Prepared pieces.
  3. Plan. Notify::plan turns each piece into a Plan: HTTP requests with continuations that inspect the responses, socket sessions, datagrams, local commands. No I/O happens here (Target::plan does steps 2 and 3).
  4. Execute. Target::send_async walks the plan on an AsyncTransport (TokioTransport by default) and Target::send on a blocking Transport (BlockingTransport, testing::FakeTransport in tests, or your own), both with ?retry= / ?wait= handling based on ErrorClass. Target::send_attempts and Target::send_attempts_async return every attempt.

Around this core:

  • Runtime services: a per-notifier Registry, with Registry::register_scheme for a closure and Registry::register for a full ServiceDef.
  • Configuration: Config loads TEXT and YAML files from paths, http(s):// or memory, with include, tags and TemplateVars.
  • Tags: Tag and TagFilter (all, always, AND/OR groups).
  • store: the per-target PersistentStore (tokens, sessions, delivery tracking).
  • Attachments: AttachmentSet loads files, http(s):// and memory:// sources into Attachments.
  • 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:

FeatureDescriptionTurns on
defaultEvery service, the async API on tokio and the bundled time zones.all-services, transport-tokio, bundle-timezones
all-servicesEvery service-* feature.162 service-* features
bundle-timezonesThe 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-tokioTokio transport: TokioTransport and AsyncMercurio::new (tokio + rustls).
transport-blockingBlocking transport: BlockingTransport and Mercurio::new (ureq).
testingCompile the fixture runner (testing::fixtures); testing::FakeTransport is always available.
service-deltachat-pgpOpenPGP signing and encryption (?pgp=) for Delta Chat.service-deltachat
service-email-pgpOpenPGP signing and encryption (?pgp=) for e-mail.service-email
service-matrix-e2eeMatrix 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.