Azure Troubleshooting

Why Your Azure Communication Services Email Lands in Spam: Domains, DKIM/DMARC, and Bounce Diagnostics

Your password-reset emails worked in the demo. You flipped to production, pointed at a real customer base, and now the support queue fills with “I never got the code.” Some users find the mail in Junk; others never see it; a handful bounce back with a cryptic 550 5.7.1. Nothing in your code changed. Azure Communication Services (ACS) Email — the managed transactional-email service that sends mail from your own domain over a REST API or SDK without an SMTP server — reports every send as Succeeded, yet the mail isn’t arriving. This is the most maddening ACS Email incident, because “the API returned 200” and “the human got the email” are completely different claims, separated by a gauntlet of receiver-side spam filters, authentication checks and reputation gates that ACS cannot see and your logs do not mention.

This is the diagnostic playbook for that gauntlet. Deliverability is not one bug; it is a chain — sender domain → SPF → DKIM → DMARC alignment → reputation → recipient filter → bounce/suppression — and a failure at any link drops your mail into spam while the send still “succeeds.” You will learn to read that chain hop by hop, using the artefacts that tell the truth: the domain verification records in your DNS zone, the Authentication-Results header in a received message, the delivery status from the SDK, the suppression list, and the Email Service diagnostic logs in Azure Monitor. Every diagnosis comes with the exact place to confirm it — a dig query, an az communication command, a header, a KQL query — and the precise fix, with az CLI and Bicep where it applies.

By the end you will stop guessing. When the “I didn’t get the email” tickets arrive you will know within minutes whether you face an unverified domain, a missing DKIM selector, an SPF record that doesn’t authorise ACS, a DMARC policy quarantining you on an alignment failure, a suppressed recipient from an earlier bounce, a managed-domain reputation problem, or simply a body that trips a spam filter. Knowing which is the difference between a one-line DNS fix and a week of blaming “Outlook being weird.”

What problem this solves

ACS Email gives you a transactional-mail pipeline you call with one SDK method — no SMTP relay to babysit. That abstraction is a gift until a message goes missing, then an opaque wall. The send API is deliberately optimistic: it tells you ACS accepted the message, not that a mailbox accepted it into the inbox. Those are wildly different events, and everything interesting — authentication, reputation, content scoring, the recipient’s filter — happens after the API returns, outside anything ACS reports in real time.

What breaks without this knowledge: an engineer re-sends three times (it “succeeds” three times), tells the user to “check spam,” or blames the recipient’s provider and ships anyway. Meanwhile the actual cause sits there, perfectly diagnosable: a DKIM CNAME never added to DNS, an SPF record listing every vendor except ACS, a forgotten p=reject DMARC policy, or a recipient who hard-bounced last week and is now on the suppression list so ACS silently drops every further send.

Who hits this: every team that migrates from a sandbox managed domain to a custom domain and skips a DNS record; every team with an existing DMARC policy that doesn’t know ACS has to align; every product sending to consumer mailboxes (Gmail and Yahoo’s 2024 rules made SPF, DKIM and DMARC effectively mandatory); and anyone who reads “the API succeeded” as “delivered.” The fix is almost never “re-send” — it’s “find the failing link and make it pass.” Every failure class, the question it forces, and the first place to look:

Failure class What’s actually happening First question to ask First place to look Most common single cause
Domain not verified ACS rejects the send or won’t let you connect the domain Is the domain Verified in ACS? az communication email domain showverificationStates Verification TXT/CNAME records never added
Lands in spam (not bounced) Delivered, but filtered to Junk Does the received header show DKIM/DMARC pass? Authentication-Results header in a test message Missing/broken DKIM or SPF
DMARC quarantine/reject Receiver drops or junks on policy Is your DMARC policy enforcing, and does ACS align? dig TXT _dmarc.<domain> + the header p=reject/quarantine with no aligned DKIM
Hard bounce Recipient address rejected by their server What’s the SMTP enhanced status code? Send-status / Azure Monitor deliveryStatusDetails Bad address (550 5.1.1)
Recipient suppressed ACS silently drops the send Is this address on the domain suppression list? Suppression-list API / Monitor logs Earlier hard bounce or complaint
Throttled / over quota 429 or queued, mail delayed Did you exceed the send-rate or size limit? API 429 / quota in the portal Burst above the per-domain rate limit

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already understand the ACS basics: an Azure Communication Services resource is the parent that exposes channels (Email, SMS, Chat, Calling), and Email Communication Services is the companion resource that owns email domains; you connect a domain to the parent, then send with a connection string or Entra ID. You should know how to run az in Cloud Shell, read JSON output, and edit DNS records in whatever zone hosts your domain (Azure DNS or an external registrar). A high-level grasp of email authentication — receivers check who claims to send against who is allowed to send — helps, but this article builds it from the ground up.

This sits in the Observability & Troubleshooting track, on the messaging side. It assumes you know your way around DNS records (the How to Host Public DNS on Azure DNS: Zone Delegation, Alias Records and Apex Domains deep-dive is the upstream skill — every fix here is a DNS record). For the observability half it pairs with Azure Monitor and Application Insights: Full-Stack Observability, because the ACS Email Service diagnostic logs land in Log Analytics and KQL is how you read delivery status at scale; if those logs are empty, No Logs Showing Up? Troubleshooting Empty Log Analytics Tables and Ingestion Gaps is the companion. If you secure the connection string in Key Vault (you should), Azure Key Vault: Secrets, Keys and Certificates Done Right covers that, and Managed Identities Demystified: System vs User-Assigned and When to Use Each covers sending without a connection string at all.

Responsibility splits across teams: the app/platform team owns the ACS resource and suppression list; the DNS/network team owns the SPF/DKIM/DMARC records; the app/dev team owns the From address and content; and the recipient’s provider — outside Azure entirely — owns the spam filter, reputation gates and bounces.

Core concepts

Five mental models make every later diagnosis obvious.

