Email with Azure Communication Services

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Commands on this page target the preview communication Azure CLI extension (auto-installs on first use, requires Azure CLI 2.67.0 or later; this page’s examples are re-verified against Azure CLI 2.90.0, checked 2026-09-27) — re-verify flag names against az communication --help before relying on them in a pipeline, since a preview extension’s surface can change between releases faster than GA command groups do.

Bookshelf’s notifier worker (the Container Apps job introduced on Container Apps: Scaling, Jobs and Dapr) sends order-confirmation emails once orders-api publishes an order. This page covers the Azure resource that makes that possible — an Email Communication Service, acs-bookshelf, linked to a Communication Services resource, cs-bookshelf — domain verification, sending from the CLI, from the Email SDK, and over SMTP, delivery/engagement tracking, quotas, the outbound port 25 block every subscription hits eventually, SendGrid as a Marketplace alternative, and deliverability basics.

The resource model

Sending email through Azure Communication Services always involves two ARM resources plus one channel identity:

Email Communication Service (Microsoft.Communication/emailServices)

The resource that owns one or more domains. It has no data-plane endpoint of its own — it exists purely to hold domain configuration (verification records, sender usernames, engagement-tracking settings) independently of any particular Communication Services resource.

Domain (Microsoft.Communication/emailServices/domains)

A child resource of the Email Communication Service, one of two kinds:

  • An Azure-managed domain (--domain-management AzureManaged, domain name literally AzureManagedDomain) — a subdomain of *.azurecomm.net that Azure provisions and verifies automatically, with no DNS work on the caller’s side. It sends immediately, but its sender address can’t be personalized beyond the donotreply@ local part, its sending volume is capped well below a custom domain’s ([Quotas and limits]), and it can’t enable user-engagement tracking.

  • A custom domain (--domain-management CustomerManaged, e.g. bookshelf.example) — a domain the caller owns, proved via a Domain TXT ownership record and then authenticated with SPF (a TXT record) and two DKIM CNAME records (DKIM and DKIM2, Azure’s two rotating signing selectors); DMARC alignment is the caller’s own DNS record layered on top of SPF/DKIM, not a record Azure issues ([Deliverability basics]). A custom domain sends from an address the caller controls and can raise its default quota ([Quotas and limits]).

Communication Services resource (Microsoft.Communication/communicationServices)

