Skip to content

Email

Outgoing mail (sign-up confirmations and the weekly newsletter) goes from the app over SMTP to OCI Email Delivery, which signs it and delivers it. The authentication records live in Cloudflare DNS. The setup was done on 2026-10-02. No credential appears on this page; they exist only as GitHub secrets and in the server’s config.yaml.

How Mail Flows

  Web server (/subscribe)          Newsletter (Saturdays 08:00 ET)
     confirmation email                weekly issue, one copy per reader
              \                          /
               v                        v
        internal/mail  (gomail, From: [email protected])
                         |
                         |  SMTP, port 587, STARTTLS
                         v
        OCI Email Delivery  (us-sanjose-1; DKIM-signs as milesahead.kumaranik.com)
                         |
                         v
        Readers' inboxes  (check SPF, DKIM and DMARC in Cloudflare DNS)
EmailSent byRecipient
Sign-up confirmationinternal/web/subscribe.go through mail.Sender.SendConfirmationThe address that signed up, at most once per 10 minutes
Weekly newsletternewsletter.SendWeekly through mail.Sender.SendNewsletterEvery confirmed subscriber, one copy each
Daily digestThe admin’s manual Send on the Newsletter page (SendDaily)Every confirmed subscriber
Vote alertcmd/milesahead/votealerts.go through mail.Sender.SendVoteAlert, when an article’s recent downvotes cross the alert linevotes.alert_email (the ALERT_EMAIL secret); not sent when it is empty or SMTP is unset. The admin Published page lists every alert either way

Review emails with a reply address exist in the code (SendAdminNotification) but nothing sends them, and review by email is off; see Inbound mail.

Every reader email (confirmation, newsletter, digest) carries the reader’s own https://milesahead.kumaranik.com/unsubscribe?token=... link, in the body and in List-Unsubscribe, with List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). The confirmation email’s headers were added when sign-up was switched on. See Newsletter for the reader side.

OCI Email Delivery

SettingValue
Regionus-sanjose-1
TierAlways Free: 3,000 emails a month
Email domainmilesahead.kumaranik.com
DKIM selectormilesahead-202610 (active)
Approved sender[email protected]
SMTP endpointsmtp.email.us-sanjose-1.oci.oraclecloud.com, port 587, STARTTLS
Sending identityIAM user milesahead-smtp, member of group milesahead-smtp-senders
Policymilesahead-smtp-send: Allow group milesahead-smtp-senders to use email-family in tenancy

The IAM user exists only to send: it holds the SMTP credential, and the group’s single policy grants email-family and nothing else. If its credentials leak, the damage is mail sent as the approved sender, and rotating them is a new SMTP credential on that user plus two secret updates.

The From address must be an approved sender, or OCI refuses the message. To send from another address, approve it in OCI first, then set the SMTP_FROM secret.

DNS Records

All in Cloudflare (DNS only), on the subdomain the mail is sent as:

  • DKIM: CNAME milesahead-202610._domainkey.milesahead.kumaranik.com → milesahead-202610.milesahead.kumaranik.com.dkim.sjc1.oracleemaildelivery.com
  • SPF: TXT on milesahead.kumaranik.com: v=spf1 include:rp.oracleemaildelivery.com ~all
  • DMARC: TXT on _dmarc.milesahead.kumaranik.com: v=DMARC1; p=none;

A test message from the server to Gmail passed SPF, DKIM (d=milesahead.kumaranik.com) and DMARC, over TLS 1.3, into the inbox.

GitHub Secrets

SecretRequiredBecomes
SMTP_HOSTyesmail.smtp.host (the endpoint above)
SMTP_USERyesmail.smtp.user, the SMTP credential’s user name
SMTP_PASSWORDyesmail.smtp.password
SMTP_PORTnomail.smtp.port, default 587; must be numeric
SMTP_FROMnomail.smtp.from, default [email protected]
ALERT_EMAILnovotes.alert_email, where vote alerts are emailed; empty by default (alerts are then shown in the admin only)

The deploy’s config job writes them into config.yaml on the runner, each JSON-encoded with jq so any character in a password survives, runs milesahead -check-config on the result and only then copies it to the server. Unset secrets leave the placeholders localhost and placeholder, which the app recognises as “SMTP not configured”. See Generated config.

The Sign-up Switch

newsletter.signup_enabled opens sign-up: the header, footer, home and end-of-article placements, the /subscribe form and confirmation emails. It defaults to false and is on in production. Off, /subscribe says sign-up opens soon and stores nothing, while confirm and unsubscribe links keep working.

