My Journey Down the Email Deliverability Rabbit Hole

thumbnail

The "Simple" Contact Form

My old website seemed in need of some change, I decided to revamp my personal portfolio website as it felt ripe for change. Naturally, I decided that this one HAS to feature a contact form and be built out. Wanting to keep things focused on the frontend (I'm using React with Next.js), setting up a backend just for email felt like overkill for my intended purpose for now as I don't expect that much traffic.

Enter EmailJS. It seemed like the perfect solution – a service designed to let you send emails directly from your client-side JavaScript without managing your own server. Connect your email provider (I used my Microsoft 365/Outlook account), create a template on their site, and trigger it with a simple function call. Easy, right? Famous last words.

// My initial setup looked something like this
import emailjs from '@emailjs/browser';

// ... inside my form component's submit handler ...

const serviceId = 'YOUR_SERVICE_ID'; // Filled these from EmailJS dashboard
const templateId = 'YOUR_TEMPLATE_ID';
const publicKey = 'YOUR_PUBLIC_KEY';

const templateParams = {
  from_name: formData.name.value,
  from_email: formData.email.value,
  message: formData.message.value,
  // Initially, I wasn't sure how the recipient was set, maybe here?
};

try {
  await emailjs.send(serviceId, templateId, templateParams, publicKey);
  // Show success message
} catch (error) {
  // Show error message
  console.error('EmailJS Error:', error);
}

First Hurdles: Talking to EmailJS & Deliverability

The form submitted... but the emails never arrived in my [email protected] inbox. Worse, sometimes EmailJS itself reported errors like template IDs not found, or strange rejection messages started appearing, like "Delivery has failed... recipient's email provider rejected it."

Okay, time to debug. With some help (shoutout to AI assistants for being great debugging partners!), I first checked the basics:

  1. EmailJS Config: Were my Service ID, Public Key, and Template ID exactly right? Turns out, I had the wrong templateId referenced in my main submit function compared to a test function – fixing that got rid of one error.

  2. Template Parameters: Was I sending the right data? Did my EmailJS template actually include the {{from_name}}, {{from_email}}, and {{message}} placeholders to display the data? I fixed that too.

But the rejections persisted. The error 550 5.7.708 Service unavailable. Access denied, traffic not accepted from this IP started appearing. This wasn't just an EmailJS config issue; this looked like my emails were being flagged as spam or untrustworthy by the receiving server (Microsoft Outlook, in my case).

Down the Rabbit Hole: Email Authentication

This led me down the path of email deliverability and authentication – concepts I hadn't needed to worry about with simple frontend projects before. I learned about the "three pillars":

  • SPF (Sender Policy Framework): A DNS record saying "These IP addresses/servers are allowed to send email for my domain."
  • DKIM (DomainKeys Identified Mail): Adds a digital signature to emails, proving they came from an authorized server and weren't tampered with.
  • DMARC (Domain-based Message Authentication, Reporting, and Conformance): A policy telling receivers what to do (monitor, quarantine, reject) if SPF/DKIM checks fail, plus reporting.

Since EmailJS was sending through my connected Microsoft 365 account, I needed to authorize Microsoft's servers to send on behalf of andytang.org.

Into the DNS Trenches: Configuring Cloudflare

My domain uses Cloudflare for DNS, so it was time to add some records:

  1. SPF: Found the correct value for Microsoft 365 (include:spf.protection.outlook.com) and added it to my existing TXT record for the root domain (@), ensuring I only had one v=spf1... record.
TXT | @ | v=spf1 include:spf.protection.outlook.com ~all
  1. DKIM: This was required by Microsoft 365. I had to enable it in the M365 Defender portal, which gave me two specific CNAME records to add in Cloudflare.
CNAME | selector1._domainkey | selector1-andytang-org._domainkey.andytangorg.onmicrosoft.com | DNS Only
CNAME | selector2._domainkey | selector2-andytang-org._domainkey.andytangorg.onmicrosoft.com | DNS Only

Key learning: These authentication records must have the Cloudflare proxy status set to "DNS only" (grey cloud), not "Proxied" (orange cloud).

  1. DMARC: To start monitoring, I added the initial DMARC policy.
TXT | _dmarc | v=DMARC1; p=none; rua=mailto:[email protected];

I learned p=none is crucial initially, and rua sends valuable (but complex XML) reports.

Then came the waiting game for DNS propagation. Using tools like whatsmydns.net became my new pastime, watching the records slowly appear across the globe. It was frustrating seeing partial propagation potentially causing Microsoft's checks to fail initially.

Success... Sort Of. Enter the Duplicates!

After propagation seemed complete and DKIM was enabled in Microsoft 365, the 5.7.708 rejection errors thankfully stopped! The contact form email was arriving!

...Actually, two identical emails were arriving for every single submission. Ugh.

Debugging Duplicates: EmailJS or My Code?

Okay, new problem. Why the duplicates?

  1. EmailJS Auto-Reply: I checked the EmailJS template settings again. Aha! There was an "Auto-Reply" tab, and it was enabled, seemingly configured to send to my own address ([email protected]) instead of the sender ({{from_email}}). I disabled/deleted this feature. Problem solved?

    Nope: Still getting duplicates.

  2. My React Code?: Could my handleSubmit function be firing twice? I opened browser DevTools -> Network tab. On submission, I carefully checked the requests to api.emailjs.com. Only one successful POST request (Status 200) was being sent per submission (plus the expected CORS OPTIONS preflight request). My code was behaving correctly!

Network Tab showing one 'fetch' and one 'preflight' request

The Plot Twist: It's Not EmailJS, It's... Outlook?

If my code only sends once, and EmailJS auto-reply is off, why two emails? On a hunch, I went directly into Outlook (web app) and sent an email from [email protected] to [email protected]. Two copies arrived in my inbox.

The duplication wasn't caused by EmailJS or my code after all! It was some strange behavior within my own Outlook/Microsoft 365 mailbox environment specific to receiving emails sent from itself. I checked for Inbox Rules and Forwarding settings, but found nothing obvious.

The Pragmatic Solution: Bypassing the Glitch

While the root cause in Outlook remained a mystery, I needed the contact form to work reliably. The diagnosis gave me an idea: what if the recipient wasn't exactly my own address?

I created a new user/mailbox in my Microsoft 365 account (an alias didn't seem to work immediately) solely for receiving these form submissions, let's call it [email protected]. I then configured EmailJS (in the template's "To Email" setting) to send the form submissions to this new address. Finally, I logged into the contact-form@... mailbox and set up automatic forwarding to my main [email protected] inbox.

Result: Success! Emails sent via EmailJS to the new address arrived only once, and then were forwarded cleanly to my main inbox. The duplication was bypassed.

What I Learned in the Depths

This seemingly simple task turned into a significant learning experience far beyond basic React:

  • Email Is Complex: What happens after you click "send" involves a whole chain of authentication and delivery systems (SPF, DKIM, DMARC, DNS).

  • DNS Matters: Understanding how to configure DNS records (TXT, CNAME) and dealing with propagation is crucial for web services.

  • Debugging Across Systems: Real-world problems often span multiple services (frontend -> API -> DNS -> Email Provider). Isolating the issue requires testing each step.

  • Value of Tools: Browser DevTools (Network tab), DNS checkers, and even AI assistants are invaluable resources for diagnostics and learning.

  • Pragmatism Over Perfection: Sometimes, identifying the root cause isn't immediately feasible or necessary. Finding a reliable workaround can be the best practical solution to meet the immediate goal.

  • Persistence Pays Off: Sticking with the problem, even when frustrating, led to a much deeper understanding and a working solution.

Final Thoughts

Building that "simple" contact form taught me more about the infrastructure underlying web communication than I ever expected. It was a reminder that even seemingly basic features can have hidden depths and that systematic troubleshooting, combined with a willingness to learn new concepts, is key. While I still don't know exactly why my Outlook account duplicates emails sent to itself, I successfully navigated the challenge and got my feature working reliably – and that feels like a significant win.

2025 — Andy Tang