Ship Data Apps with AI

👥 Users & money·Lesson 17 of 26·~20 min·Intermediate

✉️ Email that arrives

One mailbox on your domain, SMTP switched on once for every app, SPF, DKIM and DMARC done right, and reset links that point at the real site.

You'll leave with

  • A mailbox on your own domain, wired into your accounts server over SMTP
  • One SPF record that names every sender, plus DKIM and DMARC, checked from outside
  • Verification and reset emails that land, proven with a real request

Accounts work until someone forgets a password. Then your app must send an email that lands in the inbox, not spam. Email fails quietly. Today you set it up once, for every app: a mailbox on your domain, your accounts server sending through it, three DNS records (entries in your domain's public address book) that vouch for you, and a real test. No logins, only a list on the way? Do steps 1, 4 and 6.

1

A mailbox on your own domain

Two kinds of mail go out as yourdomain.com: transactional mail, sent to one person because of something they did (a verification, a password reset, a receipt), and your mailing list. Both live or die by a few DNS records.

Pick an email host, the company that runs your domain's mail: Hostinger Email if your domain is there, or Zoho and others (Google Workspace needs an app password). Create support@yourdomain.com in its control panel (at Hostinger: hPanel → Emails), with a long random password from your password manager. Prices vary by plan, and some free plans have no SMTP, so check. Some hosts have no API (no way for programs, Claude included), so this is the one step you do by hand. Then send a hello from its webmail (mail in your browser) to your personal inbox and reply; both should arrive.

What are MX and SPF records?

Two kinds of DNS record. MX records name the servers that receive your domain's mail. SPF is a TXT record (a DNS entry holding a line of text) listing every service allowed to send as your domain; mail from anywhere else looks forged.

⚠️ Creating a mailbox adds MX and SPF records, and it can rewrite an SPF record you already had. Run the SPF check in step 4 straight after.

Your accounts server (and later your list's sender) logs in to this mailbox over SMTP. Keep its password in your password manager and in an env file on the server (NAME=value lines that programs read at startup), in the root-only /root/secrets folder from Deploy behind one front door. Never in the repo or a chat.

What's SMTP?

Simple Mail Transfer Protocol: how a program hands email to a mail host. It connects on a port (a numbered door, usually 465 or 587; see step 2), logs in as the mailbox and hands over each message.

Let Claude make the file, minus the password:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouLook up my email host's SMTP server, then create /root/secrets/mail.env on my server: SMTP_HOST, SMTP_PORT=465, SMTP_USER and SMTP_FROM as support@yourdomain.com, and an empty SMTP_PASS=. chmod 600 it and show me ls -l.
ClaudeYour host's help page lists smtp.yourmailhost.com on port 465; open it to compare. The file has four values and an empty SMTP_PASS=, and ls -l shows -rw------- root root.↳ WebSearch · your host's SMTP settings↳ Bash · ssh root@YOUR_VPS_IP 'printf … > /root/secrets/mail.env; chmod 600 …; ls -l …'

What's happening underneath: chmod 600 lets only root, the server's admin user, open the file. Add the password yourself in your server tab: run nano /root/secrets/mail.env, paste it after SMTP_PASS=, then Ctrl+O, Enter, Ctrl+X to save and leave. The finished file, with your values:

SMTP_HOST=smtp.yourmailhost.com
SMTP_PORT=465
SMTP_USER=support@yourdomain.com
SMTP_PASS=YOUR_MAILBOX_PASSWORD
SMTP_FROM=support@yourdomain.com

Check it's filled without printing it:

grep -q '^SMTP_PASS=.' /root/secrets/mail.env && echo filled
filled

💡 Mailbox plans cap daily sending. If yours is too tight, a transactional service (Resend, Postmark or Amazon SES) plugs in the same way, with its own SMTP details and DNS records.

2

SMTP, switched on once

PocketBase, the free program behind your logins from Accounts without building auth, ships with SMTP off. Until someone enters a host, port, username and password, "Forgot password?" can't work for any app on that server. Nothing tells you: the reset request still answers success; failures show only on the dashboard's Logs page.

💡 No logins? Skip the PocketBase steps (the rest of this one, 3 and 5): your list's sender uses the same mailbox, port and password from the env file (built in a later lesson, Build an email list), and step 4's records are what matter.

What are ports 465 and 587?

Two doors where mail hosts take mail from programs. On 465, TLS (the encryption behind the padlock) starts with the first byte; 587 starts plain and upgrades with a command called STARTTLS. Both are safe, not interchangeable.

Use the port your host documents; the code differs too (Python: smtplib.SMTP_SSL for 465, smtplib.SMTP plus starttls() for 587). Open the dashboard at https://accounts.yourdomain.com/_/, log in as the superuser (the admin from Accounts), then Settings → Mail settings:

  1. Sender name: your brand. Sender address: support@yourdomain.com (most hosts won't send as anyone else).
  2. Switch on Use SMTP mail server. SMTP server host and Port come from your env file, Username is support@yourdomain.com, and you paste the Password yourself.
  3. Show more options → TLS encryption: Always for 465, Auto (StartTLS) for 587. The form starts on 587 and Auto, so changing only the port isn't enough.
  4. Save changes, then Send test email: Password reset, your users collection, your personal address.

A green Successfully sent test email, and the message in your inbox, prove PocketBase reaches your host. Labels vary by version.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouSend test email says Test email send timeout. Port 465, TLS encryption on Auto (StartTLS), password already in. What's wrong?
ClaudeTLS. 465 expects encryption from the first byte; Auto (StartTLS) starts plain, so both ends wait until the dashboard gives up. Your server reached port 465 with TLS at once, so the host is fine. Set TLS encryption to Always and resend.↳ Bash · ssh root@YOUR_VPS_IP 'openssl s_client -connect smtp.yourmailhost.com:465 -brief </dev/null'

What's happening underneath: openssl s_client connects the way PocketBase should and prints CONNECTION ESTABLISHED when the host speaks TLS on that port. PocketBase stores the password in its database, so treat pb_data and its backups as secret too.

📍 First Bites' self-service reset was stuck for a couple of weeks: SMTP was off on my shared accounts server, which still had a placeholder host. Once a real mailbox was plugged in, every app on that server got working resets at once.

🦥 You can launch sign-up before this, as Acento did, with "no verification, no reset" on its NOT DONE list (The laziest thing that works). But wire email before you take money (Get paid without leaking money, two lessons on): a paying user who forgets a password has no way back in.

3

Every link in every PocketBase email is built from one setting, the Application URL. Its default, http://localhost:8090, means "this computer" to whoever opens the email. On a computer, hover over your test email's button without clicking and read the address at the bottom of the window: localhost means broken.

📍 When I wired email on my accounts server, its Application URL still said localhost: every link it sent would have been broken.

The reset link opens PocketBase's own reset page under /_/, which Accounts kept off every app's domain, so the Application URL must be the accounts address. Have Claude confirm:

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouWhat do https://accounts.yourdomain.com/_/ and https://myapp.yourdomain.com/_/ return? Status codes only.
Claudeaccounts: 200, PocketBase's dashboard page. myapp: 404, as Accounts set it up. So email links must use https://accounts.yourdomain.com.↳ Bash · curl -s -o /dev/null -w '%{http_code}' https://accounts.yourdomain.com/_/ (then myapp)

What's happening underneath: curl fetched each address and printed only its status code. Try it yourself; in PowerShell, type curl.exe and NUL instead of /dev/null.

Before the first real email, open Settings → Application. Application URL: https://accounts.yourdomain.com (no slash at the end). Application name: your brand (it signs every email; the default is "Acme"). Save changes; the next test email's button starts with your accounts address.

🦥 Skipped: a branded reset page. Yours would send the link's token (its one-time code) and the new password to /api/collections/users/confirm-password-reset (edit the link in the users collection's Options). Until email works, set a password by hand in the dashboard: fine for ten users, miserable for a hundred.

⚠️ Once mail works, PocketBase also emails people who log in from a new device (users collection → Options → Send email alert for new logins). Keep it or untick it, knowingly.

4

SPF, DKIM and DMARC without tears

Three DNS records decide whether receivers believe your mail: SPF lists who may send for your domain, DKIM signs each message, and DMARC says what to do with mail that passes neither check.

What are DKIM and DMARC?

DKIM: the sender signs each message with a secret key, and the matching public key sits in your DNS at SELECTOR._domainkey.yourdomain.com (the selector is the sender's label), for receivers to check. DMARC: a TXT record at _dmarc.yourdomain.com saying what to do with mail that passes neither check: deliver (p=none), spam folder (quarantine) or refuse (reject).

📍 The day I created a mailbox for traqqit.com, my host rewrote the domain's SPF record and silently dropped my newsletter tool from it, along with its DKIM records. Every newsletter would have gone to spam.

The rule: a domain may have only one SPF record. Two v=spf1 lines don't add up: receivers call it an error, and SPF fails for all your mail. So every sender goes in one line at @, the bare domain (include: means "also allow what this service allows"; ~all, "treat anyone else as suspicious"). Copy each sender's DKIM records (often CNAMEs, which point one name at another) exactly as listed:

NameTypeValue
@TXTv=spf1 include:_spf.mailhost.example include:_spf.newsletter.example ~all
SELECTOR._domainkeyCNAME or TXTExactly what each sender lists
_dmarcTXTv=DMARC1; p=none; rua=mailto:support@yourdomain.com

Replace the includes with the ones your senders' setup pages list, and the address with yours. With p=none, the record only asks receivers for daily reports at the rua address; once they show every sender passing, switch to p=quarantine so forgeries land in spam.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouLook up my domain's SPF, DKIM and DMARC records and tell me if any service that sends my mail is missing. Don't change anything. My senders: my mailbox host and my newsletter tool.
ClaudeOne SPF line, naming only your mailbox host; the newsletter tool is missing, its two DKIM names return Non-existent domain, and there's no DMARC record. Nothing changed; the nslookup lines below show the same.↳ WebFetch · newsletter setup page↳ Bash · nslookup -type=TXT … (SPF, DMARC, each DKIM name)
YouFix it: one SPF line at @ with both senders, other TXT lines kept, the newsletter's DKIM records, and DMARC (p=none, reports to support@yourdomain.com). Show the records before and after.
ClaudeFour lines changed: the merged SPF line, two DKIM records, one DMARC record. Your A record (site address), MX and other TXT lines are unchanged; compare the two lists above.↳ hostinger · execute · list, update (TXT at @ as one full set; new records with overwrite: false), list again

What's happening underneath: Claude works through the Hostinger connection from Your own server; with DNS elsewhere, make the same edits in that company's panel.

🛑 Some DNS tools replace every record of a type at a name. Hostinger's overwrite does: replacing TXT at @ deletes every TXT line there, site verifications included, so the new set must carry them over. Check both lists: A and MX untouched, one SPF line. To undo, ask Claude to restore the DNS snapshot (a saved copy) from before the change, or the before list.

Then check from outside, the way receivers do. nslookup asks a DNS server (here 1.1.1.1, as before) for a name's records (PowerShell, Git Bash or Mac):

nslookup -type=TXT yourdomain.com 1.1.1.1
nslookup -type=TXT _dmarc.yourdomain.com 1.1.1.1
nslookup -type=TXT SELECTOR._domainkey.yourdomain.com 1.1.1.1

Replace yourdomain.com, and SELECTOR with each sender's selector, the part before ._domainkey in its records; run that line once per sender. The first two should include:

yourdomain.com text = "v=spf1 include:_spf.mailhost.example include:_spf.newsletter.example ~all" _dmarc.yourdomain.com text = "v=DMARC1; p=none; rua=mailto:support@yourdomain.com"

One v=spf1 line naming every sender (other TXT lines, like site verifications, are fine), and a long public key (p=…) for each selector, sometimes after a canonical name = line. Non-existent domain (Mac: NXDOMAIN) means the record isn't there. Re-check all three after ANY change at an email provider: nothing warns you.

5

“Forgot password?” that leaks nothing

Every login box gets a Forgot password? link. It sends a POST request (the browser sending data to a server) with the email to /api/collections/users/request-password-reset on the app's own domain, which your /api route passes to PocketBase (with Accounts' /accounts prefix, the path starts /accounts/api/, here and in the code).

PocketBase answers 204 (success, nothing to show) whether or not the account exists, so always say: If that email has an account, a reset link is on its way. Anything else tells strangers who is registered.

📍 First Bites, Acento, Grease and this course's own login all have this link.

EXAMPLE — YOU ASK CLAUDE CODE

Claude Code
YouAdd Forgot password? under the login form: POST to request-password-reset and always show the same message.
ClaudeAdded the link and a note under the password field; read the diff before deploying. A test POST for an address with no account got 204 through myapp.yourdomain.com, which PocketBase answers for every address.↳ Edit · index.html↳ Bash · curl -X POST …/request-password-reset (unknown address)

What's happening underneath: the heart of the edit. Pasting it yourself? In the getElementById('email') line, put your login form's email-box id in place of email:

<a href="#" id="forgot">Forgot password?</a>
<p id="forgot-note" hidden></p>
<script>
document.getElementById('forgot').addEventListener('click', async (e) => {
  e.preventDefault();
  const email = document.getElementById('email').value.trim();
  const note = document.getElementById('forgot-note');
  note.hidden = false;
  if (!email) { note.textContent = 'Type your email above first.'; return; }
  const r = await fetch('/api/collections/users/request-password-reset', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email }),
  }).catch(() => null);
  note.textContent = r && r.ok
    ? 'If that email has an account, a reset link is on its way. Check spam too.'
    : 'Something went wrong. Try again in a minute.';
});
</script>

