|
87 | 87 | - name: Plan Shared Environment Resources |
88 | 88 | run: bash ./cloud-infrastructure/environment/deploy-environment.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.shared_location }} ${{ inputs.production_service_principal_object_id }} --plan |
89 | 89 |
|
| 90 | + - name: Detect Email Custom Domain Verification |
| 91 | + if: ${{ inputs.domain_name != '' && inputs.domain_name != '-' }} |
| 92 | + run: | |
| 93 | + # Auto-detect whether the email custom domain (eTLD+1 of domain_name) is fully verified in |
| 94 | + # Azure Communication Services. If yes, export USE_CUSTOM_EMAIL_DOMAIN=true to $GITHUB_ENV |
| 95 | + # so the next step (Plan Cluster Resources) inherits it; the bicepparam reads the env var |
| 96 | + # and the Bicep links the CustomerManaged domain + flips SENDER_EMAIL_ADDRESS to no-reply@<apex>. |
| 97 | + # When not verified (or when the resource does not exist yet on the very first deploy), the |
| 98 | + # env var stays unset, deploy-cluster.sh defaults it to false, the link is skipped, and mail |
| 99 | + # keeps flowing on the AzureManaged sender. This means there is no operator toggle to flip: |
| 100 | + # add DNS, verification completes, the next deploy auto-flips the sender. |
| 101 | + CLUSTER_RESOURCE_GROUP_NAME="${{ inputs.unique_prefix }}-${{ inputs.azure_environment }}-${{ inputs.cluster_location_acronym }}" |
| 102 | + email_domain_name=$(echo "${{ inputs.domain_name }}" | awk -F. '{ if (NF >= 2) print $(NF-1)"."$NF; else print $0 }') |
| 103 | +
|
| 104 | + az extension add --name communication --allow-preview true --only-show-errors 2>/dev/null || true |
| 105 | + email_domain_details=$(az communication email domain show --name "$email_domain_name" --email-service-name $CLUSTER_RESOURCE_GROUP_NAME --resource-group $CLUSTER_RESOURCE_GROUP_NAME -o json 2>/dev/null || echo "") |
| 106 | +
|
| 107 | + if [[ -n "$email_domain_details" ]] && echo "$email_domain_details" | jq empty 2>/dev/null; then |
| 108 | + domain_status=$(echo "$email_domain_details" | jq -r '.verificationStates.Domain.status // "NotStarted"') |
| 109 | + spf_status=$(echo "$email_domain_details" | jq -r '.verificationStates.SPF.status // "NotStarted"') |
| 110 | + dkim_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM.status // "NotStarted"') |
| 111 | + dkim2_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM2.status // "NotStarted"') |
| 112 | +
|
| 113 | + if [[ "$domain_status" == "Verified" ]] && [[ "$spf_status" == "Verified" ]] && [[ "$dkim_status" == "Verified" ]] && [[ "$dkim2_status" == "Verified" ]]; then |
| 114 | + echo "USE_CUSTOM_EMAIL_DOMAIN=true" >> $GITHUB_ENV |
| 115 | + echo "$(date +"%Y-%m-%dT%H:%M:%S") Email custom domain '$email_domain_name' is verified - sender will be no-reply@$email_domain_name." |
| 116 | + fi |
| 117 | + fi |
| 118 | +
|
90 | 119 | - name: Plan Cluster Resources |
91 | 120 | id: deploy_cluster |
92 | 121 | env: |
@@ -142,6 +171,81 @@ jobs: |
142 | 171 | echo "- A CNAME record with the Host name '${{ inputs.back_office_domain_name }}' that points to address 'back-office.$default_domain'." |
143 | 172 | fi |
144 | 173 | fi |
| 174 | +
|
| 175 | + # Email custom domain DNS. Derived from domain_name as its eTLD+1 (apex) - cluster ingress |
| 176 | + # is typically a CNAME and DNS forbids TXT/other records at the same name as a CNAME |
| 177 | + # (RFC 1034); the apex of a domain cannot be a CNAME, so SPF/DKIM records work there. The |
| 178 | + # awk derivation takes the last two parts of the host (correct for .net, .com, .io; wrong |
| 179 | + # for multi-part public suffixes like .co.uk - revisit if that ever applies). |
| 180 | + # Skip silently when the resource does not exist yet, report "configured correctly" once |
| 181 | + # fully Verified, and only print the records (and kick verification, idempotent) while |
| 182 | + # there is real work to do. The communication extension is in preview and emits a stderr |
| 183 | + # banner on every call, so we discard stderr and validate captured stdout is JSON. |
| 184 | + email_domain_name=$(echo "${{ inputs.domain_name }}" | awk -F. '{ if (NF >= 2) print $(NF-1)"."$NF; else print $0 }') |
| 185 | + az extension add --name communication --allow-preview true --only-show-errors 2>/dev/null || true |
| 186 | + email_domain_details=$(az communication email domain show --name "$email_domain_name" --email-service-name $CLUSTER_RESOURCE_GROUP_NAME --resource-group $CLUSTER_RESOURCE_GROUP_NAME -o json 2>/dev/null || echo "") |
| 187 | +
|
| 188 | + if [[ -n "$email_domain_details" ]] && echo "$email_domain_details" | jq empty 2>/dev/null; then |
| 189 | + domain_status=$(echo "$email_domain_details" | jq -r '.verificationStates.Domain.status // "NotStarted"') |
| 190 | + spf_status=$(echo "$email_domain_details" | jq -r '.verificationStates.SPF.status // "NotStarted"') |
| 191 | + dkim_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM.status // "NotStarted"') |
| 192 | + dkim2_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM2.status // "NotStarted"') |
| 193 | +
|
| 194 | + if [[ "$domain_status" == "Verified" ]] && [[ "$spf_status" == "Verified" ]] && [[ "$dkim_status" == "Verified" ]] && [[ "$dkim2_status" == "Verified" ]]; then |
| 195 | + echo "$(date +"%Y-%m-%dT%H:%M:%S") Email custom domain '$email_domain_name' is already configured correctly. Sender 'no-reply@$email_domain_name' is active." |
| 196 | + else |
| 197 | + # Verification does not auto-start when DNS records appear; kick it on every relevant |
| 198 | + # type. Idempotent. Domain ownership must be kicked too - without this call, Azure |
| 199 | + # leaves it in NotStarted forever even after the ms-domain-verification TXT is in DNS. |
| 200 | + for verification_type in Domain SPF DKIM DKIM2; do |
| 201 | + current_status=$(echo "$email_domain_details" | jq -r --arg t "$verification_type" '.verificationStates[$t].status // "NotStarted"') |
| 202 | + if [[ "$current_status" == "NotStarted" ]] || [[ "$current_status" == "VerificationFailed" ]] || [[ "$current_status" == "CancellationRequested" ]]; then |
| 203 | + az communication email domain initiate-verification --domain-name "$email_domain_name" --email-service-name $CLUSTER_RESOURCE_GROUP_NAME --resource-group $CLUSTER_RESOURCE_GROUP_NAME --verification-type "$verification_type" --only-show-errors 1>/dev/null 2>&1 || true |
| 204 | + fi |
| 205 | + done |
| 206 | +
|
| 207 | + # Red ANSI on the headline so the records jump out in the log; the records themselves |
| 208 | + # stay plain so they are easy to copy. The final ::error:: directive is a GitHub Actions |
| 209 | + # workflow command - it surfaces a red annotation in the workflow summary and on any PR |
| 210 | + # check, not only inline. |
| 211 | + # Azure returns absolute names for apex records (Domain TXT, SPF TXT) and relative names |
| 212 | + # for subdomain records (DKIM CNAMEs). DNS UIs (Microsoft 365, Azure DNS, Cloudflare, |
| 213 | + # Route 53, ...) want "@" as shorthand for the apex - typing the literal full domain |
| 214 | + # often causes the panel to append the zone again (e.g., "platformplatform.net" becomes |
| 215 | + # "platformplatform.net.platformplatform.net"). Translate apex hits to "@" so the |
| 216 | + # operator can paste verbatim into M365's DNS panel. |
| 217 | + normalize_name() { |
| 218 | + if [[ "$1" == "$email_domain_name" ]]; then echo "@"; else echo "$1"; fi |
| 219 | + } |
| 220 | +
|
| 221 | + echo -e "$(date +"%Y-%m-%dT%H:%M:%S") \033[0;31mPlease add the following DNS entries for the email custom domain and then retry:\033[0m" |
| 222 | + domain_record_name=$(echo "$email_domain_details" | jq -r '.verificationRecords.Domain.name // ""') |
| 223 | + domain_record_value=$(echo "$email_domain_details" | jq -r '.verificationRecords.Domain.value // ""') |
| 224 | + if [[ -n "$domain_record_name" ]]; then echo "- A TXT record with the name '$(normalize_name "$domain_record_name")' (apex of '$email_domain_name') and the value '$domain_record_value' (Domain ownership). This is a separate TXT record - it coexists alongside any existing SPF or other TXT records at the same name."; fi |
| 225 | + spf_record_name=$(echo "$email_domain_details" | jq -r '.verificationRecords.SPF.name // ""') |
| 226 | + spf_record_value=$(echo "$email_domain_details" | jq -r '.verificationRecords.SPF.value // ""') |
| 227 | + if [[ -n "$spf_record_name" ]]; then echo "- A TXT record with the name '$(normalize_name "$spf_record_name")' (apex of '$email_domain_name') and the value '$spf_record_value' (SPF). If a TXT record with this exact value already exists at this name (e.g., from Microsoft 365), no action is needed; if a different SPF value exists, merge the includes so only one SPF record remains."; fi |
| 228 | + dkim_record_name=$(echo "$email_domain_details" | jq -r '.verificationRecords.DKIM.name // ""') |
| 229 | + dkim_record_value=$(echo "$email_domain_details" | jq -r '.verificationRecords.DKIM.value // ""') |
| 230 | + if [[ -n "$dkim_record_name" ]]; then echo "- A CNAME record with the Host name '$dkim_record_name' that points to address '$dkim_record_value' (DKIM selector1)."; fi |
| 231 | + dkim2_record_name=$(echo "$email_domain_details" | jq -r '.verificationRecords.DKIM2.name // ""') |
| 232 | + dkim2_record_value=$(echo "$email_domain_details" | jq -r '.verificationRecords.DKIM2.value // ""') |
| 233 | + if [[ -n "$dkim2_record_name" ]]; then echo "- A CNAME record with the Host name '$dkim2_record_name' that points to address '$dkim2_record_value' (DKIM selector2)."; fi |
| 234 | +
|
| 235 | + # Fail the plan when the email custom domain exists but verification is incomplete. |
| 236 | + # Letting plan succeed sends the operator into a multi-cycle loop where the apply |
| 237 | + # eventually links the domain (once verified by a later run) but each interim deploy |
| 238 | + # silently runs without the desired sender. Surfacing the records and failing now is |
| 239 | + # cheaper than a 30-minute cycle each iteration. |
| 240 | + echo "::error::Email custom domain '$email_domain_name' verification is incomplete. Add the DNS records above and re-run this workflow." |
| 241 | + exit 1 |
| 242 | + fi |
| 243 | + else |
| 244 | + # Mirror the cluster-ingress style "checked but no work to confirm" hint so the operator |
| 245 | + # sees the email check ran. The customer-managed domain only exists after the first apply |
| 246 | + # with this domain configured; a follow-up workflow run will surface the records. |
| 247 | + echo "$(date +"%Y-%m-%dT%H:%M:%S") Email custom domain '$email_domain_name' is not yet provisioned in Azure Communication Services. Re-run this workflow after the next apply completes." |
| 248 | + fi |
145 | 249 | else |
146 | 250 | echo "$(date +"%Y-%m-%dT%H:%M:%S") DNS configuration instructions will be shown after the Container Apps Environment is created." |
147 | 251 | fi |
@@ -174,6 +278,31 @@ jobs: |
174 | 278 | - name: Deploy Shared Environment Resources |
175 | 279 | run: bash ./cloud-infrastructure/environment/deploy-environment.sh ${{ inputs.unique_prefix }} ${{ inputs.azure_environment }} ${{ inputs.shared_location }} ${{ inputs.production_service_principal_object_id }} --apply |
176 | 280 |
|
| 281 | + - name: Detect Email Custom Domain Verification |
| 282 | + if: ${{ inputs.domain_name != '' && inputs.domain_name != '-' }} |
| 283 | + run: | |
| 284 | + # Mirror of the plan job's auto-detect step. Re-queries verification because $GITHUB_ENV does |
| 285 | + # not cross job boundaries; the second az call is cheap. When verified, exports |
| 286 | + # USE_CUSTOM_EMAIL_DOMAIN=true so the apply links the CustomerManaged domain and the next |
| 287 | + # account-api/main-api revision picks up the new sender. |
| 288 | + CLUSTER_RESOURCE_GROUP_NAME="${{ inputs.unique_prefix }}-${{ inputs.azure_environment }}-${{ inputs.cluster_location_acronym }}" |
| 289 | + email_domain_name=$(echo "${{ inputs.domain_name }}" | awk -F. '{ if (NF >= 2) print $(NF-1)"."$NF; else print $0 }') |
| 290 | +
|
| 291 | + az extension add --name communication --allow-preview true --only-show-errors 2>/dev/null || true |
| 292 | + email_domain_details=$(az communication email domain show --name "$email_domain_name" --email-service-name $CLUSTER_RESOURCE_GROUP_NAME --resource-group $CLUSTER_RESOURCE_GROUP_NAME -o json 2>/dev/null || echo "") |
| 293 | +
|
| 294 | + if [[ -n "$email_domain_details" ]] && echo "$email_domain_details" | jq empty 2>/dev/null; then |
| 295 | + domain_status=$(echo "$email_domain_details" | jq -r '.verificationStates.Domain.status // "NotStarted"') |
| 296 | + spf_status=$(echo "$email_domain_details" | jq -r '.verificationStates.SPF.status // "NotStarted"') |
| 297 | + dkim_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM.status // "NotStarted"') |
| 298 | + dkim2_status=$(echo "$email_domain_details" | jq -r '.verificationStates.DKIM2.status // "NotStarted"') |
| 299 | +
|
| 300 | + if [[ "$domain_status" == "Verified" ]] && [[ "$spf_status" == "Verified" ]] && [[ "$dkim_status" == "Verified" ]] && [[ "$dkim2_status" == "Verified" ]]; then |
| 301 | + echo "USE_CUSTOM_EMAIL_DOMAIN=true" >> $GITHUB_ENV |
| 302 | + echo "$(date +"%Y-%m-%dT%H:%M:%S") Email custom domain '$email_domain_name' is verified - linking the CustomerManaged domain and flipping the sender." |
| 303 | + fi |
| 304 | + fi |
| 305 | +
|
177 | 306 | - name: Deploy Cluster Resources |
178 | 307 | id: deploy_cluster |
179 | 308 | env: |
|
0 commit comments