This document covers deploying the ORLY Nostr-Email bridge (Marmot DM to SMTP).
The bridge converts between Nostr Marmot DMs and standard email:
| Mode | Description | Config |
|---|---|---|
| Monolithic | Bridge runs inside the ORLY relay process | ORLY_BRIDGE_ENABLED=true |
| Standalone | Bridge runs as a separate process | orly bridge subcommand |
| Launcher | Bridge managed by the process supervisor | ORLY_LAUNCHER_BRIDGE_ENABLED=true |
Monolithic is simplest for single-server deployments. Standalone is useful when the relay and bridge run on different hosts.
# 1. Generate DKIM keys
./scripts/generate-dkim.sh yourdomain.com
# 2. Set environment variables
export ORLY_BRIDGE_ENABLED=true
export ORLY_BRIDGE_DOMAIN=yourdomain.com
export ORLY_BRIDGE_SMTP_PORT=2525
export ORLY_BRIDGE_DKIM_KEY=/path/to/dkim-private.pem
export ORLY_BRIDGE_DKIM_SELECTOR=marmot
export ORLY_BRIDGE_COMPOSE_URL=https://yourdomain.com/compose
# 3. Start relay (bridge starts automatically)
./orly
The compose form is served at /compose and the decrypt page at /decrypt.
All DNS records are required for email deliverability. Without them, outbound email will be rejected by most providers.
Points incoming email for your domain to your bridge server.
yourdomain.com. IN MX 10 mail.yourdomain.com.
mail.yourdomain.com. IN A <YOUR_SERVER_IP>
If your relay is the same host:
yourdomain.com. IN MX 10 yourdomain.com.
Authorizes your server to send email for the domain.
yourdomain.com. IN TXT "v=spf1 ip4:<YOUR_SERVER_IP> -all"
For multiple IPs:
yourdomain.com. IN TXT "v=spf1 ip4:1.2.3.4 ip4:5.6.7.8 -all"
The generate-dkim.sh script outputs the exact DNS record. The selector defaults to marmot.
marmot._domainkey.yourdomain.com. IN TXT "v=DKIM1; k=rsa; p=MIIBIjANBg..."
The value is the base64-encoded RSA public key (no line breaks in the DNS TXT record).
Tells receivers how to handle mail that fails SPF/DKIM.
_dmarc.yourdomain.com. IN TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@yourdomain.com"
For testing, start with p=none (report only, don't reject):
_dmarc.yourdomain.com. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@yourdomain.com"
The PTR record maps your server's IP address back to your domain. Mail servers check that the IP sending mail resolves to the domain it claims to be.
<YOUR_IP> IN PTR yourdomain.com.
Important ordering: The forward A record (yourdomain.com → <IP>) must exist and propagate before you set the PTR. Many providers verify the forward record when you create the reverse, and will reject or silently remove the PTR if the A record doesn't match.
How to set it: PTR records are set by the IP owner (your VPS provider), not the domain registrar. Look for "Reverse DNS", "rDNS", or "PTR" in your provider's control panel, usually under Networking or IP settings.
| Provider | Where to find it |
|---|---|
| Hetzner | Cloud Console → Server → Networking → Primary IPs → Edit |
| DigitalOcean | Droplet → Networking → PTR Records |
| Vultr | Server → Settings → IPv4 → Reverse DNS |
| Linode/Akamai | Linode → Network → IP Addresses → Edit rDNS |
| Mynymbox | Management panel → Reverse DNS |
Verify after setting:
dig -x YOUR_SERVER_IP +short
# Expected: yourdomain.com.
If the output still shows the provider's default hostname (e.g., static.50.235.71.80.virtservers.com), the PTR hasn't propagated or wasn't accepted. Check that your A record is correct and retry.
Use the provided script:
./scripts/generate-dkim.sh yourdomain.com [selector]
This generates:
dkim-private.pem — the private key (keep secret, set ORLY_BRIDGE_DKIM_KEY to this path)If you prefer manual generation:
openssl genrsa -out dkim-private.pem 2048
openssl rsa -in dkim-private.pem -pubout -outform DER | openssl base64 -A
# Use the base64 output as the "p=" value in the DNS TXT record
Many cloud providers block port 25 by default. The bridge listens on port 2525 (configurable via ORLY_BRIDGE_SMTP_PORT). You need to get traffic from port 25 to your bridge port.
Redirect port 25 to your bridge port. No binary changes needed.
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525
Make it persistent across reboots:
sudo apt install iptables-persistent
sudo netfilter-persistent save
Grant the binary permission to bind low ports without root:
sudo setcap 'cap_net_bind_service=+ep' ./orly
Then set ORLY_BRIDGE_SMTP_PORT=25.
Note: setcap is cleared if you replace the binary, so re-run after each deploy.
systemd listens on port 25 and passes the file descriptor to the bridge.
# /etc/systemd/system/orly-smtp.socket
[Socket]
ListenStream=25
Accept=no
[Install]
WantedBy=sockets.target
Then configure the bridge to accept the systemd socket. This requires custom code not yet in the bridge — use iptables for now.
Some providers will open port 25 on request:
| Provider | Process |
|---|---|
| DigitalOcean | Support ticket, explain mail server use case |
| Vultr | Support ticket |
| Linode/Akamai | Support ticket (usually approved for new accounts after a period) |
| AWS EC2 | Request via SES, or use an Elastic IP with port 25 unblock form |
These providers do not block port 25 by default:
If you have a separate server with port 25 open:
# On the port-25-capable server:
ssh -R 2525:localhost:2525 user@bridge-host
Or use autossh for persistent tunnels:
autossh -M 0 -N -R 25:localhost:2525 user@bridge-host
Run a VPN between a port-25-capable server and your bridge host:
# On the port-25 server, forward port 25 to the bridge via WireGuard peer IP
iptables -t nat -A PREROUTING -p tcp --dport 25 -j DNAT --to-destination 10.0.0.2:2525
iptables -t nat -A POSTROUTING -j MASQUERADE
docker run --network host -e ORLY_BRIDGE_SMTP_PORT=2525 ...
# Then use iptables method above on the host
Run Postfix on a port-25-capable host and pipe to the bridge via LMTP or HTTP webhook:
# /etc/postfix/main.cf
transport_maps = hash:/etc/postfix/transport
# /etc/postfix/transport
yourdomain.com lmtp:bridge-host:2525
This is the most complex option but works well if you already run a mail server.
All bridge configuration is via environment variables with the ORLY_BRIDGE_ prefix.
| Variable | Default | Description |
|---|---|---|
ORLY_BRIDGE_ENABLED | false | Enable the email bridge |
ORLY_BRIDGE_DOMAIN | Email domain (e.g., relay.example.com) | |
ORLY_BRIDGE_NSEC | Bridge identity nsec (default: relay identity from database) | |
ORLY_BRIDGE_RELAY_URL | The relay that the bridge will connect to - use the public URL even if running in monolithic (single binary, no gRPC bridging) mode. Also allows remote relays to be used in the same way as the bridge is a standalone unit. | |
ORLY_BRIDGE_SMTP_PORT | 2525 | SMTP server listen port |
ORLY_BRIDGE_SMTP_HOST | 0.0.0.0 | SMTP server listen address |
ORLY_BRIDGE_DATA_DIR | $ORLY_DATA_DIR/bridge | Bridge data directory |
ORLY_BRIDGE_DKIM_KEY | Path to DKIM private key PEM file | |
ORLY_BRIDGE_DKIM_SELECTOR | marmot | DKIM selector for DNS TXT record |
ORLY_BRIDGE_NWC_URI | NWC connection string for subscription payments (falls back to ORLY_NWC_URI) | |
ORLY_BRIDGE_MONTHLY_PRICE_SATS | 2100 | Monthly subscription price in sats |
ORLY_BRIDGE_ALIAS_PRICE_SATS | 4200 | Monthly alias email price in sats |
ORLY_BRIDGE_COMPOSE_URL | Public URL of the compose form | |
ORLY_BRIDGE_SMTP_RELAY_HOST | SMTP smarthost for outbound delivery (e.g., smtp.migadu.com) | |
ORLY_BRIDGE_SMTP_RELAY_PORT | 587 | SMTP smarthost port (587 for STARTTLS) |
ORLY_BRIDGE_SMTP_RELAY_USERNAME | SMTP smarthost AUTH username | |
ORLY_BRIDGE_SMTP_RELAY_PASSWORD | SMTP smarthost AUTH password | |
ORLY_BRIDGE_ACL_GRPC_SERVER | gRPC address of ACL server (split IPC mode with paid ACL) | |
ORLY_BRIDGE_PROFILE | $DATA_DIR/profile.txt | Path to profile template file (kind 0 metadata) |
The bridge uses Nostr Wallet Connect (NWC) to create Lightning invoices for subscription payments. You need a Lightning wallet that supports NWC and can generate a connection string.
Option A: Alby (hosted, simplest)
getalby.com/apps)make_invoice and lookup_invoicepay_invoice or any spending permissionsnostr+walletconnect://pubkey?relay=wss://...&secret=...Option B: Self-hosted (Alby Hub, LNbits, NWC-enabled node)
Most self-hosted Lightning wallets with NWC support have an "Apps" or "Connections" section where you can create restricted connection strings. The process is similar — create a new app connection with only make_invoice and lookup_invoice permissions.
export ORLY_BRIDGE_NWC_URI="nostr+walletconnect://pubkey?relay=wss://relay.getalby.com/v1&secret=hexsecret"
Or use ORLY_NWC_URI if the bridge shares the wallet with the relay.
The bridge only needs to create invoices and check payment status. It never needs pay_invoice or any spending capability. If your wallet forces you to grant all permissions, create a separate wallet for the bridge.
Users send the word subscribe as a DM to the bridge identity. The bridge:
Subscription state is stored in $ORLY_BRIDGE_DATA_DIR/subscriptions.json.
Rate limits for subscribed users:
The bridge can publish a kind 0 (profile metadata) event on startup so Nostr clients can discover it and display a name, avatar, and description. Without a profile, clients show the bridge as an unknown pubkey.
Create a profile.txt in the bridge data directory using email-header format:
name: Marmot Bridge
about: Nostr-Email bridge at yourdomain.com. DM 'subscribe' to get started.
picture: https://yourdomain.com/avatar.png
nip05: bridge@yourdomain.com
lud16: tips@yourdomain.com
website: https://yourdomain.com
banner: https://yourdomain.com/banner.png
The bridge reads this file on every startup and publishes a kind 0 event signed with its identity. Lines with empty values are omitted. Comments (lines starting with #) are ignored.
To use a custom path instead of $ORLY_BRIDGE_DATA_DIR/profile.txt:
export ORLY_BRIDGE_PROFILE=/etc/orly/bridge-profile.txt
A template is provided at profile.example.txt in the repository root.
The bridge uses Blossom for attachment storage. Ensure Blossom is enabled:
ORLY_BLOSSOM_ENABLED=true
When an inbound email has non-plaintext attachments, the bridge:
#key fragment in the DMThe encryption key is in the URL fragment, which per RFC 3986 is never sent to the server. Only the DM recipient (who has the full URL) can decrypt.
The compose form at /compose is a static HTML page with no server-side logic. It:
#to=alice@example.com&subject=Re: HelloThe decrypt page at /decrypt allows email recipients to decrypt attachment URLs:
#key fragmentRun this before requesting port 25 from your provider, or before going live. Every check must pass.
# Replace with your actual domain and IP
DOMAIN=yourdomain.com
IP=1.2.3.4
echo "=== MX ==="
dig MX $DOMAIN +short
# Expected: 10 yourdomain.com. (or your MX host)
echo "=== SPF ==="
dig TXT $DOMAIN +short | grep spf
# Expected: "v=spf1 ip4:$IP -all"
echo "=== DKIM ==="
dig TXT marmot._domainkey.$DOMAIN +short
# Expected: "v=DKIM1; k=rsa; p=MIIBIj..."
echo "=== DMARC ==="
dig TXT _dmarc.$DOMAIN +short
# Expected: "v=DMARC1; p=none; ..." or "p=quarantine"
echo "=== PTR ==="
dig -x $IP +short
# Expected: yourdomain.com. (MUST match your domain, not the provider default)
echo "=== A record (forward) ==="
dig A $DOMAIN +short
# Expected: $IP
If PTR still shows the provider's default hostname, see the Reverse DNS section above. Many providers require the forward A record to exist before they accept the PTR.
Also check MX Toolbox email health for your domain — it flags common issues that will cause mail rejection.
# Check MX record
dig MX yourdomain.com +short
# Check SPF
dig TXT yourdomain.com +short
# Check DKIM
dig TXT marmot._domainkey.yourdomain.com +short
# Check DMARC
dig TXT _dmarc.yourdomain.com +short
# Check PTR (reverse DNS)
dig -x YOUR_SERVER_IP +short
Outbound email rejected as spam:
SMTP connection refused on port 25:
telnet yourdomain.com 25Bridge identity mismatch:
ORLY_BRIDGE_NSEC explicitly to overrideSubscription payments not working:
make_invoice and lookup_invoicesubscriptions.jsonAttachments not working:
ORLY_BLOSSOM_ENABLED=true/blossom/ or root# DNS records (set via your registrar):
# yourdomain.com MX 10 yourdomain.com
# yourdomain.com TXT "v=spf1 ip4:1.2.3.4 -all"
# marmot._domainkey.yourdomain.com TXT "v=DKIM1; k=rsa; p=..."
# _dmarc.yourdomain.com TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@yourdomain.com"
# Generate DKIM key
./scripts/generate-dkim.sh yourdomain.com marmot
# Environment
export ORLY_PORT=3334
export ORLY_BRIDGE_ENABLED=true
export ORLY_BRIDGE_DOMAIN=yourdomain.com
export ORLY_BRIDGE_SMTP_PORT=2525
export ORLY_BRIDGE_DKIM_KEY=/etc/orly/dkim-private.pem
export ORLY_BRIDGE_DKIM_SELECTOR=marmot
export ORLY_BRIDGE_COMPOSE_URL=https://yourdomain.com/compose
export ORLY_BRIDGE_NWC_URI="nostr+walletconnect://..."
export ORLY_BRIDGE_MONTHLY_PRICE_SATS=2100
export ORLY_BLOSSOM_ENABLED=true
# Redirect port 25 to bridge SMTP port
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525
sudo netfilter-persistent save
# Start
./orly
Instead of direct MX delivery with self-managed DKIM/SPF, you can relay outbound email through a hosted email provider. This simplifies DNS setup and improves deliverability since the provider handles IP reputation.
When ORLY_BRIDGE_SMTP_RELAY_HOST is set, the bridge sends all outbound mail through the smarthost using STARTTLS + PLAIN authentication instead of direct MX delivery.
export ORLY_BRIDGE_SMTP_RELAY_HOST=smtp.migadu.com
export ORLY_BRIDGE_SMTP_RELAY_PORT=587
export ORLY_BRIDGE_SMTP_RELAY_USERNAME=bridge@yourdomain.com
export ORLY_BRIDGE_SMTP_RELAY_PASSWORD=your-app-password
The bridge's own DKIM signing (ORLY_BRIDGE_DKIM_KEY) is bypassed when using a smarthost since the provider signs outbound mail with their own DKIM keys.
This is a worked example of deploying the bridge on a Linode VPS using Migadu as the hosted email provider for the subdomain relay.orly.dev. The domain registrar is Namecheap.
Log into admin.migadu.com, go to Domains, add relay.orly.dev.
Migadu will show a list of required DNS records and a verification TXT value unique to your account.
In Namecheap's Advanced DNS panel for orly.dev, add the following records. Since relay.orly.dev is a subdomain, the Host field is relay (not @).
MX Records
| Type | Host | Value | Priority | TTL |
|---|---|---|---|---|
| MX | relay | aspmx1.migadu.com | 10 | 1 min |
| MX | relay | aspmx2.migadu.com | 20 | 1 min |
TXT Records
| Type | Host | Value | TTL |
|---|---|---|---|
| TXT | relay | v=spf1 include:spf.migadu.com -all | 1 min |
| TXT | relay | hosted-email-verify=XXXXXXXX | 1 min |
| TXT | _dmarc.relay | v=DMARC1; p=quarantine; | 1 min |
Replace XXXXXXXX with the verification value from Migadu's admin panel.
CNAME Records (DKIM)
| Type | Host | Value | TTL |
|---|---|---|---|
| CNAME | key1._domainkey.relay | key1.relay.orly.dev._domainkey.migadu.com | 1 min |
| CNAME | key2._domainkey.relay | key2.relay.orly.dev._domainkey.migadu.com | 1 min |
| CNAME | key3._domainkey.relay | key3.relay.orly.dev._domainkey.migadu.com | 1 min |
The A record for relay.orly.dev pointing to the VPS (e.g., 69.164.249.71) remains unchanged. MX and A records coexist without conflict.
After adding DNS records (propagation is typically under 5 minutes with 1-minute TTL), go back to Migadu's domain page and run the DNS check. All checks should pass.
Verify locally:
dig relay.orly.dev MX +short
# Expected: 10 aspmx1.migadu.com. 20 aspmx2.migadu.com.
dig relay.orly.dev TXT +short
# Expected: "v=spf1 include:spf.migadu.com -all" "hosted-email-verify=..."
dig _dmarc.relay.orly.dev TXT +short
# Expected: "v=DMARC1; p=quarantine;"
dig key1._domainkey.relay.orly.dev CNAME +short
# Expected: key1.relay.orly.dev._domainkey.migadu.com.
In Migadu admin, create a mailbox for the bridge (e.g., bridge@relay.orly.dev). Note the password — this is used for SMTP authentication.
Migadu receives inbound email for relay.orly.dev via its MX servers. To deliver it to the bridge's SMTP server on the VPS, set up forwarding:
Option A: Migadu forwards to a subdomain with direct MX
Create a subdomain bridge.relay.orly.dev with MX pointing directly at the VPS:
| Type | Host | Value | Priority |
|---|---|---|---|
| MX | bridge.relay | relay.orly.dev | 10 |
In Migadu, set up a catch-all forwarding rule to forward *@relay.orly.dev to incoming@bridge.relay.orly.dev.
On the VPS, open port 25 and redirect to the bridge's SMTP port:
sudo iptables -t nat -A PREROUTING -p tcp --dport 25 -j REDIRECT --to-port 2525
sudo netfilter-persistent save
Option B: Migadu sieve forwarding to VPS
In Migadu's mailbox settings, add a sieve filter or forwarding rule that re-sends incoming mail to an address handled by the bridge's SMTP server.
Add to the ORLY service environment (.env file or systemd unit):
ORLY_BRIDGE_ENABLED=true
ORLY_BRIDGE_DOMAIN=relay.orly.dev
ORLY_BRIDGE_SMTP_PORT=2525
ORLY_BRIDGE_SMTP_HOST=0.0.0.0
ORLY_BRIDGE_SMTP_RELAY_HOST=smtp.migadu.com
ORLY_BRIDGE_SMTP_RELAY_PORT=587
ORLY_BRIDGE_SMTP_RELAY_USERNAME=bridge@relay.orly.dev
ORLY_BRIDGE_SMTP_RELAY_PASSWORD=<migadu-password>
ORLY_BRIDGE_NWC_URI=nostr+walletconnect://...
ORLY_BRIDGE_MONTHLY_PRICE_SATS=2100
ORLY_BRIDGE_COMPOSE_URL=https://relay.orly.dev/compose
# Build unified binary with web UI
./scripts/update-embedded-web.sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o orly ./cmd/orly
# Deploy
ssh root@relay.orly.dev 'systemctl stop orly'
rsync -avz --compress -e "ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes" \
orly root@relay.orly.dev:/home/mleku/.local/bin/
ssh root@relay.orly.dev 'chown mleku:mleku /home/mleku/.local/bin/orly && systemctl start orly'
# Check bridge started
ssh root@relay.orly.dev 'journalctl -u orly --since "1 min ago" | grep bridge'
# Expected: "bridge identity: npub1..." and "bridge started"
# Test outbound: DM the bridge npub with "subscribe"
# Test inbound: send email to npub1...@relay.orly.dev
White Noise is a mobile/desktop Nostr messaging client that uses NIP-17 gift-wrapped DMs. When searching for the bridge by its npub, White Noise may show the profile but say the bridge "isn't on White Noise yet." This means White Noise cannot find the bridge's relay list and doesn't know where to send messages.
White Noise (and other NIP-17 clients) discover where to send DMs by looking up the recipient's kind 10002 event (NIP-65 relay list metadata). This event tells clients which relays a user reads from and writes to. Without it, the client has no delivery target for gift-wrapped DMs.
The bridge currently publishes a kind 0 (profile) on startup but does not publish a kind 10002 relay list. Until the bridge publishes one automatically, you need to publish it manually.
Use any Nostr tool that can publish events with an arbitrary key. The event must be signed by the bridge's identity key (the nsec used for ORLY_BRIDGE_NSEC, or the relay's auto-generated identity if no explicit nsec is set).
The kind 10002 event looks like:
{
"kind": 10002,
"content": "",
"tags": [
["r", "wss://relay.orly.dev/"],
["r", "wss://relay.damus.io/", "read"],
["r", "wss://nos.lol/", "read"]
]
}
Each r tag is a relay URL. Tags without a marker (no third element) mean both read and write. "read" means the bridge reads from that relay but doesn't write there. "write" means write-only.
At minimum, include the bridge's own relay as a read+write relay:
["r", "wss://your-relay-domain/"]
Adding popular public relays as read entries helps clients that don't connect to your relay directly find the bridge.
nak is a command-line Nostr tool. Install it, then publish the relay list:
# Set the bridge's secret key
export NOSTR_SECRET_KEY=nsec1... # The bridge identity nsec
# Publish kind 10002 with your relay as read+write, plus popular relays as read-only
nak event \
--kind 10002 \
--tag r='wss://relay.orly.dev/' \
--tag r='wss://relay.damus.io/;read' \
--tag r='wss://nos.lol/;read' \
wss://relay.orly.dev wss://relay.damus.io wss://nos.lol
Publish to multiple relays so clients can discover it regardless of which relays they check.
nostril is another CLI option:
nostril --kind 10002 \
--sec <bridge-hex-secret> \
--tag r 'wss://relay.orly.dev/' \
--tag r 'wss://relay.damus.io/' read \
--tag r 'wss://nos.lol/' read \
| websocat wss://relay.orly.dev
After publishing, verify that clients can find it:
# With nak
nak req -k 10002 -a <bridge-pubkey-hex> wss://relay.orly.dev
# Or check on a web viewer
# https://njump.me/<bridge-npub>
White Noise should now show the bridge as reachable. Search for the bridge npub again and the "isn't on White Noise yet" message should be replaced with the ability to start a conversation.
For maximum discoverability, include the bridge's own relay plus 2-3 popular relays:
| Relay | Marker | Purpose |
|---|---|---|
wss://your-relay.com/ | (none) | Primary — bridge reads and writes here |
wss://relay.damus.io/ | read | Popular relay, helps discovery |
wss://nos.lol/ | read | Popular relay, helps discovery |
wss://relay.nostr.band/ | read | Indexer relay, helps discovery |
A future ORLY release will have the bridge publish kind 10002 automatically on startup alongside the kind 0 profile, eliminating this manual step.