A complete Raspberry Pi, mailcow, No-IP, WireGuard, and RabbitMQ build. Mail content is data and is never executed.
THE COW, THE RABBIT & THE DEAD LETTER OFFICE
A complete Raspberry Pi, mailcow, No-IP, WireGuard, and RabbitMQ build
INIT.WTF FIELD MANUAL 02
By Michael A. Cohee // INIT.WTF
Lab-ready draft 0.1 — 2026-08-11
We already had email. We already had queues. So naturally, we built another postal system between them.
What are we making—and why?
We are building a small, self-hosted email domain on a Raspberry Pi. Human beings send ordinary email into it. A bridge reads a dedicated mailbox, converts each message into a signed JSON envelope, and publishes that envelope into RabbitMQ. Each experimental node gets a durable queue: a packet mailbox.
RabbitMQ is not replacing SMTP, IMAP, or the internet. It is the programmable sorting room behind the public post office.
That boundary gives the project a reason to exist:
SMTP accepts mail from the outside world.
Mailcow authenticates, filters, signs, stores, and presents it normally.
IMAP remains the source of truth for the original message.
RabbitMQ turns accepted mail into observable internal events.
Topic routing sends envelopes to named nodes.
Durable queues hold mail while nodes are offline.
Manual acknowledgements expose successful processing.
Retries and dead-lettering make failure visible instead of mysterious.
The experiment teaches two different forms of store-and-forward messaging by forcing them to cooperate. It is not the cheapest way to get email. It is an excellent way to understand routing, identity, delivery guarantees, backpressure, protocol translation, and trust boundaries.
The finished ritual
Send email to red@packet.init.wtf.
Mailcow receives and stores the original message.
The bridge publishes a compact, HMAC-signed envelope.
RabbitMQ routes it to node.red.inbox.
Disconnect the red node. Its queue waits.
Reconnect it. The node consumes and acknowledges the envelope.
Send malformed or oversized mail. It lands in packetmail.dead.
That last queue is our Dead Letter Office.
What No-IP does—and does not do
Most residential public IP addresses can change. No-IP’s Dynamic Update Client watches the public IP and updates a hostname such as initmail.ddns.net. Instead of remembering today’s number, a client follows the stable name.
No-IP solves:
“What public address does my home connection have right now?”
It does not automatically solve:
carrier-grade NAT
a blocked TCP port 25
reverse DNS/PTR controlled by the IP owner
poor reputation of residential address space
outbound-mail filtering
power failures
an ISP prohibition on servers
Dynamic DNS is naming, not mail reputation. A DDNS hostname can reach a home service; it does not transform a residential circuit into a trustworthy internet mail exchange.
No-IP’s free hostnames currently require confirmation every 30 days. That is acceptable for a supervised lab and a bad unattended production dependency. Use a paid hostname, a custom-domain dynamic record, or a calendar-backed operational routine before relying on it.
Choose the network mode before buying anything
Mode A — Direct residential mail
Use direct mode only when all of these are true:
the router has a real public IPv4 or usable global IPv6 address
the connection is not behind CGNAT
the ISP permits inbound and outbound TCP 25
the ISP permits servers under the service agreement
the ISP will set a matching PTR record
the address is stable enough for mail reputation
you accept that residential deliverability may still be poor
No-IP updates the public hostname when the address changes. DNS for the experimental mail domain points directly to that hostname or to a custom A/AAAA record kept current by No-IP.
This is the fewest boxes and the most fragile public-email design.
Mode B — Static edge, home mailbox (recommended)
Use a small VPS with a static public IP and configurable PTR as the internet-facing SMTP edge. A WireGuard tunnel connects that VPS to Mailcow on the Pi. The VPS accepts only known recipients for the lab domain, forwards inbound mail through WireGuard, and accepts outbound mail only from the authenticated tunnel peer.
No-IP then serves a narrower, honest job: a stable name for optional direct home access, status checks, or the Mailcow web/IMAP endpoint. The mail domain’s MX remains on the static VPS.
flowchart TD
I["Internet SMTP"] --> V["Static VPS: MX + PTR"]
V -->|"WireGuard 10.77.0.0/24"| P["Home Pi: mailcow"]
P --> B["Mail ↔ AMQP bridge"]
B --> R["RabbitMQ packet mailboxes"]
The direct mode is documented because it is educational. The hybrid mode is recommended because it separates the public mail identity from a changing home circuit.
Bill of materials
Home node
Raspberry Pi 5 with 16 GB RAM recommended for the combined stack
Raspberry Pi 5 with 8 GB RAM acceptable if RabbitMQ moves to another node or Mailcow’s ClamAV and full-text search are disabled
official-quality power supply
active cooling
256 GB or larger USB 3 SSD; do not make a microSD card the primary mail store
wired Ethernet
small UPS strongly recommended
router access for DHCP reservation and any deliberate port forwarding
Mailcow’s current documented floor is 6 GiB RAM plus 1 GiB swap and 20 GiB disk before mail storage. ARM64 is supported. The project is a full groupware stack—not merely Postfix—and memory use reflects that.
Internet identity
a domain you control; this manual isolates the experiment under packet.init.wtf
a No-IP hostname such as initmail.ddns.net
access to the authoritative DNS zone for init.wtf
for recommended Mode B: a small VPS with static IPv4, PTR control, and TCP 25 permitted
Workstation
SSH client
text editor
password manager
a second email account outside the lab domain for testing
Address and name plan
All addresses below are documentation examples. Replace them before deployment.
| Object | Example |
|---|---|
| Mail domain | packet.init.wtf |
| Public SMTP edge | mx1.packet.init.wtf |
| Mailcow client name | home.packet.init.wtf |
| No-IP hostname | initmail.ddns.net |
| Pi LAN address | 192.168.50.20 |
| VPS public address | 203.0.113.25 |
| VPS WireGuard address | 10.77.0.1 |
| Pi WireGuard address | 10.77.0.2 |
| RabbitMQ vhost | /packetmail |
| RabbitMQ management | 127.0.0.1:15672 |
| Bridge mailbox | packetbridge@packet.init.wtf |
| Node mailboxes | red@packet.init.wtf,green@packet.init.wtf |
203.0.113.25 is reserved example space. It will never be your VPS address.
Phase 0 — The go/no-go survey
Detect CGNAT
Look at the router’s WAN address and compare it with the public IPv4 shown by a reputable IP-check service or the No-IP dashboard.
If the router’s WAN address is in any of these ranges, it is not a directly routable public IPv4 address:
10.0.0.0/8
172.16.0.0/12
192.168.0.0/16
100.64.0.0/10
If the router and internet disagree, assume CGNAT until the ISP confirms otherwise. Port forwarding will not fix upstream NAT.
Test outbound SMTP
From an existing Linux machine on the home connection:
nc -vz gmail-smtp-in.l.google.com 25
A timeout or policy block suggests outbound TCP 25 is filtered. A connection proves only outbound reachability; inbound port 25 still requires a separate test from outside the home network after a deliberate forward exists.
Ask the PTR question
The owner of the public IP range controls reverse DNS. Ask the ISP whether it will set PTR for the address. Check an existing PTR with:
dig -x YOUR_PUBLIC_IP +short
If the ISP cannot set PTR to the SMTP hostname, choose Mode B. Do not try to conceal the mismatch with another CNAME.
Protect the real domain
Do not move existing personal or business mail to the experiment. A subdomain has its own MX records, SPF, DKIM, DMARC, and blast radius. Breaking packet.init.wtf should not break ordinary init.wtf mail.
Phase 1 — Build the Pi correctly
Use Raspberry Pi Imager to write Ubuntu Server 24.04 LTS ARM64 to the SSD. In the customization screen:
hostname: packet-pi
create a non-default administrator account
install an SSH public key
disable password SSH if you can reliably use the key
set America/New_York
do not configure Wi-Fi when Ethernet is available
Boot from the SSD, reserve 192.168.50.20 for the Pi in the router, and connect:
ssh youradmin@192.168.50.20
Update and install the operating-system tools:
sudo apt update
sudo apt full-upgrade
sudo apt install -y git openssl curl gawk coreutils grep jq dnsutils netcat-openbsd wireguard
sudo reboot
Verify the machine before adding state:
hostnamectl
timedatectl
hostname -I
ip route
lsblk
df -h
free -h
Mailcow requires swap even on a reasonably sized private installation. If the image has none, create 2 GiB:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h
Never publish output containing real public addresses, interface identifiers, or SSH material without redaction.
Phase 2 — Install Docker from its official repository
Remove conflicting distribution packages if present:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc
Add Docker’s Ubuntu repository:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Create /etc/apt/sources.list.d/docker.sources:
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: noble
Components: stable
Architectures: arm64
Signed-By: /etc/apt/keyrings/docker.asc
Install and verify:
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world
sudo docker version
sudo docker compose version
Docker-published ports can bypass ordinary UFW expectations. Mailcow specifically warns administrators to place container restrictions in the DOCKER-USER chain or an upstream firewall. For the home node, our first boundary is simple: do not create router port forwards until the corresponding phase explicitly requires one.
Phase 3 — Give the house a stable No-IP name
Create a No-IP hostname such as initmail.ddns.net. Generate a DDNS Key rather than reusing the main account password.
Install No-IP’s official DUC container:
sudo install -d -m 0700 /opt/noip-duc
sudo nano /opt/noip-duc/noip-duc.env
Enter:
NOIP_USERNAME=YOUR_DDNS_KEY_USERNAME
NOIP_PASSWORD=YOUR_DDNS_KEY_PASSWORD
NOIP_HOSTNAMES=initmail.ddns.net
Protect it and start the updater:
sudo chmod 600 /opt/noip-duc/noip-duc.env
sudo docker pull ghcr.io/noipcom/noip-duc:latest
sudo docker run -d \
--name noip-duc \
--restart unless-stopped \
--env-file /opt/noip-duc/noip-duc.env \
ghcr.io/noipcom/noip-duc:latest
Verify without displaying credentials:
sudo docker logs --tail 30 noip-duc
getent ahostsv4 initmail.ddns.net
If the hostname uses No-IP’s free tier, confirm it on the required 30-day cycle. IP updates by the DUC do not count as manual hostname confirmation.
Connect the custom name
For optional direct web/IMAP access, create this record in the init.wtf DNS zone:
home.packet.init.wtf. CNAME initmail.ddns.net.
That CNAME is for clients and certificate validation. It is not the recommended MX target in Mode B.
Phase 4 — Install Mailcow
Before Mailcow binds its ports, confirm nothing else is using them:
sudo ss -tlpn | grep -E -w '25|80|110|143|443|465|587|993|995|4190' || true
Clone the stable project:
cd /opt
sudo git clone https://github.com/mailcow/mailcow-dockerized
cd /opt/mailcow-dockerized
sudo ./generate_config.sh
Enter home.packet.init.wtf as MAILCOW_HOSTNAME. Review /opt/mailcow-dockerized/mailcow.conf and set:
MAILCOW_HOSTNAME=home.packet.init.wtf
TZ=America/New_York
HTTP_REDIRECT=y
On a 16 GB Pi, keep ClamAV and full-text search enabled initially. On an 8 GB Pi sharing resources with RabbitMQ, use:
SKIP_CLAMD=y
SKIP_FTS=y
Those switches reduce protection and search capability in exchange for memory. Record the decision in the build log.
Certificate choice
Mailcow’s normal ACME mode uses HTTP-01 and needs public TCP 80 to reach the Pi. If you deliberately expose home.packet.init.wtf, forward TCP 80 and 443 to the Pi and let Mailcow validate the name.
For the recommended VPN-oriented build, use Mailcow’s DNS-01 integration instead. It can issue a certificate without exposing port 80, but the exact API credentials depend on the authoritative DNS provider. Follow Mailcow’s current DNS-01 page and grant the token only the DNS permissions it needs. Do not paste DNS API credentials into the zine.
Start Mailcow:
cd /opt/mailcow-dockerized
sudo docker compose pull
sudo docker compose up -d
sudo docker compose ps
Open the admin UI only from the trusted LAN or VPN. Change the default administrator password immediately and enable two-factor authentication.
Create:
domain: packet.init.wtf
mailbox: packetbridge@packet.init.wtf
alias: red@packet.init.wtf → packetbridge@packet.init.wtf
alias: green@packet.init.wtf → packetbridge@packet.init.wtf
mailbox: dmarc@packet.init.wtf, kept separate from the bridge
Use unique password-manager-generated credentials. Do not enable a catch-all address.
Create a DKIM key for packet.init.wtf in the Mailcow UI and retain the exact TXT record for the DNS phase.
Phase 5 — Publish the right DNS
Recommended Mode B records
mx1.packet.init.wtf. A YOUR_VPS_STATIC_IP
packet.init.wtf. MX 10 mx1.packet.init.wtf.
home.packet.init.wtf. CNAME initmail.ddns.net.
packet.init.wtf. TXT "v=spf1 a:mx1.packet.init.wtf -all"
Add the DKIM TXT value generated by Mailcow. Begin DMARC in observation mode while testing:
_dmarc.packet.init.wtf. TXT "v=DMARC1; p=none; rua=mailto:dmarc@packet.init.wtf"
After SPF, DKIM, forwarding, and legitimate message flows pass repeatedly, move toward p=quarantine and eventually p=reject. Do not begin at reject while the routing path is still changing.
At the VPS provider, set:
PTR YOUR_VPS_STATIC_IP -> mx1.packet.init.wtf
Forward and reverse names should agree:
dig +short mx1.packet.init.wtf A
dig -x YOUR_VPS_STATIC_IP +short
dig +short packet.init.wtf MX
dig +short packet.init.wtf TXT
Direct Mode A records
If every residential gate passed, the MX may point to a No-IP hostname that resolves directly to the home address:
packet.init.wtf. MX 10 initmail.ddns.net.
The ISP must still set PTR to the SMTP hostname, permit port 25, and tolerate the service. A correct forward record cannot compensate for missing reverse DNS.
Phase 6 — Build the WireGuard mail tunnel
Mode B uses a point-to-point tunnel. The home Pi initiates it, so CGNAT and a changing residential address do not prevent the VPS from replying through the established mapping.
Install WireGuard on the VPS and Pi:
sudo apt update
sudo apt install -y wireguard
Generate keys separately on each host:
sudo -i
umask 077
wg genkey | tee /etc/wireguard/private.key | wg pubkey > /etc/wireguard/public.key
exit
Never copy a private key into chat, a screenshot, the zine, or Git.
VPS /etc/wireguard/wg0.conf
[Interface]
Address = 10.77.0.1/24
ListenPort = 51820
PrivateKey = <VPS_PRIVATE_KEY>
[Peer]
PublicKey = <PI_PUBLIC_KEY>
AllowedIPs = 10.77.0.2/32
Pi /etc/wireguard/wg0.conf
[Interface]
Address = 10.77.0.2/24
PrivateKey = <PI_PRIVATE_KEY>
[Peer]
PublicKey = <VPS_PUBLIC_KEY>
Endpoint = mx1.packet.init.wtf:51820
AllowedIPs = 10.77.0.1/32
PersistentKeepalive = 25
PersistentKeepalive = 25 keeps the home NAT mapping alive. The No-IP hostname is not required for this tunnel because the stable VPS is the endpoint and the Pi initiates the connection.
Protect and start the interface on both hosts:
sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/private.key
sudo systemctl enable --now wg-quick@wg0
sudo wg show
Test from the Pi:
ping -c 3 10.77.0.1
Test from the VPS:
ping -c 3 10.77.0.2
Permit UDP 51820 on the VPS firewall. Do not forward UDP 51820 on the home router for this topology; the Pi is the initiating peer.
Phase 7 — Configure the VPS as a narrow SMTP edge
This Postfix edge accepts mail only for the listed lab recipients and accepts outbound relay only from the Pi’s WireGuard address.
Install:
sudo apt update
sudo apt install -y postfix postfix-pcre certbot swaks
Choose “Internet Site” and enter mx1.packet.init.wtf when the package asks.
Add or reconcile these settings in /etc/postfix/main.cf:
myhostname = mx1.packet.init.wtf
myorigin = $myhostname
inet_interfaces = all
inet_protocols = ipv4
mydestination = localhost
mynetworks = 127.0.0.0/8 10.77.0.2/32
relay_domains = packet.init.wtf
relay_recipient_maps = hash:/etc/postfix/relay_recipients
transport_maps = hash:/etc/postfix/transport
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
smtp_send_xforward_command = yes
smtpd_tls_security_level = may
smtp_tls_security_level = may
List only real recipients in /etc/postfix/relay_recipients:
packetbridge@packet.init.wtf OK
red@packet.init.wtf OK
green@packet.init.wtf OK
dmarc@packet.init.wtf OK
Route the domain through WireGuard in /etc/postfix/transport:
packet.init.wtf smtp:[10.77.0.2]:25
Compile the maps and check syntax:
sudo postmap /etc/postfix/relay_recipients
sudo postmap /etc/postfix/transport
sudo postfix check
sudo systemctl restart postfix
sudo systemctl status postfix --no-pager
Acquire a public certificate for mx1.packet.init.wtf using the appropriate Certbot HTTP or DNS method, then set:
smtpd_tls_cert_file = /etc/letsencrypt/live/mx1.packet.init.wtf/fullchain.pem
smtpd_tls_key_file = /etc/letsencrypt/live/mx1.packet.init.wtf/privkey.pem
Reload Postfix after the certificate exists:
sudo postfix check
sudo systemctl reload postfix
At the VPS firewall allow TCP 25 and UDP 51820. TCP 80 is needed if Certbot uses HTTP-01. Do not open RabbitMQ ports on the VPS.
In Mailcow, add 10.77.0.1/32 under Configuration & Details → Forwarding Hosts so its Postfix instance can accept XFORWARD metadata only from the WireGuard edge. Then inspect received headers and spam-filter behavior. Trusting a forwarding host changes where filtering responsibility sits; do not assume the thin VPS edge provides the same protection as a mature filtering gateway.
Prove it is not an open relay
From a machine outside the VPS and outside the WireGuard tunnel:
swaks --server mx1.packet.init.wtf \
--from outsider@example.net \
--to unrelated-recipient@gmail.com
The VPS must reject relaying. Then test a known lab recipient:
swaks --server mx1.packet.init.wtf \
--from your-external-test-address@example.net \
--to red@packet.init.wtf
The second test should be accepted by the edge and delivered to Mailcow through 10.77.0.2. If the first test succeeds, stop Postfix immediately and fix the relay restrictions.
Send Mailcow outbound through the edge
In Mailcow’s admin UI:
Open Configuration & Details → Routing.
Add [10.77.0.1]:25 as a relayhost/smarthost.
No SMTP password is needed because WireGuard authenticates the peer and Postfix trusts only 10.77.0.2/32.
Assign that relayhost to the packet.init.wtf domain.
Run Mailcow’s relayhost test.
Docker normally masquerades traffic leaving its bridge, so the VPS should see the connection as 10.77.0.2. Confirm in the VPS mail log. If it sees another source address, do not widen mynetworks; correct the Pi’s Docker-to-WireGuard routing instead.
Phase 8 — Install RabbitMQ and the bridge
The accompanying packetmail-bridge code bundle contains:
.env.example — secret-free configuration template
Dockerfile and compose.yaml — isolated runtime
package.json and package-lock.json — pinned Node dependencies
src/bridge.mjs and src/lib.mjs — bridge and validation logic
test/lib.test.mjs — deterministic unit tests
LICENSE — MIT terms for the original bridge code
Copy it to the Pi as /opt/packetmail-bridge. The source is deliberately outside /opt/mailcow-dockerized; Mailcow upgrades own their directory and our bridge owns its own.
Create the runtime environment:
cd /opt/packetmail-bridge
sudo cp .env.example .env
sudo chmod 600 .env
openssl rand -hex 24
openssl rand -hex 32
sudo nano .env
Use the first generated value for both RABBITMQ_PASSWORD and the password inside RABBITMQ_URL. Hex characters are URL-safe. Use the second for PACKET_HMAC_SECRET.
Fill in the Mailcow credentials for packetbridge@packet.init.wtf. The example environment deliberately limits outbound mail to packet.init.wtf:
OUTBOUND_ALLOWED_DOMAINS=packet.init.wtf
Do not widen that list until abuse controls, rate limits, logs, and the public-mail path have been tested.
Build and test the code:
cd /opt/packetmail-bridge
sudo docker build --target test -t packetmail-bridge:test .
Start the services:
sudo docker compose build
sudo docker compose up -d
sudo docker compose ps
sudo docker compose logs --tail 100 bridge
The bundle pins RabbitMQ 4.3.4-management, the current release when this manual was drafted. Before deploying later, compare the pinned version with RabbitMQ’s supported releases and test upgrades deliberately.
What the bridge declares
| Exchange/queue | Type | Purpose |
|---|---|---|
| packetmail | topic exchange | Normal routing |
| packetmail.dlx | topic exchange | Failure routing |
| node.red.inbox | durable queue | Red node mailbox |
| node.green.inbox | durable queue | Green node mailbox |
| packetmail.outbound | durable queue | Requests to send mail |
| packetmail.retry.60s | delayed queue | Finite outbound retries |
| packetmail.dead | durable queue | Final failures and rejected mail |
Inbound messages have a seven-day queue TTL and a 10,000-envelope cap. Raw messages larger than 1 MiB go to the Dead Letter Office. Parsed text is limited to 16 KiB. Attachments remain in Mailcow; RabbitMQ receives only attachment metadata.
Management without public exposure
RabbitMQ management is bound to loopback on the Pi. Reach it through SSH:
ssh -L 15672:127.0.0.1:15672 youradmin@192.168.50.20
Then open http://127.0.0.1:15672. Never forward 15672 or 5672 from the home router.
For a hardened revision, create a separate administrator account and remove administrator tags from the bridge account. The first lab keeps one loopback-only account to reduce bootstrap complexity.
Phase 9 — Understand the envelope
Email to red@packet.init.wtf becomes:
{
"version": 1,
"packet_id": "c4c6f6ecfddbf3a5fd02a863301ff32f",
"source": "sender@example.net",
"destination": "node.red",
"created_at": "2026-08-11T16:00:00.000Z",
"received_at": "2026-08-11T16:00:02.000Z",
"message_id": "<original-message-id@example.net>",
"subject": "TRANSMISSION 0042",
"text": "The compact plain-text body.",
"raw_sha256": "...",
"raw_bytes": 4812,
"imap_uid": 184,
"attachments": [],
"signature": "..."
}
The deterministic packet ID combines the original message identity, raw-message hash, and destination. Consumers can store processed packet IDs and safely ignore redeliveries.
The HMAC detects accidental or unauthorized changes inside the trusted packet network. It does not encrypt the envelope. Keep RabbitMQ on the host network boundary or a VPN and add AMQP TLS before separating nodes across untrusted links.
Nothing in an email body is executed. The bridge emits data, not shell commands.
Phase 10 — Test the whole chain
Test A: infrastructure
On the Pi:
sudo docker ps
sudo docker compose -f /opt/mailcow-dockerized/docker-compose.yml ps
sudo docker compose -f /opt/packetmail-bridge/compose.yaml ps
sudo wg show
getent ahostsv4 initmail.ddns.net
On the VPS:
sudo wg show
sudo postfix check
sudo postqueue -p
sudo journalctl -u postfix -n 100 --no-pager
Test B: public inbound
Send a plain-text email from an unrelated provider to red@packet.init.wtf.
Verify in order:
VPS Postfix accepted the known recipient.
Mailcow shows the original in the red mailbox or the dedicated bridge mailbox chosen by your addressing design.
Bridge logs show inbound_processed.
RabbitMQ shows one ready message in node.red.inbox.
The supplied bridge polls packetbridge@packet.init.wtf. The red and green Mailcow aliases deliver into that one source mailbox while the visible To header retains the node address used for RabbitMQ routing. This first bridge intentionally does not route BCC-only recipients because their destination is absent from the message headers.
Test C: offline delivery
Leave node.red.inbox without a consumer. Send three messages. Confirm the ready count reaches three. Start a consumer and acknowledge them one at a time. This is the project’s central demonstration: the node can be absent without losing its mail.
Test D: outbound
Publish this JSON to exchange packetmail with routing key mail.outbound:
{
"to": "green@packet.init.wtf",
"subject": "RETURN PATH TEST",
"text": "The rabbit has left the sorting office."
}
The bridge submits it to Mailcow over authenticated port 587. Mailcow signs it and sends it through the VPS relayhost. The outbound worker retries twice through the 60-second retry queue; the third failure goes to packetmail.dead.
Test E: the Dead Letter Office
Perform three controlled failures:
Send to unknown@packet.init.wtf after temporarily adding it as a Mailcow alias but not adding it to PACKET_NODES.
Send a message larger than MAX_RAW_BYTES.
Publish an outbound request to a domain absent from OUTBOUND_ALLOWED_DOMAINS.
All three should be visible as dead-letter events without executing content or creating an infinite retry loop.
Failure atlas
No-IP resolves to the wrong address
Compare the No-IP dashboard with the router WAN address.
Inspect docker logs noip-duc.
Confirm the DDNS Key covers the selected hostname.
Do not update more frequently than the provider allows.
Remember that free hostname confirmation is separate from DUC updates.
External mail never reaches the VPS
Verify MX and A records.
Verify PTR separately.
Confirm TCP 25 is allowed by the VPS provider and firewall.
Inspect Postfix logs while sending a test.
Confirm the sender is addressing packet.init.wtf, not home.packet.init.wtf.
VPS accepts mail but the Pi never sees it
Check wg show for a recent handshake.
Ping 10.77.0.2 from the VPS.
Confirm Mailcow publishes TCP 25 on the Pi.
Check the Pi’s DOCKER-USER rules and router/LAN firewall.
Confirm /etc/postfix/transport was processed with postmap.
Mailcow receives mail but RabbitMQ stays empty
Confirm the message landed in packetbridge@packet.init.wtf.
Confirm the bridge IMAP password.
Confirm the To header still contains red@packet.init.wtf or green@packet.init.wtf.
Confirm PACKET_NODES=red,green.
Inspect bridge JSON logs; they never print passwords.
RabbitMQ receives duplicate envelopes
At-least-once processing permits redelivery when a process fails between publish confirmation and marking the IMAP message seen. Deduplicate by packet_id; do not pretend exactly-once delivery exists across IMAP and AMQP without a transactional boundary.
Outbound mail loops or retries forever
The supplied worker does not requeue the same delivery immediately. It republishes into a 60-second retry queue and stops after the configured attempt limit. If a custom consumer calls nack(requeue=true) forever, it has removed one of the design’s safety properties.
Mail lands in spam
Verify PTR, SPF, DKIM, and DMARC alignment.
Inspect the receiving provider’s authentication results.
Check the VPS IP’s reputation.
Send normal, low-volume test traffic.
Do not use the lab for bulk mail.
Security doctrine
Keep the experiment on a subdomain.
Never publish credentials, WireGuard private keys, DKIM private keys, or Mailcow encryption keys.
Do not expose RabbitMQ AMQP or management ports to the internet.
Do not execute attachments, HTML, URLs, headers, or body text.
Keep a hard message-size ceiling.
Allowlist outbound domains during the lab.
Reject unknown recipients at the VPS before accepting their message bodies.
Use finite retries and a visible dead-letter queue.
Treat every consumer as idempotent.
Use unique service accounts and two-factor authentication where available.
Keep the Pi and VPS patched.
Review logs without retaining more personal content than necessary.
Do not turn the system into an anonymous relay, bulk sender, or covert tunnel.
This machine processes correspondence. Even a playful lab inherits privacy obligations.
Backup and recovery
Mailcow stores mail, databases, spam-learning state, and encryption material in Docker volumes. Its encryption key material is essential; a backup without the keys is not a recovery plan.
Use Mailcow’s supported helper:
cd /opt/mailcow-dockerized
sudo ./helper-scripts/backup_and_restore.sh backup all --delete-days 14
Back up separately:
/opt/mailcow-dockerized/mailcow.conf
/opt/noip-duc/noip-duc.env
/etc/wireguard/
/opt/packetmail-bridge/.env
RabbitMQ durable data or exported definitions
VPS Postfix configuration and maps
DNS record inventory
Encrypt backup media. Test a restore on an isolated machine. A backup job that has never completed a restore test is only a theory.
Maintenance calendar
Weekly
confirm the WireGuard handshake is recent
inspect Mailcow and bridge health
check the Dead Letter Office
check disk and backup capacity
Monthly
apply operating-system security updates
run Mailcow’s supported update process
review the pinned RabbitMQ release against current support
verify SPF, DKIM, DMARC, MX, A, and PTR
test inbound and outbound delivery from an external provider
confirm the No-IP hostname if using the free tier
restore one small artifact from backup
After every topology change
repeat the open-relay test
repeat the unknown-recipient test
repeat the offline-node test
confirm no new Docker port is public
Zine treatment
This article should look like a recovered postal-computing manual:
dot-matrix route slips instead of cards
tractor-feed edges on only the configuration spreads
one full-page topology diagram stamped NOT TO SCALE
green-and-black photographs of the Pi, SSD, and Ethernet switch
a redacted Postfix queue listing
a redacted RabbitMQ queue screenshot
physical rubber-stamp marks for ACK, NACK, RETRY, and DEAD
a fold-out DEAD LETTER MANIFEST containing the three controlled failures
Suggested cover line:
RUN A POST OFFICE INSIDE YOUR POST OFFICE
Suggested pull quote:
Dynamic DNS tells the world where the house moved. It does not make the neighborhood trust your mail.
Definition of done
Do not label the article FIELD TESTED until every box is checked:
[ ] Pi boots from SSD after power loss
[ ] UPS shutdown or recovery behavior tested
[ ] No-IP changes propagate correctly
[ ] Mailcow certificate validates without warnings
[ ] External MX, A, and PTR agree
[ ] SPF passes
[ ] DKIM passes
[ ] DMARC report arrives
[ ] VPS refuses unrelated relay
[ ] WireGuard reconnects after home IP change
[ ] Known recipient reaches Mailcow
[ ] Mailcow message becomes a RabbitMQ envelope
[ ] Offline node retains three messages
[ ] Consumer deduplicates a replayed packet ID
[ ] Oversized input reaches the Dead Letter Office
[ ] Outbound failure stops after finite retries
[ ] Mailcow backup completes
[ ] Isolated restore succeeds
[ ] Every photographed secret is redacted
Until then, the correct cover stamp is LAB-READY DRAFT.
Primary references
Mailcow system requirements and ports — https://docs.mailcow.email/getstarted/prerequisite-system/
Mailcow installation — https://docs.mailcow.email/getstarted/install/
Mailcow DNS requirements — https://docs.mailcow.email/getstarted/prerequisite-dns/
Mailcow relayhosts — https://docs.mailcow.email/manual-guides/Postfix/u_e-postfix-relayhost/
Mailcow external-forwarding example — https://docs.mailcow.email/third_party/exchange_onprem/third_party-exchange_onprem/
Mailcow DNS-01 certificates — https://docs.mailcow.email/post_installation/firststeps-ssl-dns/
Mailcow backups — https://docs.mailcow.email/backup_restore/b_n_r-backup/
No-IP Linux Dynamic Update Client — https://www.noip.com/support/knowledgebase/install-linux-3-x-dynamic-update-client-duc
No-IP DUC Docker deployment — https://www.noip.com/support/knowledgebase/ip-linux-duc-docker
No-IP free-hostname confirmation — https://www.noip.com/support/knowledgebase/confirm-my-hostname-free-account-support-question-day
WireGuard quick start and persistent keepalive — https://www.wireguard.com/quickstart/
RabbitMQ current installation guidance — https://www.rabbitmq.com/docs/download
RabbitMQ publisher confirms and consumer acknowledgements — https://www.rabbitmq.com/docs/confirms
RabbitMQ dead-letter exchanges — https://www.rabbitmq.com/docs/dlx
Docker Engine on Ubuntu — https://docs.docker.com/engine/install/ubuntu/
END OF LINE // INIT.WTF // FIELD MANUAL 02