“Accepted” is not “delivered to the inbox.” The send API returns a status meaning ACS queued your message — it passed validation and the domain is connected. It says nothing about whether the receiver authenticated it, scored its content, checked your reputation, and chose Inbox vs Junk vs reject. Deliverability lives entirely in that gap: Succeeded is the start of delivery, and the truth arrives later as a delivery status (Delivered / Bounced / Suppressed / Failed) in the operation result and diagnostic logs.

Three authentication mechanisms answer one question: “is this sender allowed?” On mail claiming to be from @yourbrand.com, a receiver runs three checks. SPF asks “is the sending IP authorised in yourbrand.com’s SPF record?”; DKIM asks “is the message signed by a key published in yourbrand.com’s DNS, and is the signature intact?”; DMARC asks “did SPF or DKIM pass and align with the From, and if not, what should I do?” SPF authorises an IP, DKIM authorises a message, DMARC ties them to the From. A clean pass on all three is most of the difference between Inbox and Junk.

Alignment is the trap inside DMARC. SPF and DKIM can pass while DMARC still fails, because DMARC additionally requires the passing domain to align with the visible From: domain. SPF aligns on the MailFrom / Return-Path (the envelope sender ACS sets); DKIM aligns on the d= tag in the signature. If ACS signs with a d= of your custom domain and you publish its DKIM keys, DKIM aligns and DMARC passes — which is why DKIM is the load-bearing record for ACS. “SPF passed but DMARC failed” almost always means SPF authorised a server on a domain that doesn’t match your From — alignment, not authorisation, is the failing half.

The domain comes in two flavours with opposite trade-offs. An Azure-managed domain (<guid>.azurecomm.net) is instant with pre-configured SPF/DKIM but shares reputation and can’t brand the From; a custom domain (mail.yourbrand.com) is yours — you publish SPF/DKIM/DMARC, send from your brand, and build your own reputation. Managed = fast and throwaway; custom = production and yours.

Reputation is earned, shared or borrowed — and it decays. Mailbox providers score the sending identity (IP and domain) on history: volume consistency, complaint rate, bounce rate, spam-trap hits. A brand-new custom domain has no reputation and must be warmed up or a sudden blast looks suspicious; a managed domain borrows shared reputation you don’t control. A hard bounce or complaint damages reputation and lands the recipient on your suppression list. Reputation is the invisible gate behind every “it’s in spam for some users but not others.”

Those terms — the two domain types, SPF/DKIM/DMARC, the MailFrom, the suppression list, the delivery status, and reputation/warm-up — are the whole vocabulary; the glossary defines each, and the sections below take them one at a time.

Email authentication, end to end (SPF, DKIM, DMARC)

The three records aren’t interchangeable — each answers a different question and fails its own way:

Mechanism The question it answers Record type & location What aligns with the From Failure symptom
SPF Is the sending IP authorised by the domain? TXT at the domain (or MailFrom subdomain) MailFrom / Return-Path spf=fail/softfail; lowers trust, rarely junks alone
DKIM Is the message signed by a key the domain published? Two CNAME selectors → ACS-hosted keys The d= tag in the signature dkim=fail/none; major Junk driver; breaks DMARC
DMARC Did SPF or DKIM pass and align? What to do if not? TXT at _dmarc.<domain> The visible From: domain dmarc=fail + p=quarantine/reject → Junk or drop

SPF — authorising the sending server

SPF is a single TXT record listing the servers allowed to send for a domain; the receiver checks the connecting IP of the MailFrom domain against it and records spf=pass/softfail/fail. SPF alone rarely junks you, but a fail lowers trust and it is one of the two ways DMARC can pass. For ACS you publish a record that includes the ACS infrastructure via the include: value ACS gives you. The cardinal rules: exactly one SPF record per domain (two is a permerror), stay within the 10-lookup limit, and end with ~all or -all.

Confirm what’s published with dig +short TXT mail.yourbrand.com | grep spf1 (expect one v=spf1 include:... ~all line). In Bicep this is a Microsoft.Network/dnsZones/TXT resource on the MailFrom host with that single value (the DKIM Bicep below shows the record pattern).

The SPF values and the traps that quietly break them:

SPF element Meaning Use it when Trap / limit
v=spf1 Marks the record as SPF v1 Always, first token Two SPF TXT records = permerror, SPF ignored
include:<host> Authorise another domain’s senders Add the ACS include Each include counts toward the 10-lookup cap
ip4: / a / mx Authorise literal IPs / your A/MX hosts Static or self-hosted senders Each costs a DNS lookup; brittle
~all Softfail everything else Most production (safer rollout) Receivers may still accept softfail
-all Hardfail everything else Locked-down senders Risky if any legit sender is unlisted
+all Pass-all Never Authorises the world — never use

DKIM — signing the message (the record that matters most)

DKIM is the workhorse for ACS because it is what makes DMARC align. ACS signs each message with a DKIM-Signature header carrying a selector and the signing domain (d=); the receiver fetches the public key from <selector>._domainkey.<domain> and verifies it. For a custom domain ACS hosts the keys and gives you two CNAME recordsselector1-azurecomm-prod-net._domainkey and selector2-azurecomm-prod-net._domainkey (two selectors for key rotation). If either CNAME is missing, signing fails (dkim=none), DMARC can’t align via DKIM, and your mail gets junked — the number-one cause of ACS Email landing in spam after a custom-domain migration.

Confirm both resolve with dig +short CNAME selector1-azurecomm-prod-net._domainkey.yourbrand.com (and selector2-...) — each must return an azurecomm-prod-net target; empty means the record is missing. In Bicep:

// The two DKIM selector CNAMEs (targets come from ACS domain config — do not invent them)
resource dkim1 'Microsoft.Network/dnsZones/CNAME@2023-07-01-preview' = {
  parent: zone
  name: 'selector1-azurecomm-prod-net._domainkey'
  properties: { TTL: 3600, CNAMERecord: { cname: '<acs-selector1-target>' } }
}
resource dkim2 'Microsoft.Network/dnsZones/CNAME@2023-07-01-preview' = {
  parent: zone
  name: 'selector2-azurecomm-prod-net._domainkey'
  properties: { TTL: 3600, CNAMERecord: { cname: '<acs-selector2-target>' } }
}

