defmodule Swoosh.Adapters.AzureCommunicationServices do @moduledoc """ Azure Communication Services Email adapter for Swoosh. ## Configuration config :da_product_app, DaProductApp.Mailer, adapter: Swoosh.Adapters.AzureCommunicationServices, sender_address: "donotreply@shukria.ae", acs_endpoint: "https://shukria-commserv-merchants.uae.communication.azure.com", api_version: "2025-09-01", acs_key: System.get_env("ACS_KEY") """ use Swoosh.Adapter, required_config: [:sender_address, :acs_endpoint, :acs_key] require Logger alias Swoosh.Email @impl true def deliver(%Email{} = email, config) do Logger.info("[AzureCommunicationServices] ===== STARTING EMAIL DELIVERY =====") Logger.info("[AzureCommunicationServices] Email recipients: #{inspect(email.to)}") Logger.info("[AzureCommunicationServices] Email subject: #{email.subject}") sender_address = config[:sender_address] || raise_missing_config(:sender_address) acs_endpoint = config[:acs_endpoint] || raise_missing_config(:acs_endpoint) acs_key = config[:acs_key] || raise_missing_config(:acs_key) api_version = config[:api_version] || "2025-09-01" Logger.info("[AzureCommunicationServices] Config values:") Logger.info(" - sender: #{sender_address}") Logger.info(" - endpoint: #{acs_endpoint}") Logger.info(" - api_version: #{api_version}") Logger.info(" - acs_key present: #{if acs_key && acs_key != "configure ACS_KEY here", do: "YES ✓", else: "NO ✗ (INVALID)"}") # Azure Communication Services Email API - HMAC-SHA256 signature authentication url = "#{acs_endpoint}/emails:send?api-version=#{api_version}" Logger.info("[AzureCommunicationServices] Request URL: #{url}") # Extract host from URL (without https:// and trailing slash) uri = URI.parse(url) host = uri.host path_and_query = "#{uri.path}?#{uri.query}" Logger.info("[AzureCommunicationServices] Host extracted: #{host}") Logger.info("[AzureCommunicationServices] Path and query: #{path_and_query}") # Prepare request body body = prepare_body(email, sender_address) content_hash = :crypto.hash(:sha256, body) |> Base.encode64() Logger.info("[AzureCommunicationServices] Content SHA256: #{String.slice(content_hash, 0, 20)}...") # Generate timestamp in HTTP date format (RFC 7231) utc_now = DateTime.utc_now() http_date = Calendar.strftime(utc_now, "%a, %d %b %Y %H:%M:%S GMT") Logger.info("[AzureCommunicationServices] HTTP Date: #{http_date}") # Build the string to sign for HMAC-SHA256 # Format: POST\n/path?query\nX-MS-DATE;HOST;X-MS-CONTENT-SHA256\n string_to_sign = "POST\n#{path_and_query}\n#{http_date};#{host};#{content_hash}" Logger.info("[AzureCommunicationServices] String to sign:") Logger.info("[AzureCommunicationServices] #{inspect(string_to_sign)}") # Create HMAC-SHA256 signature decoded_key = Base.decode64!(acs_key) signature = :crypto.mac(:hmac, :sha256, decoded_key, string_to_sign) |> Base.encode64() Logger.info("[AzureCommunicationServices] Signature (first 20 chars): #{String.slice(signature, 0, 20)}...") # Build Authorization header with HMAC-SHA256 format # CRITICAL: Header names in Authorization must match header names used in request headers = [ {"Content-Type", "application/json"}, {"Authorization", "HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=#{signature}"}, {"x-ms-date", http_date}, {"host", host}, {"x-ms-content-sha256", content_hash} ] Logger.info("[AzureCommunicationServices] Auth: HMAC-SHA256 signature authentication") Logger.info("[AzureCommunicationServices] Headers: Authorization, x-ms-date, host, x-ms-content-sha256") Logger.debug("[AzureCommunicationServices] Request body prepared") Logger.info("[AzureCommunicationServices] Sending HTTP request to Azure...") case make_request(url, headers, body) do {:ok, response} -> Logger.info("[AzureCommunicationServices] ✅ Email sent successfully to #{inspect(email.to)}") {:ok, response} {:error, reason} = error -> Logger.error("[AzureCommunicationServices] ❌ Email delivery FAILED") Logger.error("[AzureCommunicationServices] Error: #{inspect(reason)}") error end end @impl true def validate_config(config) do missing = Enum.filter([:sender_address, :acs_endpoint, :acs_key], fn k -> is_nil(config[k]) || config[k] == "" end) if missing == [], do: :ok, else: {:error, "Missing: #{inspect(missing)}"} end @impl true def validate_dependency do case Application.ensure_all_started(:finch) do {:ok, _} -> :ok {:error, _} -> {:error, :finch_not_started} end end defp prepare_body(%Email{} = email, sender) do Logger.info("[AzureCommunicationServices] ===== PREPARE_BODY STARTING =====") Logger.info("[AzureCommunicationServices] email.attachments count: #{length(email.attachments)}") # Verify attachment structure BEFORE prep_attachments if length(email.attachments) > 0 do first_att = List.first(email.attachments) Logger.info("[AzureCommunicationServices] First attachment is %Swoosh.Attachment{}: #{is_struct(first_att, Swoosh.Attachment)}") Logger.info("[AzureCommunicationServices] First attachment data type: #{inspect(first_att.data, limit: 50)}") Logger.info("[AzureCommunicationServices] First attachment filename: #{first_att.filename}") end %{ "senderAddress" => sender, "content" => %{ "subject" => email.subject, "plainText" => email.text_body, "html" => email.html_body }, "recipients" => %{ "to" => prep_recipients(email.to), "cc" => prep_recipients(email.cc), "bcc" => prep_recipients(email.bcc) }, "attachments" => prep_attachments(email.attachments) } |> remove_empty() |> Jason.encode!() |> tap(fn json_body -> Logger.info("[AzureCommunicationServices] Final JSON body size: #{byte_size(json_body)} bytes") Logger.info("[AzureCommunicationServices] JSON first 500 chars: #{String.slice(json_body, 0, 500)}") # If there are attachments, verify contentInBase64 is present and non-empty case Jason.decode(json_body) do {:ok, decoded} -> attachments = decoded["attachments"] || [] if is_list(attachments) && length(attachments) > 0 do first_att = List.first(attachments) if first_att do base64_len = String.length(first_att["contentInBase64"] || "") Logger.info("[AzureCommunicationServices] ✅ First attachment contentInBase64 present: #{base64_len} chars (non-empty: #{base64_len > 0})") end end {:error, _} -> nil end end) end defp prep_recipients([]), do: [] defp prep_recipients(nil), do: [] defp prep_recipients(list) when is_list(list) do Enum.map(list, fn {name, email} -> %{"address" => email, "displayName" => name} email when is_binary(email) -> %{"address" => email} end) end defp prep_attachments([]), do: [] defp prep_attachments(nil), do: [] defp prep_attachments(list) when is_list(list) do Enum.map(list, fn att -> Logger.info("[AzureCommunicationServices] Processing attachment: #{att.filename}") Logger.info("[AzureCommunicationServices] Attachment data type: #{inspect(att.data, limit: 50)}") content = case att.data do # Handle double-wrapped results from generate functions {:ok, bin} when is_binary(bin) -> Logger.warn("[AzureCommunicationServices] Unwrapping {:ok, binary} - possible double-wrap") Logger.info("[AzureCommunicationServices] Unwrapped binary size: #{byte_size(bin)} bytes") encoded = Base.encode64(bin) Logger.info("[AzureCommunicationServices] Base64 encoded size: #{String.length(encoded)} chars") encoded {:data, bin} when is_binary(bin) -> Logger.info("[AzureCommunicationServices] Binary size: #{byte_size(bin)} bytes") encoded = Base.encode64(bin) Logger.info("[AzureCommunicationServices] Base64 encoded size: #{String.length(encoded)} chars") Logger.info("[AzureCommunicationServices] Base64 first 100 chars: #{String.slice(encoded, 0, 100)}") encoded bin when is_binary(bin) -> Logger.info("[AzureCommunicationServices] Direct binary size: #{byte_size(bin)} bytes") encoded = Base.encode64(bin) Logger.info("[AzureCommunicationServices] Base64 encoded size: #{String.length(encoded)} chars") encoded charlist when is_list(charlist) -> Logger.error("[AzureCommunicationServices] Got charlist instead of binary: #{inspect(charlist)}") Logger.error("[AzureCommunicationServices] This indicates Elixlsx API was used incorrectly") "" _ -> Logger.warn("[AzureCommunicationServices] Unknown attachment data type, using empty string") "" end result = %{ "name" => att.filename, "contentType" => att.content_type || "application/octet-stream", "contentInBase64" => content } Logger.info("[AzureCommunicationServices] Attachment struct created: #{inspect(result, limit: 80)}") result end) end defp remove_empty(map) do map |> Enum.reject(fn {_k, nil} -> true {_k, []} -> true {_k, %{} = v} when is_map(v) and map_size(v) == 0 -> true _ -> false end) |> Map.new() end defp make_request(url, headers, body) do Logger.debug("[AzureCommunicationServices] Building HTTP request...") request = Finch.build(:post, url, headers, body) Logger.debug("[AzureCommunicationServices] Executing HTTP request via Finch...") case Finch.request(request, DaProductApp.Finch) do {:ok, %{status: s, body: b}} when s in 200..299 -> Logger.info("[AzureCommunicationServices] HTTP Response: #{s} (Success)") Logger.info("[AzureCommunicationServices] Response body: #{b}") # Parse JSON response to extract the ID case Jason.decode(b) do {:ok, decoded} -> Logger.info("[AzureCommunicationServices] Parsed response: #{inspect(decoded)}") {:ok, %{id: decoded["id"], status: s}} {:error, decode_err} -> Logger.warn("[AzureCommunicationServices] Failed to parse JSON response: #{inspect(decode_err)}") {:ok, %{status: s, body: b}} end {:ok, %{status: s, body: b}} -> Logger.error("[AzureCommunicationServices] HTTP Response: #{s} (Error)") Logger.error("[AzureCommunicationServices] Response body: #{b}") {:error, %{status: s, body: b}} {:error, reason} -> Logger.error("[AzureCommunicationServices] HTTP Request Failed") Logger.error("[AzureCommunicationServices] Error: #{inspect(reason)}") {:error, reason} end end defp raise_missing_config(key) do raise ArgumentError, "Missing Azure ACS config: #{key}" end end