DIRECT MAIL AdRails 0.8.0

Put the mailbox on the same ruler as Google Ads

AdRails runs a direct-mail program beside your paid ads: it cleans the list, assigns phone-readable tracking codes, prints exact Avery labels, counts human scans from your own logs, and reports response rates with 95% intervals, then puts cost per lead and per client next to Google Ads. Ten tools, local-first, open source.

Last reviewed 2026-11-06 with AdRails 0.8.0

THE MISSING COLUMN

Why do ad tools ignore the mailbox?

Clicks arrive with an ID attached. Postcards arrive as a phone call, a QR scan or a code typed into a form, so the mailbox never gets a column next to search.

Google Ads has a meter

Spend, clicks, conversions and cost per lead are read from the account. channel_cac can pull live Google Ads spend for a campaign, so the paid-search number is not typed in by hand. selftest: mail.cac

A postcard has no click

Nobody puts a postcard's cost per lead next to Google's because the response signal is a call, a scan or a read-out code. Without a unique code the lead is unattributable. Without a clean list, the print and postage spend is guesswork before it leaves the building.

One ruler, five channels

channel_cac reports cost per lead and per client across Google Ads, ChatGPT Ads, direct mail, lead vendors and referrals. Attribution order: tracking code, then UTM, then what the lead said, then referrer. selftest: mail.cac

ChatGPT Ads (OpenAI Ads API) is supported since AdRails 0.5.0. Today the live spend pull is Google Ads; ChatGPT Ads still appears as a channel in channel_cac. Ten direct-mail tools are listed under /docs/tools/.

SIX FAILURES, SIX CHECKS

What six mailing mistakes does the toolkit exist to stop?

The rails were generalised from a live postcard program a Tampa property manager ran in 2026. That program hit all six: labels cut off ZIP+4 and unit numbers, foreign addresses with domestic stamps, institutional owners, a stale suppression list, test scans counted as responses, and link-preview bots counted as people.

01

Truncated labels

mail_list_clean normalises USPS abbreviations and ZIP+4. mail_labels wraps a long line and refuses a label that will not fit; it does not cut the unit number off. selftest: mail.hygiene, mail.labels

02

Foreign addresses

A domestic stamp on an overseas address is wasted postage. mail_list_clean drops foreign and incomplete addresses before the print file is built. selftest: mail.hygiene

03

Institutional owners

Banks, REITs and housing authorities come off the list. Plain LLCs and trusts are kept, because the rule is ownership type, not the word LLC. selftest: mail.hygiene

04

A stale suppression list

Clients, leads and do-not-mail entries are suppressed. mail_list_clean warns when the client suppression list is empty, which is the state that produces an angry call. selftest: mail.hygiene

05

Your own test scans

The first scan is usually yours. mail_scans drops your own IPs and CIDRs, IPv4 and IPv6, and any hit before that piece was mailed. selftest: mail.scans

06

Link-preview bots

Slack, Facebook, WhatsApp and similar fetch a URL to build a preview. mail_scans drops bots and link previewers, so the count is unique human scans per code. selftest: mail.scans

PHONE-PROOF CODES

Can a tracking code survive being read over the phone?

A code that only works when scanned is not a tracking code for a local service business. Most of the response arrives as a spoken code on a call.

The check character

mail_codes_assign uses an alphabet without 0, 1, I, L, O or U, then adds a check character: a position-weighted checksum modulo the prime 31. It is measured to catch every single wrong character and every swapped adjacent pair, with 0 misses in 4,060,000 substitutions. selftest: mail.codes

Why 47 is the number

On the same substitution test, standard Luhn mod 30 missed 47 adjacent swaps. A caller reading two characters in the wrong order would have produced a valid-looking code and a misattributed lead. selftest: mail.codes

What it will not do

Lookalike characters are never guessed. If a caller reads a character the alphabet excludes, mail_codes_assign does not silently repair it into a different code. selftest: mail.codes

Each code also gets a tracking URL with UTM tags and a CSV ready for the label run. selftest: mail.codes

INDEPENDENTLY DECODED

Do the QR codes actually scan?