The DKIM moving parts and where each one breaks:

DKIM element What it is Where it lives Failure if wrong
Selector + public key The CNAME naming which ACS-hosted key signs (e.g. selector1-azurecomm-prod-net) <selector>._domainkey.<domain> CNAME Missing CNAME → no key → dkim=none
d= tag The signing domain in the signature header The DKIM-Signature header Doesn’t match From → no align → DMARC fail
Two selectors selector1 + selector2 for key rotation Two CNAME records Only one added → rotation breaks signing later
b= / bh= The signature and body hash In the header (set by ACS) Body modified in transit → dkim=fail

DMARC — the policy that decides your fate

DMARC is a TXT record at _dmarc.<domain> that requires alignment (a passing SPF or DKIM whose domain matches the visible From) and states a policy for failures — none (monitor), quarantine (Junk), or reject (refuse). It is why a signed message from the wrong domain still fails, and why an enforcing policy you forgot can quarantine your new ACS mail on day one. Gmail and Yahoo now effectively require it for bulk senders (p=none minimum), and an aligned DMARC pass is your strongest trust signal — but turning it to quarantine/reject before DKIM aligns buries your own mail.

Read the policy in effect with dig +short TXT _dmarc.yourbrand.com. In Bicep, start at p=none:

// Start at p=none to monitor, then tighten once DKIM aligns
resource dmarc 'Microsoft.Network/dnsZones/TXT@2023-07-01-preview' = {
  parent: zone
  name: '_dmarc'
  properties: {
    TTL: 3600
    TXTRecords: [
      { value: [ 'v=DMARC1; p=none; rua=mailto:dmarc-reports@yourbrand.com; adkim=r; aspf=r' ] }
    ]
  }
}

The DMARC tags you set, with the recommended rollout value:

DMARC tag Meaning Values Recommended (ACS rollout)
v Version DMARC1 DMARC1 (required, first)
p Policy for the domain none / quarantine / reject none first → quarantinereject
rua Where to send aggregate reports mailto: Set it — reports show alignment results
pct % of mail the policy applies to 1–100 Ramp 25100 when tightening
adkim / aspf DKIM / SPF alignment strictness r (relaxed) / s (strict) r unless you control MailFrom exactly

The cardinal rule: roll DMARC out in stagesp=none with rua reporting first, confirm aligned-DKIM passes from the reports and a test send, then quarantine, finally reject. Jumping straight to reject before DKIM is verified is a self-inflicted outage.

Reading the Authentication-Results header

The fastest deliverability diagnosis costs nothing: send a test to an address you control, open the raw source, and read the Authentication-Results header. In one line it tells you whether SPF, DKIM and DMARC each passed — turning “is it an auth problem?” from a guess into a fact.

Authentication-Results: mx.google.com;
  dkim=pass header.i=@yourbrand.com header.s=selector1-azurecomm-prod-net;
  spf=pass (google.com: domain of bounces@mail.yourbrand.com ...) smtp.mailfrom=mail.yourbrand.com;
  dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=yourbrand.com

How to read every outcome you’ll see in that header:

Header token Meaning What it tells you If it’s not pass
dkim=pass Signature verified The DKIM CNAMEs are correct and signing works none = CNAME missing; fail = key/body mismatch
spf=pass Sending IP authorised SPF TXT includes ACS and MailFrom resolves softfail/fail = SPF missing the ACS include
dmarc=pass SPF or DKIM passed and aligned Alignment is working; trust is high fail = nothing aligned with the From domain

So dkim=pass with dmarc=fail is an alignment problem (DKIM’s d= doesn’t match the From); spf=pass with dmarc=fail means the MailFrom is an ACS host (SPF aligns to that, not your brand) and DKIM isn’t carrying alignment — exactly why DKIM must be configured for custom domains.

Choosing and verifying your domain

Every send starts from a domain, and that choice fixes your reputation, your From address, and how much DNS work you do.

Azure-managed vs custom domain

The decision is about whose reputation and which environment: managed gets a test send in minutes from a *.azurecomm.net address; custom gets your brand and your own reputation at the cost of DNS work:

Dimension Azure-managed domain Custom domain
Setup time Minutes (no DNS work) Hours–days (DNS + propagation + verify)
From address Fixed *.azurecomm.net subdomain Your brand (no-reply@mail.yourbrand.com)
SPF / DKIM Pre-configured by ACS You publish them in your zone
Reputation Shared across tenants Yours alone (must warm up)
Send limits Low, test-grade per-day caps Higher, raisable via support
Best for Dev/test, demos, quick spikes Production, anything customer-facing
Brand trust Low (generic domain) High (your domain)
Risk Another tenant can taint shared reputation You own every reputation signal

The rule: managed for non-production, custom for anything a customer sees — the From address alone screams “test”, and the shared reputation is outside your control.

Verifying a custom domain

Connecting a custom domain is two gates: prove ownership, then prove you authorised ACS to send (SPF + DKIM). ACS gives you the exact records (... domain show --query verificationRecords); you add them, then ask ACS to check each:

# Create the domain, read the records to add, then verify each type
az communication email domain create --domain-name mail.yourbrand.com \
  --email-service-name acs-email-prod -g rg-mail-prod \
  --location global --domain-management CustomerManaged
az communication email domain show --domain-name mail.yourbrand.com \
  --email-service-name acs-email-prod -g rg-mail-prod --query "verificationRecords" -o json
# After adding the records in DNS, verify each type (Domain, SPF, DKIM, DKIM2):
az communication email domain initiate-verification --domain-name mail.yourbrand.com \
  --email-service-name acs-email-prod -g rg-mail-prod --verification-type Domain

