Quick check

Confirm these four things before going further.

  • The send actually appears in Settings → Email log at the expected timestamp.
  • That row’s status is delivered, not queued, sent, bounced, or failed.
  • The recipient’s address is correctly spelled and isn’t on the suppression list.
  • The recipient checked their spam / junk folder.

The send never happened

The symptom

You expected an email to fire (e.g. you clicked Send proposal on a proposal detail page, or you saved a new invoice), but the email log has no row for it.

Why it happens

  • The template you were trying to send (e.g. proposal_sent) is missing from Settings → Email templates, was deleted, or is marked inactive.
  • A required variable for the template isn’t in the source record — e.g. a proposal with no client email, an invoice with no total_cents. The dispatcher refuses to fill in nulls.
  • The originating module failed before it called rpc_emit_email. (For example, an invoice save validation error short-circuits before any email queue insert.)

How to fix it

  1. Open the source record (the proposal, the invoice, the order) and check it has all the fields the email needs: client.email, totals, dates.
  2. Settings → Email templates. Confirm the template key (e.g. proposal_sent) exists and is Active. Click it and check the Required variables tab — every variable listed must be in the payload.
  3. Retry the action that should have fired the email. The new row should appear in the email log within a second.
  4. If the row still doesn’t appear, check Settings → Errors for an entry around the same timestamp — rpc_emit_email validation failures land there.

Hard bounce

The symptom

Email log row shows status = bounced, bounce_type = hard. The recipient’s address is now in the suppression list.

Why it happens

A hard bounce means the receiving mail server has told us the address is permanently undeliverable — usually because the mailbox doesn’t exist, the domain doesn’t accept mail, or the recipient’s server has explicitly rejected us as a sender.

We auto-add hard-bounce addresses to core.email_suppressions. Future sends to that address are silently skipped (the row shows status = suppressed) until you remove it — this protects your sender reputation. Resending to a bouncing address makes the problem worse for every other recipient on your domain.

How to fix it

  1. Confirm the address is correct (typo? extra space? wrong domain?). Most hard bounces are typos like jane@gmial.com.
  2. Contact the recipient through another channel (phone, SMS) and confirm a working address. Update their client record with the corrected email.
  3. If the correct address is the same as the bouncing one (the recipient claims it works), then remove from the suppression list at Settings → Email log → Suppressions. Click the row → Remove suppression. Document your reason — this is audited.
  4. Re-send the email. If it bounces again, the suppression is doing its job — don’t remove it a second time.

Soft bounce

The symptom

Email log row shows status = bounced, bounce_type = soft. The address is not added to the suppression list.

Why it happens

A soft bounce is transient: the recipient’s mailbox is full, their server is down, a greylist is in effect, or our message tripped a momentary content filter. We auto-retry up to 3 times over the next ~30 minutes. If all 3 retries soft-bounce, we mark the message failed; we do not add the address to suppressions (the user’s address probably still works).

How to fix it

  1. Look at the bounce_reason column on the row. Common patterns:
    • mailbox full → recipient needs to clean their inbox. Reach out another way.
    • greylisted → usually self-resolves on the next retry; check back in 15 minutes.
    • spamhaus / blocklist → our IP or your from-domain has been temporarily flagged. See Delivered but marked spam.
  2. If all retries failed, click Resend on the row. That re-enqueues a fresh send (often the issue has cleared by the next attempt).

Delivered, but recipient marked it spam

The symptom

Email log shows status = delivered, possibly also complained_at populated. The recipient says they never received it, and you find it in their spam folder.

Why it happens

The receiving mail server accepted the message but filed it as junk based on its content, sender reputation, or DKIM/SPF mismatch. Once a user clicks “mark as spam”, the server tells us via Resend’s complaint webhook and we add the address to suppressions (same as a hard bounce).

How to fix it

  1. Check that your from domain is fully verified in Resend — DKIM and SPF must both be green. See From domain not verified.
  2. Set up DMARC on your domain (v=DMARC1; p=none; rua=mailto:... is fine for monitoring). DMARC alone often nudges Gmail / Outlook to deliver to inbox.
  3. Have the recipient mark the message as Not spam in their mail client. That teaches the recipient’s filter; subsequent sends to that address improve.
  4. Check the email template for spam-trigger phrases — ALL CAPS subject lines, image-only bodies, suspicious link domains, “FREE!!!” in the subject. Edit the template at Settings → Email templates.
  5. If the address is now suppressed, remove the suppression after the recipient confirms they want the mail and has marked it not-spam.