The resource applications and the CLI actually address to send — it exposes the data-plane endpoint (https://cs-bookshelf.<region>; .communication.azure.com) and the connection string/access keys. A Communication Services resource has no domains of its own; one or more Email Communication Service domains are linked to it, and only a linked domain’s sender addresses can be used from that Communication Services resource.

Sender username (MailFrom)

The local part of the From address for a given domain — DoNotReply by default, with additional usernames addable per domain (az communication email domain sender-username create) up to 100 per domain ([Quotas and limits]). Bookshelf sends order confirmations from DoNotReply@<managed-domain> on the Azure-managed domain in the examples below; switching to a custom domain later needs no application-side change beyond the sender address string.

Both the Email Communication Service and the Communication Services resource are created with --location Global — Communication Services is a data-residency-based, not region-based, service — and a separate --data-location parameter (e.g. United States, Europe) picks which geography actually stores the data; this is a different axis from the $LOCATION (eastus2) used by every other Bookshelf resource on this site, and worth not confusing with it.

flowchart LR ECS["Email Communication Service\nacs-bookshelf"] -->|owns| DOM1["Domain: AzureManagedDomain\n(*.azurecomm.net)"] ECS -->|owns| DOM2["Domain: bookshelf.example\n(custom, SPF/DKIM/DKIM2/DMARC)"] DOM1 -->|linked| CS["Communication Services resource\ncs-bookshelf"] DOM2 -.->|linked, optional| CS CS -->|data-plane endpoint +\nconnection string| APP["notifier (Container Apps job)"]

In the Azure portal

  1. Search for Email Communication Services → Create: $RG, name acs-bookshelf, Data Location (e.g. United States).

  2. Once created, open acs-bookshelf → Provision domains → Add domain → Azure Managed Domain for the no-DNS path (see [Domain verification] for the custom-domain path from the same blade).

  3. Search for Communication Services → Create: $RG, name cs-bookshelf, Data Location matching the Email Communication Service above.

  4. Open cs-bookshelf → Email → Domains → Connect domain, and pick the Email Communication Service (acs-bookshelf) and the domain to link.

With the Azure CLI

export RG=rg-bookshelf
export LOCATION=eastus2

az group create --name "$RG" --location "$LOCATION"

az extension add --name communication

# Email Communication Service -- owns domains
az communication email create \
  --name acs-bookshelf \
  --resource-group "$RG" \
  --location Global \
  --data-location "United States"

# Azure-managed domain -- no DNS work, sends immediately
az communication email domain create \
  --domain-name AzureManagedDomain \
  --email-service-name acs-bookshelf \
  --resource-group "$RG" \
  --location Global \
  --domain-management AzureManaged

# Communication Services resource -- the data-plane endpoint applications send through
az communication create \
  --name cs-bookshelf \
  --resource-group "$RG" \
  --location Global \
  --data-location "United States"

# Link the Azure-managed domain to the Communication Services resource
MANAGED_DOMAIN=$(az communication email domain list \
  --email-service-name acs-bookshelf \
  --resource-group "$RG" \
  --query "[0].name" -o tsv)

az communication update \
  --name cs-bookshelf \
  --resource-group "$RG" \
  --set "linkedDomains=[\"$(az communication email domain show --domain-name "$MANAGED_DOMAIN" --email-service-name acs-bookshelf --resource-group "$RG" --query id -o tsv)\"]"

[[Domain verification]] == Domain verification

An Azure-managed domain needs no verification step — it is created already verified. A custom domain moves through three verification passes, each triggered with az communication email domain initiate-verification --verification-type <type> (Domain, SPF, DKIM, DKIM2, DMARC) after the matching DNS record is in place; DNS propagation typically takes 15-30 minutes before a verification attempt succeeds.

Verification type Record Purpose

Domain

TXT

Proves ownership of the domain before anything else is accepted.

SPF

TXT (v=spf1 include:spf.protection.outlook.com -all)

Authorizes Azure Communication Services' sending infrastructure to send as this domain.

DKIM

CNAME (selector1-azurecomm-prod-net._domainkey)

The first of two rotating DKIM signing keys Azure Communication Services cryptographically signs outgoing mail with.

DKIM2

CNAME (selector2-azurecomm-prod-net._domainkey)

The second rotating DKIM selector, kept in place alongside DKIM so key rotation never leaves a gap.

DMARC

TXT (published on the caller’s own DNS, not issued by Azure)

Declares how a receiving mail server should treat a message that fails SPF/DKIM alignment ([Deliverability basics]); Azure verifies the record exists but the policy itself is the domain owner’s choice.

In the Azure portal

  1. Open acs-bookshelf → Provision domains → Add domain → Custom domain; enter the domain name twice to confirm, then Add.

  2. Verify Domain: copy the generated TXT record into the domain’s DNS zone, wait for propagation, then Done.

  3. Once Domain Status reads Verified, Configure under the same domain adds the SPF TXT record and the two DKIM CNAME records; add them to DNS, then Done.

  4. Optionally enable User Engagement Tracking on the domain (needed for the engagement events in [Delivery status and engagement tracking]) — disabled by default and unavailable on Azure-managed domains.

With the Azure CLI

az communication email domain create \
  --domain-name bookshelf.example \
  --email-service-name acs-bookshelf \
  --resource-group "$RG" \
  --location Global \
  --domain-management CustomerManaged

# after adding the generated TXT record to DNS and waiting for propagation
az communication email domain initiate-verification \
  --domain-name bookshelf.example \
  --email-service-name acs-bookshelf \
  --resource-group "$RG" \
  --verification-type Domain

# after adding the SPF TXT and both DKIM CNAME records
az communication email domain initiate-verification \
  --domain-name bookshelf.example --email-service-name acs-bookshelf --resource-group "$RG" --verification-type SPF
az communication email domain initiate-verification \
  --domain-name bookshelf.example --email-service-name acs-bookshelf --resource-group "$RG" --verification-type DKIM
az communication email domain initiate-verification \
  --domain-name bookshelf.example --email-service-name acs-bookshelf --resource-group "$RG" --verification-type DKIM2

az communication email domain show \
  --domain-name bookshelf.example --email-service-name acs-bookshelf --resource-group "$RG" \
  --query verificationStates

Sending email

With the Azure CLI

az communication email send is a synchronous convenience command that submits the message and polls az communication email status get internally until the message reaches a final state:

CONNECTION_STRING=$(az communication list-key \
  --name cs-bookshelf --resource-group "$RG" --query primaryConnectionString -o tsv)

az communication email send \
  --connection-string "$CONNECTION_STRING" \
  --sender "DoNotReply@<managed-domain>" \
  --to "reader@example.com" \
  --subject "Your Bookshelf order is confirmed" \
  --text "Thanks for your order -- it ships within 2 business days."

# Check status of a message sent earlier by its message ID
az communication email status get \
  --connection-string "$CONNECTION_STRING" \
  --message-id "<message-id>"

A connection string is the only way the communication CLI extension itself authenticates — there is no az login-based option for these two commands — so treat $CONNECTION_STRING the way any other credential on this site is treated: read it from Key Vault (Key Vault and Secrets) for a real pipeline, never hard-code it in a script or commit it to source control. Application code has the better option below.

From application code

The Email SDK authenticates with DefaultAzureCredential against the Communication Services resource’s endpoint, exactly like every other Bookshelf service on this site (Identity and Access) — notifier’s managed identity needs no stored connection string at all, only a role assignment (`Contributor or the narrower built-in Communication and Email Service Owner role) scoped to cs-bookshelf:

package com.example.bookshelf.notifier;

import com.azure.communication.email.EmailClient;
import com.azure.communication.email.EmailClientBuilder;
import com.azure.communication.email.models.EmailMessage;
import com.azure.communication.email.models.EmailSendResult;
import com.azure.core.util.polling.SyncPoller;
import com.azure.identity.DefaultAzureCredentialBuilder;

public class OrderConfirmationSender {

    private final EmailClient emailClient = new EmailClientBuilder()
        .endpoint("https://cs-bookshelf.unitedstates.communication.azure.com/")
        .credential(new DefaultAzureCredentialBuilder().build())
        .buildClient();

    public void sendOrderConfirmation(String recipient, String orderId) {
        EmailMessage message = new EmailMessage()
            .setSenderAddress("DoNotReply@<managed-domain>")
            .setToRecipients(recipient)
            .setSubject("Your Bookshelf order " + orderId + " is confirmed")
            .setBodyPlainText("Thanks for your order -- it ships within 2 business days.");

        SyncPoller<EmailSendResult, EmailSendResult> poller = emailClient.beginSend(message);
        poller.waitForCompletion();
    }
}

beginSend returns a long-running-operation poller rather than blocking synchronously, so notifier (running as a Container Apps job, per Container Apps: Scaling, Jobs and Dapr) can fire the send and either wait on the poller inline for a short job or check status on a later invocation via emailClient.getSendStatus(operationId). The .NET and Python Email SDKs expose the same EmailClient/DefaultAzureCredential shape and are the better starting point than Java only when the rest of the calling service is already .NET or Python (Bookshelf’s own services are Spring Boot, so Java is the default here).

[[SMTP relay]] == SMTP relay

Where a library or platform only speaks SMTP — an existing JavaMailSender bean, a legacy on-call alerting tool, a CMS plugin — Azure Communication Services also exposes an authenticated SMTP relay at smtp.azurecomm.net:587, STARTTLS, rather than requiring every caller to adopt the Email SDK. Authentication is still Microsoft Entra ID, not a shared SMTP password: a Microsoft Entra application is registered, granted the Communication and Email Service Owner role (or Contributor) on cs-bookshelf, and its client secret becomes the SMTP password. The SMTP username is not the app’s client ID alone — it is the three-part string <communication-services-resource-name>.<entra-app-id>.<entra-tenant-id> (e.g. cs-bookshelf.11111111-1111-1111-1111-111111111111.22222222-2222-2222-2222-222222222222), which is how the relay knows which Communication Services resource and which Entra tenant to authenticate the connection against.

spring:
  mail:
    host: smtp.azurecomm.net
    port: 587
    username: cs-bookshelf.${ENTRA_APP_ID}.${ENTRA_TENANT_ID}
    password: ${ACS_SMTP_PASSWORD}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true

With spring-boot-starter-mail on the classpath, this configuration is all Spring Boot needs to autowire a JavaMailSender — no code change from the plain-SMTP MailSender/JavaMailSender setup already shown on Configuration and Profiles, just a different host/username shape and ${ACS_SMTP_PASSWORD} sourced from Key Vault rather than committed to a properties file. The Quarkus Mailer extension configures the same relay through quarkus.mailer.* properties exactly as Scheduling and Mail describes for any other SMTP provider, pointed at smtp.azurecomm.net:587 with start-tls=REQUIRED and this same three-part username.

[[Delivery status and engagement tracking]] == Delivery status and engagement tracking via Event Grid

Rather than polling az communication email status get or the SDK’s getSendStatus, Azure Communication Services publishes delivery outcomes — and, on a domain with User Engagement Tracking enabled, opens and link clicks — directly to Event Grid:

Microsoft.Communication.EmailDeliveryReportReceived

Fired once a message reaches a final delivery state (Delivered, Bounced, Suppressed, Failed, Quarantined, FilteredSpam), carrying the message ID, the final status, and, for a bounce, a per-recipient failure reason.

Microsoft.Communication.EmailEngagementTrackingReportReceived

Fired when a recipient opens the message or clicks a tracked link, for a domain with engagement tracking enabled — the mechanism that made --user-engmnt-tracking Enabled/Disabled on az communication email domain update relevant above.

az eventgrid event-subscription create \
  --name acs-bookshelf-email-events \
  --source-resource-id "$(az communication show --name cs-bookshelf --resource-group "$RG" --query id -o tsv)" \
  --endpoint "$(az functionapp function show --name notifier --resource-group "$RG" --function-name EmailStatusHandler --query invokeUrlTemplate -o tsv)" \
  --included-event-types Microsoft.Communication.EmailDeliveryReportReceived Microsoft.Communication.EmailEngagementTrackingReportReceived

Subscribing `notifier’s own status handler this way lets Bookshelf record a bounce against the customer’s order without adding a poll loop anywhere in the request path.

sequenceDiagram participant App as notifier (Container Apps job) participant ACS as Communication Services (cs-bookshelf) participant Recip as Recipient mailbox participant EG as Event Grid participant Handler as notifier status handler App->>ACS: beginSend(EmailMessage) via DefaultAzureCredential ACS-->>App: operation id (poller) ACS->>Recip: Deliver message (SPF/DKIM/DMARC checked by recipient MTA) Recip-->>ACS: Accept / bounce / mark as spam ACS->>EG: Microsoft.Communication.EmailDeliveryReportReceived EG->>Handler: Push delivery event Recip->>ACS: Open message / click tracked link (engagement tracking on) ACS->>EG: Microsoft.Communication.EmailEngagementTrackingReportReceived EG->>Handler: Push engagement event

[[Quotas and limits]] == Quotas and limits

Every subscription starts with conservative send rate limits and grows them only on request, since Azure Communication Services shares outbound IP reputation across every tenant using the service:

Operation Domain type Per minute Per hour

Send email

Azure-managed domain

5

10

Send email

Custom domain

30

100

Get email status

Azure-managed domain

10

20

Get email status

Custom domain

60

200

Azure-managed domains cannot request a higher limit at all; a verified custom domain can, and Azure Communication Services documents supporting up to 1-2 million messages per hour for accounts that earn it. A handful of other size/resource limits apply regardless of domain type: at most 50 recipients across to/cc/bcc per message (more needs a support request), a 10 MB cap on the total request size including attachments (roughly 7.5 MB of actual attachment content once Base64’s ~33% overhead is accounted for; larger files are better sent as a Blob Storage link with a SAS, per Blob Storage), at most 100 sender usernames per domain, and at most 100 domains linked to one Communication Services resource.

Requesting a quota increase is a standard Azure support request (Service and Subscription Limits (Quotas) as the issue type), evaluated on sender reputation rather than granted automatically: Microsoft looks at the domain’s failure rate (bounces, complaints, blocks), and asks for it to stay under 1% before approving a higher limit — requests typically take up to 72 hours, and Microsoft’s own guidance is to ramp volume up gradually over two to four weeks on a new domain rather than starting at, or requesting, a high limit on day one, so that third-party mail providers have time to build trust in the domain’s new sending IPs.

[[The outbound port 25 block]] == The outbound port 25 block

Azure blocks outbound TCP port 25 from most compute services — virtual machines, VM Scale Sets, App Service, Container Apps, AKS nodes — on most subscription types, to protect the shared IP reputation of Azure’s own address space from being used for unauthenticated spam relaying; there is no per-resource opt-out, and the block applies independent of any NSG rule the caller configures on their own subnet. This is precisely why Azure Communication Services (both the Email SDK and the SMTP relay above) and third-party relays such as SendGrid exist as the recommended path: they listen on port 587 with mandatory authenticated SMTP (STARTTLS plus a credential, never anonymous relay), which Azure does not block on any subscription type, and they additionally carry their own established sending reputation rather than an ordinary VM’s arbitrary outbound IP. An application or script that tries to talk directly to a recipient’s port 25 mail server from an Azure compute resource — rather than through an authenticated relay like ACS or SendGrid — fails outright rather than degrading, which is the most common report behind "email works locally but not from our VM."

SendGrid via Azure Marketplace as an alternative

Twilio SendGrid is offered through Azure Marketplace as a SaaS listing (the same Marketplace SaaS billing mechanism described for Elastic Cloud and the partner databases on Redis and Partner Databases on Azure): subscribing creates a SendGrid account tied to the Azure subscription and billed on the regular Azure invoice, while SendGrid itself operates the sending infrastructure, API, and its own SMTP relay entirely outside Azure Communication Services. It is worth considering instead of Azure Communication Services Email when a team already standardizes on SendGrid’s templates, marketing-email features, or existing integrations, or is migrating an app that already speaks SendGrid’s API; Azure Communication Services remains the more integrated choice for a Bookshelf-style application that also uses Communication Services' SMS/chat/calling channels or wants delivery/engagement events to land on the same Event Grid topic as everything else on Identity and Access's managed identities.

[[Deliverability basics]] == Deliverability basics

SPF, DKIM, and DMARC together are what lets a receiving mail server decide whether a message claiming to be from bookshelf.example actually came from an authorized sender:

  • SPF lists which sending infrastructure (Azure Communication Services' own, once the SPF TXT record above is in place) is authorized to send as the domain in the Return-Path/envelope-from address.

  • DKIM cryptographically signs each outgoing message with a private key only Azure Communication Services holds; the two CNAME records above let a receiver fetch the matching public key and verify the signature wasn’t tampered with in transit.

  • DMARC is the policy layered on top: it declares what a receiver should do (none, quarantine, reject) when a message fails alignment — SPF’s envelope-from domain or DKIM’s signing domain not matching the visible From: domain — and where to send aggregate/failure reports. Azure Communication Services verifies that a DMARC record exists on the domain but does not generate or manage its policy value; that record is authored and owned the same way any other DNS record for the domain is.

Alongside authentication, Azure Communication Services maintains a per-domain suppression list — addresses that hard-bounced, marked a prior message as spam, or were added manually — and automatically skips sending to any address already on it, which protects the domain’s own reputation from repeatedly hammering a dead or complaining address. The list is inspectable and manageable through the management SDKs (there is no dedicated az communication command for it as of this writing); an address can be removed once whatever caused the bounce/complaint is understood to be resolved.

Troubleshooting

  • A custom domain’s SPF/DKIM show "Not configured" despite correct-looking DNS — almost always a record added at the wrong zone level (root vs. subdomain, see the zone-level examples in [Domain verification]) or verification attempted before DNS has actually propagated; re-run az communication email domain initiate-verification after confirming the record resolves publicly (dig TXT/dig CNAME from outside the corporate network).

  • az communication email send returns 403/Unauthorized — the connection string is for the wrong Communication Services resource, or the sender address’s domain isn’t linked to cs-bookshelf yet (The resource model).

  • Messages accepted by Azure Communication Services never arrive — check the domain’s suppression list first ([Deliverability basics]), then the recipient-side spam folder; a Microsoft.Communication. EmailDeliveryReportReceived event with status FilteredSpam or Quarantined ([Delivery status and engagement tracking]) confirms it left Azure successfully and was judged suspicious downstream, which points at DMARC alignment or sender reputation rather than at Azure Communication Services itself.

  • SMTP relay authentication fails with a generic 535 — double-check the three-part username (<communication-services-resource>.<entra-app-id>.<entra-tenant-id>) against the exact Communication Services resource name and that the Entra app’s client secret (not its client ID) is the password, and that the app actually holds Communication and Email Service Owner (or Contributor) on that resource.

Clean up

az group delete --name "$RG" --yes --no-wait

Deleting the resource group removes acs-bookshelf, cs-bookshelf, their domains, and any Event Grid subscriptions on them; there is no soft-delete/purge step for Communication Services resources, unlike Key Vault or API Management.