ACS issues four records: the Domain TXT (proves ownership — without it you can’t connect the domain at all), the SPF TXT (authorises ACS to send), and the two DKIM CNAMEs (selector1-... and selector2-..., publishing the signing keys — without them dkim=none and DMARC can’t align). After all four show Verified, connect the domain to the parent Communication Services resource so sends from it are authorised. Verification waits on DNS propagation — add the records, wait out the TTL, then initiate verification; initiating before propagation is the most common “it won’t verify” self-own.

Sender addresses and MailFrom

Once a domain is verified you add one or more sender usernames (the part before the @, e.g. no-reply), making the From no-reply@mail.yourbrand.com. The MailFrom (Return-Path) is separate — the envelope sender ACS uses for bounces and SPF, an ACS-managed address on your domain by default. Keep them distinct: the From is what the human sees and what DMARC evaluates; the MailFrom is where bounces go and what SPF is checked against.

# Add a sender username to a verified domain
az communication email domain sender-username create \
  --domain-name mail.yourbrand.com --email-service-name acs-email-prod \
  --resource-group rg-mail-prod --sender-username no-reply \
  --username no-reply --display-name "YourBrand (no-reply)"

Sending mail and reading the real result

The send call is where engineers form the wrong belief that “it worked.” Here is how to get the delivery truth.

The send operation and its statuses

You send with the Email SDK (or REST). The call is long-running — you poll an operation through statuses — and the status that matters is the message’s delivery status, not “the call returned”.

// .NET: send and WAIT for the result, don't fire-and-forget
var client = new EmailClient(connectionString);
var message = new EmailMessage(
    senderAddress: "no-reply@mail.yourbrand.com",
    recipientAddress: "user@example.com",
    content: new EmailContent("Your code") { PlainText = "Your code is 123456" });

EmailSendOperation op = await client.SendAsync(WaitUntil.Completed, message);
Console.WriteLine($"Status: {op.Value.Status}, OperationId: {op.Id}"); // correlate Id in Monitor

The send-status values and what they do not mean:

EmailSendStatus Meaning What it does NOT mean Your next move
NotStarted Queued, not yet processing Anything about delivery Keep polling
Running ACS is processing the send That a mailbox accepted it Keep polling
Succeeded ACS accepted & dispatched the message That it reached the inbox Check delivery status / Monitor logs
Failed ACS could not send it That the address is bad (could be config) Read the error code below
Canceled The operation was canceled A recipient action Re-issue if intended

The single most important sentence in this article: Succeeded means accepted, not delivered. Delivered vs Bounced vs Suppressed lives in the delivery-status detail — per-recipient in the operation result, and at scale in the Azure Monitor logs (below).

Send-time error codes (the call itself fails)

When the call fails, the error code tells you whether it’s your config, your input, or a limit:

HTTP / code Meaning Likely cause Fix
401 Unauthorized Auth to ACS failed Bad/rotated connection string or key Refresh the connection string / Entra token
403 Forbidden (DomainNotLinked) The From domain isn’t connected Domain not linked to the ACS resource, or not verified Connect & verify the domain
400 InvalidSenderDomain Sender address not allowed From username/domain not provisioned Add the sender username; use a verified domain
400 (Invalid recipient/content) Malformed request Bad email address, missing content Validate inputs before send
429 TooManyRequests Rate/size limit hit Burst over the per-domain send rate Back off + retry with the Retry-After header
413 / 500 / 503 Payload too large / transient error Oversized attachments, or an ACS-side blip Shrink/host attachments; retry with backoff

Bounces, soft bounces and the delivery status

A message ACS accepted can still come back as a delivery status with an SMTP enhanced status code: Delivered (2.x.y); Bounced hard (5.x.y, permanent — adds the recipient to your suppression list); Suppressed (dropped because already suppressed); FilteredSpam/Quarantined (the receiver’s filter or DMARC held it — a reputation/auth fix, not a retry); and Failed soft (4.x.y, temporary like a full mailbox — retriable). The SMTP codes you’ll meet most:

Enhanced code Class Typical meaning What to do
5.1.1 / 5.1.10 Permanent Recipient address does not exist (5.1.10 = M365 variant) Drop the address; fix data quality
5.7.1 Permanent Rejected by policy (auth/reputation/blocklist) Fix SPF/DKIM/DMARC; check blocklists
5.7.26 Permanent Failed auth (SPF/DKIM/DMARC) at receiver Fix authentication — the classic deliverability bug
5.4.1 Permanent Recipient server not accepting (no answer) Verify the recipient domain/MX
4.2.2 / 5.2.2 Temp / Perm Mailbox full / over quota Retry later; if persistent, stop
4.7.x Temporary Greylisting / temporary policy defer Retry after a delay (greylisting clears)

Diagnosing deliverability with Azure Monitor

At scale you don’t poll individual operations — you read the Email Service diagnostic logs ACS emits to Log Analytics, where every send, delivery and engagement event is a queryable row. This is how you answer “what’s our bounce rate?” and “which recipients are suppressed?” across millions of messages. The logs are off by default; route them to a workspace via a diagnostic setting on the Communication Services resource:

# Send ACS Email diagnostic logs to Log Analytics
az monitor diagnostic-settings create \
  --name acs-email-diag \
  --resource $(az communication show -n acs-comms-prod -g rg-mail-prod --query id -o tsv) \
  --workspace $(az monitor log-analytics workspace show -g rg-mail-prod -n law-mail --query id -o tsv) \
  --logs '[{"category":"EmailSendMailOperational","enabled":true},
           {"category":"EmailStatusUpdateOperational","enabled":true},
           {"category":"EmailUserEngagementOperational","enabled":true}]'

The ACS Email log categories and what each answers:

Log category What it records Question it answers
EmailSendMailOperational Each send request (sender, message id, size) “Did we attempt the send, and with what From?”
EmailStatusUpdateOperational Delivery status updates (Delivered/Bounced/Suppressed) “What actually happened to the message?”
EmailUserEngagementOperational Opens/clicks (when tracking is on) “Did the recipient engage?”

The KQL you’ll actually run