A QR symbol that looks right on screen and fails on a phone is a silent hole in the response count.

What is generated

mail_qr is a pure-Python encoder for versions 1-10, error-correction levels L/M/Q/H and all eight masks. It returns SVG text and writes a PNG in the mail workdir. selftest: mail.qr

The decoder result

An independent decoder (OpenCV) read 557 of 557 test symbols back to the exact input. The harness control read 40 of 40 codes from an established encoder, so the test itself was not the weak link. selftest: mail.qr

PRINT-EXACT SHEETS

Will the labels print exactly, or cut the unit number?

The address is the delivery mechanism. A wrapped line is recoverable; a truncated unit number is a returned piece and a wasted stamp.

Avery sheets as HTML

mail_labels builds print-exact Avery 5160/8160, 5163, 5164 and 5167 sheets as HTML that prints to PDF in any browser. An optional QR can sit on each label, and a vendor CSV is included with a CSV-injection guard. selftest: mail.labels

SAMPLE by default

The SAMPLE watermark is ON by default. A real print run uses sample=false and is written to the tamper-evident audit chain, so there is a record of which sheet was actually released to print. selftest: mail.labels

Too long is refused

A line that cannot fit is refused, not cut. mail_list_clean normalises the address first; mail_labels wraps and refuses overflow. selftest: mail.hygiene, mail.labels

HUMANS, NOT HITS

How do you count scans without storing IP addresses?

A scan log can answer "did anyone respond?" without becoming a second customer database of raw IP addresses.

From your own logs

mail_scans reads your own web-server logs and counts unique human scans per code. It drops bots and link previewers such as Slack, Facebook and WhatsApp, your own IPs and CIDRs (IPv4 and IPv6), unknown codes, and any hit before that piece was mailed. selftest: mail.scans

Hashed ids, not raw IPs

Visitor ids are hashed; raw IPs are not stored. Re-running the same log is idempotent, so a weekly scan job does not inflate the numbers. First-scan delay and scan rate by batch or segment are returned too. selftest: mail.scans

A scan is not a lead

mail_scans counts scans. mail_response turns scans, leads and clients into response rates with intervals. A high scan count on a small batch still gets called "too early". selftest: mail.response

INTERVALS, NOT POINTS

What is an honest response rate for a postcard?

A single percentage with no interval invites a decision the sample cannot support. The tool says what the data can and cannot show.

Rates with intervals

mail_response reports response rates by any column — batch, segment or variant — with 95% Wilson intervals and a verdict. Small samples are labelled "too early" or "no signal yet: could still be up to X%" rather than dressed up as a point estimate. selftest: mail.response

A/B, not vibes

mail_compare runs a two-proportion test with a difference interval: two postcard designs, or mailed versus a no-mail holdout. The difference gets an interval, not just a winner. selftest: mail.response

The worked example

Base rate 1%, target lift +50%. Do not compute the counts yourself; mail_holdout_plan returns the counts for a no-mail holdout at 95% confidence and 80% power. Its result lands within 3% of the textbook formula. selftest: mail.response

A no-mail holdout is a clean control: some pieces are not mailed and the response difference is measured. mail_holdout_plan sizes that holdout before you print.

ONE COST PER LEAD

How do you compare a postcard with a Google Ads click?

The channel with the cheapest visible cost is not always the channel with the cheapest client. The comparison has to use the same unit.

Cost per lead, cost per client

channel_cac puts Google Ads, ChatGPT Ads, direct mail, lead vendors and referrals on one ruler. Attribution order: tracking code, then UTM, then what the lead said, then referrer. It can pull live Google Ads spend for a campaign, and labels small numbers "too few to judge". selftest: mail.cac

Break-even before you print

mail_breakeven takes cost per piece (print, postage, other), lead-to-client rate and client value, and returns the response rate you need to break even. That is the number to beat, not last year's postcard anecdote. selftest: mail.cac

No postage, no contact

Nothing in the direct-mail tools sends mail, buys postage or contacts anyone. The tools build the list, codes, QR symbols and label sheets; your printer and mail house do the rest.

MONTH ONE

What does a month-one direct-mail routine look like?

The agent runs the same sequence every campaign: clean, code, label, mail, scan, response, CAC. Each step has a tool and an output you can inspect.

01

Clean the list

mail_list_clean normalises USPS abbreviations and ZIP+4, drops foreign and incomplete addresses, institutional owners, your own properties, clients and do-not-mail entries, and duplicates. selftest: mail.hygiene

02

Assign codes

mail_codes_assign gives every piece a unique code with a check character and a tracking URL with UTM tags, then writes a CSV ready for the label run. selftest: mail.codes

03

Print labels

Run mail_labels with the SAMPLE watermark first. When the sheet is right, run sample=false; the real run is written to the audit chain. Add a QR per label with mail_qr. selftest: mail.labels, mail.qr

04

Mail

Your printer or mail house sends the pieces. AdRails does not send mail, buy postage or contact anyone; it prepares the list, codes, symbols and labels.

05

Read scans weekly

mail_scans reads the web-server log, drops bots, link previewers, your own IPs, unknown codes and pre-mail hits, and returns unique human scans with first-scan delay. selftest: mail.scans

06

Response at six weeks

mail_response reports the rate by batch or segment with a 95% Wilson interval and an honest verdict. mail_compare tests variant against variant or mailed against holdout. selftest: mail.response

07

CAC across channels

channel_cac puts direct mail next to Google Ads, ChatGPT Ads, lead vendors and referrals as cost per lead and per client, with small numbers flagged "too few to judge". selftest: mail.cac

START OFFLINE, MEASURE ONLINE

Can the mailbox join the same report as paid search?

Ten direct-mail tools run locally with your list, your printer and your CRM. The measurement is built to survive a phone call, a bot and a small sample.

For the answer-engine side of the same problem, see the AEO toolkit: crawler access for 18 AI and search crawlers, page answer-readiness, llms.txt and share-of-answer tracking. selftest: aeo.crawl_audit

FAQ

Straight answers.

Can AdRails mail postcards for me?

No. The ten direct-mail tools do not send mail, buy postage or contact anyone. They clean the list, assign tracking codes, build print-exact label sheets and QR codes, and measure scans and response from logs you already have. You keep your printer, postage vendor and CRM; AdRails keeps the measurement honest.

How do I know a postcard actually caused a lead?

Each piece gets a unique tracking code and a URL with UTM tags. channel_cac attributes in a fixed order: tracking code first, then UTM, then what the lead said, then referrer. Scans count only after the mail date and only from unique humans, with bots, link previewers, your own IPs and unknown codes dropped (mail.scans, mail.cac).

What sample size do I need before I trust a response rate?

Use mail_holdout_plan. Give it the base response rate and the lift you want to detect; at 95% confidence and 80% power it returns the pieces and holdout counts, landing within 3% of the textbook formula. At a 1% base rate and a target of +50%, do not hand-calculate: mail_holdout_plan returns the counts. Until then mail_response says 'too early'.

Can the tracking codes be read over the phone?

Yes. Codes exclude 0, 1, I, L, O and U and carry a check character: a position-weighted checksum modulo the prime 31. It is measured to catch every single wrong character and every swapped adjacent pair, with 0 misses in 4,060,000 substitutions. Standard Luhn mod 30 missed 47 adjacent swaps on the same test (mail.codes).

Will the labels cut off ZIP+4 or apartment numbers?

No. mail_list_clean normalises USPS abbreviations and ZIP+4, and mail_labels refuses a label that is too long instead of truncating it. Sheets are print-exact Avery 5160/8160, 5163, 5164 and 5167 as HTML that prints to PDF from a browser. SAMPLE is on by default; a real run is written to the audit chain (mail.labels).

Do the QR codes work with normal phone scanners?

They are generated by a pure-Python encoder for versions 1-10, error-correction levels L/M/Q/H and all eight masks, returned as SVG and written as PNG. An independent decoder (OpenCV) read 557 of 557 test symbols back to the exact input, and the harness control read 40 of 40 codes from an established encoder (mail.qr).