Address is on the suppression list

The symptom

Email log shows status = suppressed for every send to this address. Nothing actually gets sent to the recipient.

Why it happens

The address is in core.email_suppressions — typically because of an earlier hard bounce or a recipient marking us spam. Resend treats this as a hard rule: we don’t even attempt the send, we just log a suppressed row.

How to fix it

  1. Settings → Email log → Suppressions. Search for the address.
  2. Click the row to see the original reason (hard_bounce, complaint, manual) and the date it was added.
  3. If you’ve confirmed with the recipient that the address works and they want our mail, click Remove suppression with a one-sentence reason.
  4. Trigger the original action again. The next send should now go through with status = sentdelivered.
Don’t bulk-remove suppressions

Each removal is audited and counts against your sender reputation if it re-bounces. Remove suppressions one at a time, with the recipient’s active confirmation.

From domain not verified

The symptom

Every send shows status = failed with provider_error containing “domain not verified” or “not authorized to send from”.

Why it happens

Resend (our upstream email provider) won’t relay mail from a from-address whose domain hasn’t been verified in your Resend account. If you changed core.email_settings.default_from_email to a new domain and didn’t verify it in Resend first, every send rejects upstream.

How to fix it

  1. Open your Resend dashboard → Domains. Find the from-domain. Status must be Verified with DKIM and SPF both green.
  2. If not verified, add the required DNS records (Resend will show you the exact TXT and CNAME values) and wait for propagation (usually <15 minutes).
  3. In PeptideClients, double-check Settings → Email → Default from address matches a verified address.
  4. Re-trigger the send. Subsequent log rows should be delivered.

The symptom

The email was delivered correctly — the recipient got it — but the “View proposal” / “Pay invoice” / approval link inside the email points at http://localhost:5173 or some other non-production URL. Clicking it fails.

Why it happens

Email templates build links from core.email_settings.default_app_url. If that value was set during local development and never updated for production, every link in every email picks up the dev URL.

How to fix it

  1. Go to Settings → Email → App URL (or, for platform admins, edit core.email_settings.default_app_url directly).
  2. Set it to https://app.peptideclients.com exactly. No trailing slash, no www..
  3. Save. The next email will use the corrected URL — we re-render template URLs at send time, not at template-edit time.
  4. Old emails already in inboxes still point at the broken URL — you can’t fix those. Resend a fresh copy to anyone who needs the link.
Never use window.location.origin for email links

Some operators try to “fix” emails by editing templates to use a hardcoded URL. Don’t. The right answer is to set default_app_url — that’s the single source of truth across every template.

Reading the diagnostics

Settings → Email log lists every email we attempted in the last 90 days. Columns:

ColumnWhat to look at
sent_atWhen the row landed in our queue. Often a fraction of a second before delivered_at.
toRecipient address. Watch for typos.
template_keye.g. proposal_sent, invoice_paid. Confirms which template fired.
statusqueuedsentdelivered on the happy path. bounced, failed, suppressed, or complained mean trouble.
bounce_type / bounce_reasonPopulated when status = bounced. Tells you hard vs soft and the upstream reason string.
provider_message_idResend’s id. Clicking it deep-links to that message in the Resend dashboard for the full upstream event timeline.
provider_errorPopulated when status = failed. The raw error string from Resend.
When to open Resend directly

Our log is the source of truth for “did we try to send”. Resend’s dashboard is the source of truth for “what did the recipient’s mail server actually do”. Click the provider_message_id to jump straight to the Resend page for any message.

When to write to support

Email {{CONTACT_EMAIL}} with:

  1. The recipient address and the approximate send timestamp.
  2. The email log row — click Copy as text. Includes status, provider_message_id, and bounce_reason.
  3. The template_key involved.
  4. If you have it, the Resend event timeline for the provider_message_id (open Resend, screenshot the “Events” section).
  5. One sentence each on expected vs actual.

Related: Email feature guide · Magic link email never arrives · Troubleshooting hub.