// Delivery-status breakdown for the last 24h — your bounce/suppression dashboard
ACSEmailStatusUpdateOperational
| where TimeGenerated > ago(24h)
| summarize count() by DeliveryStatus
| order by count_ desc
// Which recipients/domains are hard-bouncing? (data-quality + reputation signal)
ACSEmailStatusUpdateOperational
| where TimeGenerated > ago(7d) and DeliveryStatus == "Bounced"
| extend recipientDomain = tostring(split(RecipientId, "@")[1])
| summarize bounces = count() by recipientDomain
| order by bounces desc
// Correlate a single message from send to final status by MessageId
ACSEmailSendMailOperational
| where TimeGenerated > ago(1d)
| join kind=leftouter (ACSEmailStatusUpdateOperational) on MessageId
| project TimeGenerated, Sender, MessageId, Size, DeliveryStatus, DeliveryStatusDetails

Those three patterns — group by DeliveryStatus, break bounces down by recipient domain, and join send-to-status by MessageId — answer almost every operational question (delivery mix, suppression rate, failure spikes via bin(TimeGenerated, 5m), complaint signal from the engagement table). If these tables are empty, the diagnostic setting isn’t routing or the workspace is wrong — that ingestion gap is covered in No Logs Showing Up? Troubleshooting Empty Log Analytics Tables and Ingestion Gaps.

The suppression list

ACS keeps a suppression list per email domain: addresses that hard-bounced or complained, which ACS silently refuses to send to (the send Succeeds, the status is Suppressed). This protects your reputation — hammering a dead address is what gets a sender blocklisted — but it surprises engineers who “re-sent and it still didn’t arrive.” Review it, and only remove an address you’ve confirmed is corrected.

# List suppressed addresses for a domain (via the Domains suppression-list management)
az communication email domain suppression-list list \
  --domain-name mail.yourbrand.com --email-service-name acs-email-prod \
  --resource-group rg-mail-prod -o table

Architecture at a glance

Hold the whole system as a left-to-right pipeline and every diagnosis becomes “which stage is failing?” Your application calls the ACS Email send API; ACS validates that the domain is connected and verified, signs the message with your DKIM key (a d=yourbrand.com signature), sets the MailFrom/Return-Path for bounces, and dispatches it — at which point the call returns Succeeded. That is the last event ACS fully controls.

From there the message crosses to the recipient’s mail provider, which runs the gauntlet your logs can’t see: it looks up your SPF record, verifies your DKIM signature (and whether d= aligns with the From), evaluates DMARC, scores your reputation, and runs content filtering. The verdict — Inbox, Junk, quarantine, or reject — is decided here, entirely outside Azure. The outcome then flows back as a delivery status into your Azure Monitor logs and, on a hard bounce or complaint, onto your suppression list.

The mental model: ACS owns the left half (domain, signing, dispatch), which you fix with DNS and config; the right half (auth checks, reputation, filtering) lives at the receiver, influenced only through what you publish (SPF/DKIM/DMARC) and how you behave (volume, complaints, list hygiene). Every “it’s in spam” question reduces to: did the message pass the three auth checks with alignment, and is your reputation good enough? The diagnostic move is always the same — read the Authentication-Results header on a test send to localise the failing check, then fix the corresponding DNS record.

Real-world scenario

Finlyte, a fintech startup in Bengaluru, runs onboarding and OTP delivery through ACS Email. In the sandbox they used an Azure-managed domain — instant, OTPs in seconds — and shipped. Two weeks after launch on their custom domain mail.finlyte.in, the support inbox filled with “I never received my OTP”: roughly 40% of sign-ups stalled at verification.

The first reflex was to re-send (it “succeeded” every time); the second was to blame Gmail. The breakthrough cost nothing: an engineer sent a test OTP to her own Gmail, opened Show original, and read spf=pass, dkim=none, dmarc=fail. SPF passed because the MailFrom resolved to an ACS host — but DKIM was none, so DMARC had nothing aligned with finlyte.in, and Finlyte had set p=quarantine months earlier for a different sender. Gmail was doing exactly what the policy told it to: quarantine unaligned mail to Junk.

The root cause was a single missing DNS record. During migration the team had added the domain-verification TXT and SPF TXT, ACS flipped those to Verified, and they declared victory — but they had never added the two DKIM selector CNAMEs. az communication email domain show --query verificationStates confirmed it: DKIM: NotVerified, DKIM2: NotVerified. No DKIM → no signature → DMARC couldn’t align on finlyte.in → with p=quarantine, Gmail junked it. The 40% had it sitting in Junk; a stricter slice was outright quarantined.

The fix had two parts. Immediately: add both DKIM CNAMEs, wait the TTL, run initiate-verification --verification-type DKIM / DKIM2. Within an hour both showed Verified, a fresh test showed dkim=pass and dmarc=pass, and OTPs reached the inbox. The next week they pulled ACSEmailStatusUpdateOperational, found 6% Bounced with 5.1.1 (typos from a broken form) and 3% Suppressed, fixed signup validation, cleaned the list, and only then walked DMARC toward p=reject. Delivered rate went from ~60% to 98.5%. The lesson on the wall: “A ‘Succeeded’ send that doesn’t arrive is a DKIM question. Read the header before you blame the receiver.”

The incident as a timeline — the order of moves is the lesson:

Time Symptom Action taken Effect What it should have been
Day 0 OTPs missing for ~40% Re-send the OTP “Succeeds”, still missing Read the received header first
+1 day Still missing Blame Gmail’s filter No progress Don’t blame the receiver blind
+2 days Breakthrough Read Authentication-Results on a test dkim=none; dmarc=fail This was the diagnosis
+2 days Root cause ... --query verificationStates DKIM/DKIM2 NotVerified One missing record set
+2 days Fixed (auth) Add DKIM CNAMEs, verify dkim=pass; dmarc=pass; inbox The actual fix is DNS
+1 week Hardening KQL bounce audit; fix signup; tighten DMARC 60% → 98.5% delivered List hygiene + staged DMARC