At startup the app checks whether SMTP looks unset (smtpLooksUnset in cmd/milesahead/main.go): no host or From, a user or password that is still placeholder or an unexpanded ${...}, or a localhost relay that does not answer. If it does:

  • with the switch on, it logs an ERROR, newsletter sign-up stays closed: newsletter.signup_enabled is on but SMTP is not configured, and sign-up stays closed, because readers would sign up and never get the email;
  • it warns mail: SMTP is not configured; no newsletter or confirmation email will be sent;
  • the Saturday send is skipped with a warning rather than failing (so the send-failed alert does not fire every week), and the admin’s Send says SMTP is not configured.

With SMTP set and the switch on, the journal says newsletter sign-up is open with the SMTP host (never the credentials).

Alerts

milesahead_emails_sent_total{kind,outcome} counts confirmation and newsletter emails sent and failed. Two Grafana rules in deploy/grafana-alerts.json watch it, pushed by the deploy’s grafana job:

AlertFires when
Confirmation emails failing2 or more failed confirmations in 6 hours (threshold 1.5, since increase extrapolates)
Newsletter send failedany failed newsletter send in 6 hours (a send stops at its first failure)

OCI’s List-Unsubscribe and the Suppression List

OCI Email Delivery adds its own List-Unsubscribe header to any message that does not already have an https one. An unsubscribe through that header puts the address on OCI’s suppression list, not in the app: OCI then silently drops mail to it, while the app still counts the reader as subscribed.

So every reader email carries the app’s own per-reader https link and one-click header, which OCI leaves alone; an unsubscribe then reaches POST /unsubscribe and the app marks the reader unsubscribed.

Admin emails (the vote alert, sent to votes.alert_email) have no app link, so they still get OCI’s header, and Gmail may show an Unsubscribe link beside them. Do not click it: it would put the owner’s address on OCI’s suppression list, and no further alert (or any other mail from the app) would reach it. If that happens, remove the address from the suppression list in the OCI console (Email Delivery, Suppression List).

OCI also suppresses addresses that hard-bounce or complain. Those are dropped by OCI and still count as sent in the app’s metric.

Inbound Mail

The code has half of a review-by-email loop: a review email with a review+TOKEN@ reply address (SendAdminNotification), and an IMAP poller (internal/mail/inbound.go) that reads replies and treats APPROVE, REJECT, UNPUBLISH or anything else as rewrite feedback. It is off:

  • nothing calls SendAdminNotification, so no review emails go out;
  • nothing resolves a reply’s token to its article;
  • so the poller never starts, even with a mailbox configured (it would only mark that mailbox’s unread mail as read). The deploy config sets mail.imap to localhost with placeholder credentials, and startup logs review by email is off (mail.imap not configured); the admin settings page shows “Review by email: off”.

OCI Email Delivery only sends, so replies would need a real mailbox (IMAP, e.g. Gmail) or an inbound webhook service. ROADMAP.md (Future) lists what it would take.

Articles are reviewed in the admin dashboard. Mail sent to [email protected] is not read by the app.

Troubleshooting

Is mail configured? After a deploy, look for newsletter sign-up is open in the journal. newsletter sign-up stays closed or mail: SMTP is not configured means the SMTP secrets are missing or still placeholders.

journalctl -u milesahead --since today | grep -E 'sign-up|SMTP|confirmation email|newsletter send'

Did a message leave? milesahead_emails_sent_total in Grafana, split by kind and outcome. A failure is logged with the SMTP error. An authentication error (535) means the SMTP credential is wrong or was rotated: create a new SMTP credential for milesahead-smtp, update SMTP_USER and SMTP_PASSWORD, and redeploy. A rejected sender means SMTP_FROM is not an approved sender.

Did it arrive, and was it trusted? Open the message’s original headers in the receiving mailbox. Authentication-Results should show spf=pass, dkim=pass with header.d=milesahead.kumaranik.com, and dmarc=pass. Check the records:

dig +short TXT milesahead.kumaranik.com
dig +short CNAME milesahead-202610._domainkey.milesahead.kumaranik.com
dig +short TXT _dmarc.milesahead.kumaranik.com

A reader gets nothing, but the app counts them subscribed. Look the address up in the OCI Console’s Email Delivery suppression list. It may have bounced, been reported as spam, or used OCI’s own unsubscribe header. Remove it only if the reader asks to receive mail again.

Tightening DMARC. p=none only monitors, and the record names no report address, so no reports arrive. Once mail has been passing for a while, add a rua=mailto: address someone reads, watch the reports, then move to p=quarantine (and later p=reject). That is an open ROADMAP item.

Costs

The Always Free tier covers 3,000 emails a month. A weekly issue is one email per confirmed subscriber per Saturday, about 4.3 a month, plus one confirmation per sign-up (and any resend), so the free tier holds roughly 650 subscribers with room for confirmations. milesahead_subscribers_total shows where the count stands; check OCI’s current pricing before the list grows past that.