@AjayKumar claims in the comment to their answer that this should now be resolved, but it still does not work for me.
This may be because our developer users are guest users from another Azure tenant, I don't know.
Regardless, here's an implementation that tests if the DefaultAzureCredential is a user, and only if it's a user, it uses Azure Resource Manager (ARM) to fetch the the ACS connection string. For this to work, your user must be Contributor on the whole subscription - but surely that's not a problem since you have a developer subscription for this sort of stuff, riight? (:
This solution uses the following packages:
The code:
using Azure;
using Azure.Communication.Email;
using Azure.Core;
using Azure.Identity;
using Azure.ResourceManager;
using Azure.ResourceManager.Communication;
using System.Net.Mail;
using System.Text;
using System.Text.Json;
namespace Your.Namespace.Here;
public sealed class AzureEmailer(AzureEmailer.Config config)
{
public sealed record Config(Uri AzureCommunicationServiceInstanceBaseUrl);
public async Task SendEmailAsync(
string from,
List<(string EmailAddress, string DisplayName)> to,
string subject,
string body,
bool isHtmlEmailBody = false,
List<(string Filename, string ContentType, BinaryData Data)>? attachments = null,
List<(string EmailAddress, string DisplayName)>? cc = null,
List<(string EmailAddress, string DisplayName)>? bcc = null
) {
var emailClient = await GetClientAsync(config.AzureCommunicationServiceInstanceBaseUrl);
EmailContent emailContent;
if(isHtmlEmailBody)
emailContent = new EmailContent(subject)
{
Html = body
};
else
emailContent = new EmailContent(subject)
{
PlainText = body
};
var recipients = new EmailRecipients(
to.Select(x => new EmailAddress(x.EmailAddress, x.DisplayName)),
cc?.Select(x => new EmailAddress(x.EmailAddress, x.DisplayName)),
bcc?.Select(x => new EmailAddress(x.EmailAddress, x.DisplayName))
);
var message = new EmailMessage(from, recipients, emailContent);
if(attachments is not null)
foreach(var attachment in attachments)
message.Attachments.Add(new EmailAttachment(attachment.Filename, attachment.ContentType, attachment.Data));
await emailClient.SendAsync(WaitUntil.Completed, message);
}
private static Dictionary<Uri, EmailClient> ClientCache { get; } = new();
private static SemaphoreSlim OneAtATime { get; } = new(1, 1);
private static async Task<EmailClient> GetClientAsync(Uri AcsBaseUrl)
{
await OneAtATime.WaitAsync();
try
{
if(ClientCache.TryGetValue(AcsBaseUrl, out var client))
return client;
EmailClient newClient;
var isUser = await IsAzureDefaultCredentialUserCredentials();
Console.WriteLine($"{nameof(AzureEmailer)} says \"IsUser == {isUser}\"");
if(isUser)
newClient = await GetClientFromAzureUserIdentityWorkaroundAsync(AcsBaseUrl);
else
newClient = new EmailClient(AcsBaseUrl, new DefaultAzureCredential());
ClientCache.Add(AcsBaseUrl, newClient);
return newClient;
}
finally
{
OneAtATime.Release();
}
}
private static async Task<bool> IsAzureDefaultCredentialUserCredentials()
{
try
{
var credential = new DefaultAzureCredential();
var context = new TokenRequestContext(new[] { "https://management.azure.com/.default" });
var token = await credential.GetTokenAsync(context);
var tokenParts = token.Token.Split('.');
//We don't actually know in this case because we couldn't parse the token.
//But the true-case is used for a workaround in dev, so it's better to make code that's inconvinient for dev
//yet still works in prod.
if(tokenParts.Length != 3)
return false;
var payload = tokenParts[1];
var payloadBase64Unpadded = payload.Replace('-', '+').Replace('_', '/');
var remainder = payloadBase64Unpadded.Length % 4;
string payloadbase64;
if(remainder == 0)
payloadbase64 = payloadBase64Unpadded;
//remainder == 1 should not possible because of how base64 works.
else if(remainder == 2)
payloadbase64 = payloadBase64Unpadded + "==";
else if(remainder == 3)
payloadbase64 = payloadBase64Unpadded + "=";
else
return false; //again, we couldn't parse the token. Better to just return false.
var json = Encoding.UTF8.GetString(Convert.FromBase64String(payloadbase64));
var claims = JsonDocument.Parse(json).RootElement;
return claims.TryGetProperty("idtyp", out var idType) && idType.GetString() == "user";
}
catch
{
//again, better to just break the developer-flow than break prod.
return false;
}
}
private static async Task<EmailClient> GetClientFromAzureUserIdentityWorkaroundAsync(Uri acsBaseUrl)
{
//There's a bug(?) in Azure Communication Serices, that means that if your default azure credential is based on a
//user, then you cannot use the normal managed identity flow like your managed identity applications can.
//This is despite how most (all?) other services that work with managed identity accept user identities fine.
//The following is an extremely rough hack to make the dev-experience require the same inputs as prod.
//For speed these requests could be parallelized since there's a bit of waiting time.
var armClient = new ArmClient(new DefaultAzureCredential());
await foreach(var tenant in armClient.GetTenants().GetAllAsync())
{
//For reasons we need to make an arm client for the specific tenant or
//we won't be able to get the list of subscriptions - we'd instead get an empty list.
var tenantArmClient = new ArmClient(new DefaultAzureCredential(new DefaultAzureCredentialOptions
{
TenantId = tenant.Data.TenantId.ToString()
}));
await foreach(var subscription in tenantArmClient.GetSubscriptions().GetAllAsync())
{
try
{
await foreach(var acsInstance in subscription.GetCommunicationServiceResourcesAsync())
if(string.Equals(acsInstance.Data.HostName, acsBaseUrl.Host, StringComparison.OrdinalIgnoreCase))
{
var keys = await acsInstance.GetKeysAsync();
return new EmailClient(keys.Value.PrimaryConnectionString);
}
}
catch(RequestFailedException ex) when (ex.ErrorCode == "SubscriptionNotRegistered")
{
//This subscription is does not have permissions to use azure communication service,
//which means our target ACS instance won't be in this subscription anyway, so we'll just move on.
}
}
}
throw new Exception($"The requested Azure Communication Service instance with ({acsBaseUrl}) was not found.");
}
}