Advantages and disadvantages

The managed-pipeline model both causes this class of problem and gives you the tools to diagnose it. Weigh it honestly:

Advantages (why this model helps you) Disadvantages (why it bites)
No SMTP server to run — send with one SDK call, scale handled for you “Succeeded” only means accepted; delivery truth arrives later and elsewhere
Azure-managed domains let you send a test mail in minutes Managed domains share reputation and can’t carry your brand — useless for prod
DKIM keys are hosted and rotated by ACS — you just publish two CNAMEs If you skip those CNAMEs, mail silently junks with no error on send
Delivery status + Azure Monitor logs give per-message and fleet-wide truth Logs are off by default; without them you’re diagnosing blind
Suppression list protects your reputation automatically It silently drops sends, surprising anyone who “just re-sends”
Standard SPF/DKIM/DMARC + SMTP bounce codes — portable knowledge DMARC alignment is subtle (an old p=reject buries day-one mail), and the real cause lives at the receiver, which ACS can’t fully see

The model is right for transactional mail — OTPs, receipts, resets, notifications — where you want to ship features, not run a mail server. It bites hardest on teams that miss a DNS record on migration, products with an unreconciled DMARC policy, and anyone who reads “Succeeded” as “delivered.” The disadvantages are all manageable — but only if you know the chain exists.

Hands-on lab

Verify a custom domain, then prove the deliverability chain by sending a test message and reading its authentication header — the single most useful skill here. You need a domain you control and an ACS resource. Run in Cloud Shell (Bash).

Step 1 — Variables and resource group.

RG=rg-acs-email-lab
LOC=global
ES=acs-email-lab
COMMS=acs-comms-lab
DOMAIN=mail.yourbrand.com     # a domain whose DNS you can edit
az group create -n $RG -l southindia -o table

Step 2 — Create the Email + Communication Services resources.

az communication email create -n $ES -g $RG -l $LOC --data-location unitedstates -o table
az communication create -n $COMMS -g $RG -l $LOC --data-location unitedstates -o table

Expected: two resources provisioned; note the id of each.

Step 3 — Add a custom domain and read the DNS records ACS wants.

az communication email domain create --domain-name $DOMAIN --email-service-name $ES \
  -g $RG -l $LOC --domain-management CustomerManaged -o table
az communication email domain show --domain-name $DOMAIN --email-service-name $ES \
  -g $RG --query "verificationRecords" -o json

Expected: a JSON block listing the Domain TXT, SPF TXT and two DKIM CNAMEs — the exact values to add to DNS.

Step 4 — Add the records in your DNS zone, then verify. Add each record from Step 3, wait out the TTL, then:

for T in Domain SPF DKIM DKIM2; do
  az communication email domain initiate-verification --domain-name $DOMAIN \
    --email-service-name $ES -g $RG --verification-type $T
done
az communication email domain show --domain-name $DOMAIN --email-service-name $ES \
  -g $RG --query "verificationStates" -o json

Expected: all four states read Verified. If DKIM is NotVerified, your CNAME hasn’t propagated — wait and re-run (this is the exact bug from the scenario).

Step 5 — Confirm the records resolve from public DNS (independent of ACS):

dig +short TXT $DOMAIN | grep spf1
dig +short CNAME selector1-azurecomm-prod-net._domainkey.$DOMAIN
dig +short CNAME selector2-azurecomm-prod-net._domainkey.$DOMAIN
dig +short TXT _dmarc.$DOMAIN     # add a p=none DMARC record first

Expected: an SPF line with the ACS include, two CNAMEs resolving to azurecomm-prod-net targets, and your DMARC policy.

Step 6 — Connect the domain, add a sender, and send a test.

az communication email domain sender-username create --domain-name $DOMAIN \
  --email-service-name $ES -g $RG --sender-username no-reply \
  --username no-reply --display-name "Lab no-reply"
# Connect $DOMAIN to $COMMS (portal: Communication Services → Email → Connect domain),
# grab the connection string, then send to an address you own (Gmail is ideal):
az communication email send-status ... # via SDK/REST using the connection string

Step 7 — Read the Authentication-Results header (the payoff). In the received Gmail message: ⋮ → Show original. Read the line:

Authentication-Results: ... dkim=pass ... spf=pass ... dmarc=pass ...

Expected: all three pass. If dkim=none, your CNAMEs aren’t right; if dmarc=fail with dkim=pass, it’s an alignment problem.

Validation checklist. You verified a custom domain, confirmed each DNS record from both ACS (Step 4) and public DNS (Step 5), sent a real message, and read the receiver’s verdict in the header (Step 7) — the whole chain end to end. That header read is the 5-minute diagnosis you’ll reach for in every real incident.

Cleanup (avoid lingering charges).

az group delete -n $RG --yes --no-wait

ACS Email has no per-resource hourly charge (you pay per message and per MB), so the main thing to remove is the domain records from your DNS zone if you don’t keep the domain.

Common mistakes & troubleshooting

This is the playbook — the part you bookmark. First as a scannable table you read when the “I didn’t get the email” tickets land, then the same entries with full confirm-command detail underneath. It spans the basic failures (a missing DNS record) and the advanced ones (DMARC alignment, reputation, suppression).