Once deployed, open your login page in Chrome or Edge, press F12 (Mac: Cmd+Option+I) for DevTools (the browser's inspector) and pick Network. Request a reset for an address with no account: the request-password-reset row should say 204, Console show no red errors, and the page your sentence. Safari first needs Show features for web developers (Settings → Advanced); phones can't do this.

6

Test with a real request

⚠️ Send test email only proves your mail host accepted a message. Its link carries a token for a made-up user, so it shows an invalid or expired token message. Expected, not a bug.

Test the real path with a plus-address: YOUR_INBOX+smtptest@gmail.com lands in YOUR_INBOX@gmail.com (replace YOUR_INBOX with your Gmail name).

  1. Sign up in your app with that address, log out, and use Forgot password? with it (or request a verification email, if your app sends them).
  2. Within a minute or two it should arrive in the Inbox, not Spam, from support@yourdomain.com.
  3. In Gmail on a computer, open it, then ⋮ (top right of the message) → Show original: SPF, DKIM and DMARC should all say PASS. The Gmail phone app can't show this.
  4. Click its button: a PocketBase page on accounts.yourdomain.com asks for a new password. Set it, then log in to your app with it.
  5. Delete the throwaway user: dashboard → Collections → users, open its record, delete it.

PocketBase sends one reset per account every couple of minutes; a quick second click gets 204 but no email. No logins? Send from webmail to the plus-address and check Show original the same way.

Anything other than PASS? Find the DKIM-Signature line among the headers (the labeled lines above the message) and give Claude that line, not the email, whose body holds a live reset link: "Show original says DKIM isn't PASS, and its DKIM-Signature has s=SELECTOR. Look up that record and tell me what's wrong. Don't change anything."

What's happening underneath: s= names the selector, the very record the receiver looked up; if it's missing, add the DKIM record from your host's setup page. Even with three passes, a brand-new domain can land in spam at first.

Do it with Claude: the prompts

  1. Look up my domain's SPF, DKIM and DMARC records and tell me if any service that sends my mail is missing. Don't change anything.
  2. Open my PocketBase mail settings and fill in the host, port 465, TLS and the sender, but leave the password for me to type in the dashboard. Set the Application URL to https://accounts.yourdomain.com, then send a real reset to a throwaway user.
  3. Add Forgot password? under the login form: POST to request-password-reset and always show the same message.

Prompt 2 needs a superuser login Claude never has to see, like Accounts' pb-admin.env. Without it, Claude should list the dashboard fields (step 2) and still send the reset, signing up the throwaway user like any visitor.

What's happening underneath: receiving servers look up your DNS records for every message, so one wrong TXT record silently changes where all your mail lands.

Your turn

  • Prove the mailbox: send a webmail hello to your personal inbox and reply. Done when both arrive and the server's grep check prints filled.
  • Logins only: switch on SMTP and set the Application URL. Done when Send test email succeeds and that email's button starts with https://accounts.yourdomain.com.
  • Fix your DNS. Done when nslookup shows exactly one v=spf1 line naming every sender, and the DMARC and each DKIM lookup print text, not Non-existent domain.
  • Logins only: ship the Forgot password? link. Done when an address with no account gets 204 in DevTools and the same sentence a real account gets.
  • Run the real test. Done when your plus-address reset (or, without logins, a webmail message) lands in the Inbox with three PASSes in Show original, the link sets a new password, and the throwaway user is deleted.

Next, Sync and sharing that never lose work: two phones, two parents, one log, and nothing lost.