# Symptom Root cause Confirm (exact cmd / portal path) Fix
1 Mail lands in Junk; send “Succeeded” DKIM not configured (CNAMEs missing) dig CNAME selector1-azurecomm-prod-net._domainkey.<domain> empty; header dkim=none Add both DKIM CNAMEs; initiate-verification DKIM/DKIM2
2 Send fails 403 DomainNotLinked Domain not verified or not connected to the ACS resource az communication email domain show --query verificationStates; check the linked domain Verify all records; connect the domain to the Communication Services resource
3 Mail quarantined/rejected at receiver DMARC p=quarantine/reject with nothing aligned dig TXT _dmarc.<domain>; header dmarc=fail Fix DKIM alignment first, then keep/raise policy
4 spf=pass but dmarc=fail MailFrom is an ACS host; SPF aligns to that, not your From; DKIM not aligning Read smtp.mailfrom= vs header.from= in the header Configure DKIM so DMARC aligns via the signature
5 Some recipients never get it; no bounce visible Recipients on the suppression list (earlier hard bounce) az communication email domain suppression-list list; Monitor DeliveryStatus == Suppressed Confirm address is valid; remove from suppression list deliberately
6 Mail bounces 550 5.1.1 Recipient address doesn’t exist Monitor ACSEmailStatusUpdateOperational DeliveryStatus == Bounced, code 5.1.1 Remove the address; fix data-entry validation
7 Bounces 5.7.26 / 5.7.1 Receiver rejected on authentication/reputation Header / deliveryStatusDetails; check SPF/DKIM/DMARC all pass Fix the failing auth record; check blocklists
8 Domain won’t verify in ACS Records added but DNS hasn’t propagated (or wrong host/typo) dig the exact record; compare to verificationRecords value Match the host/value exactly; wait the TTL; re-initiate
9 SPF ignored / permerror; intermittent spam A second v=spf1 record, or more than 10 DNS lookups in the chain dig TXT <domain> shows two v=spf1 lines; or count the lookups Merge into ONE record; flatten includes under the 10-lookup cap
10 Production mail from *.azurecomm.net looks like spam Shipped on an Azure-managed domain The From address is *.azurecomm.net Migrate to a verified custom domain
11 New custom domain mail junked despite passing auth No sender reputation yet (cold domain) Low/blank reputation; junked at high volume from day 1 Warm up: ramp volume gradually; keep complaints low
12 Sends start failing 429 under load Exceeded the per-domain send-rate limit 429 TooManyRequests with Retry-After Back off + retry; request a higher quota via support
13 DKIM was working, now dkim=fail intermittently Body modified in transit, or one selector removed Header dkim=fail; confirm both selector CNAMEs still resolve Keep both selectors published; don’t let a relay rewrite the body
14 Gmail/Yahoo bulk mail bounces with policy errors Missing DMARC / unsubscribe / high complaint rate Header dmarc= absent; complaint rate high Publish p=none DMARC min; add one-click unsubscribe; cut complaints
15 Logs empty in Log Analytics; can’t audit delivery Diagnostic setting not routing ACS Email categories az monitor diagnostic-settings list on the ACS resource Add the three Email*Operational categories to the workspace

The expanded form, with the full reasoning for the entries that bite hardest:

1. Mail lands in Junk while the send reports Succeeded. Root cause: DKIM is not configured — the two selector CNAMEs were never added (the classic post-migration miss). No signature → DMARC can’t align → receivers junk it. Confirm: dig +short CNAME selector1-azurecomm-prod-net._domainkey.<domain> returns nothing; a received message’s Authentication-Results shows dkim=none. Fix: Add both DKIM CNAMEs from verificationRecords, wait out the TTL, and run az communication email domain initiate-verification --verification-type DKIM and DKIM2.

2. Mail is quarantined or rejected at the receiver. Root cause: A DMARC policy of quarantine/reject is in effect while nothing aligns with the From — often an old policy the team set for a different sender. Confirm: dig +short TXT _dmarc.<domain> shows p=quarantine/reject; the header shows dmarc=fail. Fix: Make DKIM align first (Mistake 1), confirm dmarc=pass on a test, then keep or raise the policy. Never enforce before alignment works.

3. Some recipients never receive mail and there’s no obvious bounce. Root cause: Those addresses are on the suppression list from an earlier hard bounce or complaint; ACS silently drops the send (status Suppressed). Confirm: az communication email domain suppression-list list ...; or KQL ACSEmailStatusUpdateOperational | where DeliveryStatus == "Suppressed". Fix: Confirm the address is genuinely valid now, then remove it from the suppression list deliberately — don’t blanket-clear, or you’ll re-hammer dead addresses and hurt reputation.

The remaining rows cover the rest of the field — 403/DomainNotLinked, spf=pass yet dmarc=fail, the 5.1.1/5.7.x bounces, a domain that won’t verify, the SPF traps, cold-domain junking, 429 throttling, intermittent DKIM fail, the Gmail/Yahoo bulk bar, and empty Log Analytics tables. Across all of them the method never changes: read the header to localise the failing check, confirm with the one command in the table, fix the corresponding record or setting.

Best practices

The alerts worth wiring before the next incident — the leading indicators, not the lagging “tickets arrived”:

Alert on Signal (KQL source) Threshold (starting point) Why it’s leading
Bounce rate DeliveryStatus == "Bounced" ÷ total > 2% over 1h High bounces tank reputation fast
Suppression growth new Suppressed per hour sustained increase Signals a bad list or a broken form
Auth failures bounces with 5.7.26/5.7.1 any sustained count Direct sign SPF/DKIM/DMARC broke
Delivered-rate drop Delivered ÷ total < your baseline (e.g. 95%) Catches a reputation/auth regression early
429 rate send responses 429 any sustained You’re hitting the send-rate cap
DKIM resolution external dig of both selectors either returns empty Catches an accidental DNS deletion

Security notes

Cost & sizing

ACS Email’s billing is simple: no hourly resource charge, just per email sent plus per MB transferred, so cost scales with how much you send — predictable and usually small for transactional workloads:

A rough monthly picture for a small product: a few thousand to tens of thousands of messages costs tens to low hundreds of rupees, plus modest Log Analytics ingestion (~₹500–2,000). The expensive failure mode isn’t the bill — it’s a deliverability regression that halves your sign-up conversion. The cost drivers:

Cost driver What you pay for Rough INR / month (small product) What it affects Watch-out
Messages sent Per-email charge Tens–low hundreds Direct send volume Scales linearly with volume
Data transferred Per-MB of message size Small unless attachment-heavy Body + attachment size Attachments inflate this; link instead
Custom domain Nothing (DNS only) ₹0 (registrar/zone aside) Brand + reputation Time cost: setup + warm-up
Azure-managed domain Free to provision ₹0 Test sending Shared reputation; not for prod
Log Analytics ingestion Per-GB of diagnostic logs ~₹500–2,000 Deliverability visibility Retention + engagement-event volume
Reputation (intangible) List hygiene effort “Free” but real Inbox vs Junk placement Bounces/complaints cost conversions

Interview & exam questions

1. A send returns Succeeded but the email never arrived. What does Succeeded mean, and what do you check first? It means ACS accepted and dispatched the message, not that a mailbox received it — delivery, authentication and filtering all happen afterward at the receiver. Check first by reading the Authentication-Results header of a test send for SPF/DKIM/DMARC, then the delivery status in the ACSEmailStatusUpdateOperational logs.

2. Why is DKIM the most important record for ACS deliverability on a custom domain? Because DKIM is what makes DMARC align with your From: ACS signs with a d= equal to your domain (keys behind the two selector CNAMEs), so DKIM passes and aligns, whereas SPF often aligns only to the ACS MailFrom. Without DKIM, DMARC has nothing aligned and an enforcing policy junks or rejects your mail.

3. Explain SPF, DKIM and DMARC in one sentence each, and what each aligns on. SPF lists authorised sending IPs and aligns on the MailFrom; DKIM signs the message and aligns on the signature’s d= tag; DMARC requires SPF or DKIM to pass and align with the visible From and sets policy for failures. SPF authorises an IP, DKIM authorises a message, DMARC ties them to the From.

4. Difference between an Azure-managed domain and a custom domain, and when do you use each? Managed is an instant *.azurecomm.net subdomain with pre-set SPF/DKIM, shared reputation and low send limits — good for dev/test. Custom is your own, with auth records you publish, your brand in the From, and reputation you build — required for production. Prototype on managed; ship on custom.

5. A header shows spf=pass but dmarc=fail. What’s wrong and how do you fix it? SPF passed against the MailFrom, an ACS-owned host, so it aligns to that domain — not your From — and DKIM isn’t carrying alignment, so DMARC fails. Fix it by configuring DKIM so the signature’s d= matches your domain.

6. A recipient never gets mail and there’s no bounce. What’s likely, and how do you confirm? The address is likely on your suppression list from an earlier hard bounce or complaint, so ACS silently drops the send (Suppressed). Confirm via the suppression-list API or KQL (DeliveryStatus == "Suppressed"), and only remove it after confirming it’s valid.

7. What does a 5.7.26 bounce mean? A permanent rejection meaning the receiver failed your email authentication (SPF/DKIM/DMARC) — usually DKIM not aligning, or SPF not authorising the sender. Fix the failing record and re-test the header.

8. How do you roll out a DMARC policy without breaking your own mail? In stages: p=none with rua reporting first, confirm from the reports and a test header that ACS mail passes DMARC with aligned DKIM, then quarantine, finally reject. Enforcing before DKIM aligns quarantines your own legitimate mail.

9. Your bulk mail to Gmail and Yahoo started bouncing in 2024. What changed and what must you have? They introduced bulk-sender requirements: authenticated mail (SPF and DKIM), a DMARC record (at least p=none), a one-click List-Unsubscribe, and a complaint rate under their threshold. Missing any of these — or a high complaint rate — gets bulk mail rejected.

10. Where do you find delivery truth at scale, and how do you turn it on? In Log Analytics via the ACS Email diagnostic logs — EmailSendMailOperational, EmailStatusUpdateOperational (Delivered/Bounced/Suppressed) and EmailUserEngagementOperational. Turn them on with a diagnostic setting on the Communication Services resource routing those categories to a workspace, then query with KQL.

These map primarily to AZ-204 (Developer Associate)develop solutions that use Azure Communication Services, plus the messaging/monitoring and diagnostic-logs objectives. The deliverability/DNS knowledge (SPF/DKIM/DMARC records) is portable email-architecture skill that also touches AZ-700 (name resolution) and AZ-500 (DMARC as anti-spoofing and secure secret handling).

Quick check

  1. A send reports Succeeded but the email never reaches the inbox. What does Succeeded actually guarantee, and what’s the first free diagnostic you run?
  2. You added the domain-verification TXT and the SPF TXT, ACS shows them Verified, but mail still lands in Junk. What did you most likely forget, and how do you confirm it?
  3. A received message’s header shows dkim=pass but dmarc=fail. What kind of problem is this, in one word, and what’s the fix?
  4. A recipient never gets your mail and you see no bounce. Name the mechanism that’s silently dropping it and the command to confirm.
  5. Why can publishing a second v=spf1 TXT record break all your email?

Answers

  1. Succeeded only guarantees that ACS accepted and dispatched the message — not that any mailbox received it; delivery, authentication and filtering happen afterward at the receiver. The first free diagnostic is to send a test to an address you control and read the Authentication-Results header for SPF/DKIM/DMARC results.
  2. You most likely forgot the two DKIM selector CNAMEs (selector1-azurecomm-prod-net._domainkey and selector2-...) — domain and SPF can verify without them. Confirm with dig +short CNAME selector1-azurecomm-prod-net._domainkey.<domain> (empty = missing) and a header showing dkim=none.
  3. Alignment. DKIM is passing but its d= domain doesn’t align with the visible From, so DMARC fails — configure DKIM so it signs with your domain (or correct the From) so DMARC aligns via the signature.
  4. The suppression list — ACS silently drops sends to addresses that previously hard-bounced or complained (delivery status Suppressed). Confirm with az communication email domain suppression-list list ... or KQL where DeliveryStatus == "Suppressed".
  5. SPF permits exactly one v=spf1 record per domain; a second one is a permerror that makes receivers ignore SPF entirely. Merge all senders into a single SPF record with multiple include: mechanisms.

Glossary

Next steps

You can now localise any “email didn’t arrive” report to a link in the chain and fix it. Build outward:

AzureCommunication ServicesEmailDeliverabilityDKIMDMARCSPFTroubleshooting
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading