When the legacy app stops sending email: SMTP basic auth and a .NET 8 relay

Mail is usually the first thing in a legacy system to break for reasons that have nothing to do with the legacy system. The code has not changed in eleven years. The mail provider changed underneath it.

The current version of this is authentication. Microsoft has been withdrawing basic authentication from Exchange Online protocol by protocol for several years, and client submission over SMTP AUTH is the last one standing for most line-of-business applications. If your VB6 order entry app, your Classic ASP portal or your Access nightly job sends mail with a username and password over port 587, it is on a clock. Check the current retirement dates in Microsoft's own documentation before you plan; they have moved more than once, and the date that matters is the one on your tenant's Message Center, not the one in a blog post.

This tutorial is about what to do with that in a system you are already modernizing. The short version: do not chase the credentials through fifteen call sites. Put one small .NET 8 relay in front of all of them. It is a good first slice, it is independently useful, and it removes a class of outage from the rest of the migration.

Step 1: find every place the system sends mail

You will find more senders than anyone remembers. In a typical mid-market system there are four or five.

Search the source for the usual constructors. Classic ASP and VBScript:

CDO.Message
CDONTS.NewMail
Persits.MailSender
SMTPsvg.Mailer
http://schemas.microsoft.com/cdo/configuration/

VB6 and VBA (Access included):

CDO.Message
MAPI.Session
Outlook.Application
DoCmd.SendObject
SendObject

.NET Framework WebForms:

System.Net.Mail
SmtpClient
MailMessage
<mailSettings>

And the ones outside the application code, which are the ones that bite:

  • sp_send_dbmail and the Database Mail profiles in SQL Server. Query msdb.dbo.sysmail_account and msdb.dbo.sysmail_server to see the account and server each profile uses.
  • SQL Agent alerts and operator notifications.
  • SSRS or Crystal subscriptions, which have their own SMTP settings in the report server configuration.
  • The IIS SMTP virtual server on the web box, relaying for anything on the machine. If %SystemRoot%\System32\inetsrv\config or the old C:\inetpub\mailroot\Pickup directory exists and has recent files in it, something is still dropping .eml files there.

Write the list down as a table: caller, library, server and port, credential source, what it sends, who reads it. Half the value of this exercise is discovering that two of the six senders produce mail nobody has read since 2019.

Step 2: measure what actually goes out

Do not decide volume from reading code. Pull the last 30 days from the provider. In Exchange Online, the message trace in the admin center or Get-MessageTraceV2 gives you sender, recipient count, and timestamps. Export it and answer three questions:

  1. How many messages a day, and what is the peak hour? This decides whether you need a queue or a straight pass-through.
  2. How many go to external recipients? Internal-only mail has easier options than mail that has to pass SPF, DKIM and DMARC at somebody else's gateway.
  3. Which senders are transactional (an order confirmation a customer waits for) and which are operational (a nightly job report to one address)? Transactional mail needs delivery guarantees and a retry story. Operational mail mostly needs to stop silently failing.

That last split usually shrinks the problem. Two paths need real work; the rest can be pointed at the relay and forgotten.

Step 3: pick the submission method before you write code

There are three realistic targets, and the choice is a business decision as much as a technical one.

Direct send or a receive connector against Exchange Online. The legacy app keeps talking SMTP on port 25 to yourtenant-com.mail.protection.outlook.com, and the tenant trusts it by public IP address or certificate rather than by password. No credentials in the app at all. Cheapest change. Costs: it requires a static public IP or a certificate-based connector, and direct send has recipient and reputation limits that make it unsuitable for customer-facing volume.

SMTP AUTH with OAuth 2.0 client credentials. The protocol stays SMTP, but the password is replaced by a token from Entra ID with the SMTP.SendAsApp permission. This keeps every existing code path if, and only if, the client library can do XOAUTH2. CDO cannot. System.Net.Mail.SmtpClient cannot do it cleanly either. In practice this means you are writing new .NET code anyway, which points at the relay.

Microsoft Graph sendMail. HTTPS, app-only permissions, scoped with an application access policy so the app can only send as the one mailbox it owns. This is the option most tenants are standardizing on. It is not SMTP, so the legacy app cannot speak it directly.

If you are not on Exchange Online, the equivalent decision is an SMTP relay service with an API key. The shape of the solution below does not change.

One recommendation: whichever you pick, give the application its own mailbox and its own identity. Applications that send as a person's mailbox generate a support ticket every time that person leaves.

Step 4: build the relay as a strangler slice

The relay is a .NET 8 minimal API with one endpoint. Everything legacy keeps doing what it does; only the address it sends to changes.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IMailSender, GraphMailSender>();
var app = builder.Build();

app.MapPost("/mail", async (MailRequest req, IMailSender sender, ILogger<Program> log) =>
{
    if (req.To.Count == 0 || string.IsNullOrWhiteSpace(req.Subject))
        return Results.BadRequest("to and subject are required");

    var id = Guid.NewGuid();
    await sender.SendAsync(req, id);
    log.LogInformation("sent {Id} from {Source} to {Count} recipients",
        id, req.Source, req.To.Count);
    return Results.Ok(new { id });
});

app.Run();

public record MailRequest(
    string Source,
    List<string> To,
    List<string>? Cc,
    string Subject,
    string Body,
    bool IsHtml,
    List<Attachment>? Attachments);

public record Attachment(string FileName, string ContentBase64);

Source is not decoration. It is the name of the calling legacy module, and it is the field that lets you answer "which of the six senders is producing these" in six months.

The Graph sender behind it:

public sealed class GraphMailSender : IMailSender
{
    private readonly GraphServiceClient _graph;
    private readonly string _fromMailbox;

    public GraphMailSender(IConfiguration config)
    {
        var credential = new ClientSecretCredential(
            config["Graph:TenantId"],
            config["Graph:ClientId"],
            config["Graph:ClientSecret"]);
        _graph = new GraphServiceClient(credential);
        _fromMailbox = config["Graph:FromMailbox"]!;
    }

    public async Task SendAsync(MailRequest req, Guid id)
    {
        var message = new Message
        {
            Subject = req.Subject,
            Body = new ItemBody
            {
                ContentType = req.IsHtml ? BodyType.Html : BodyType.Text,
                Content = req.Body
            },
            ToRecipients = req.To.Select(Recipient).ToList(),
            CcRecipients = (req.Cc ?? new()).Select(Recipient).ToList(),
        };

        await _graph.Users[_fromMailbox]
            .SendMail
            .PostAsync(new SendMailPostRequestBody
            {
                Message = message,
                SaveToSentItems = true
            });
    }

    private static Recipient Recipient(string address) =>
        new() { EmailAddress = new EmailAddress { Address = address } };
}

Scope the app registration with an application access policy so that client ID can only send as that one mailbox. An unscoped Mail.Send application permission can send as anyone in the tenant, which is not a permission you want sitting in a legacy app's config file.

Two things to add before this goes near production:

  • A queue. Write the request to a table or a durable queue, return the id, and send from a background worker with retry and a dead-letter path. Nightly jobs that send 400 messages in a loop will otherwise fail halfway and leave you guessing which half.
  • An allow-list in non-production. The relay should refuse to send to any domain except your own unless it is running in production. Every shop that skips this eventually emails real customers from a test restore.

Step 5: change the callers, one at a time

The callers get the smallest possible edit. Classic ASP:

Function SendMail(sTo, sSubject, sBody)
    Dim http, json
    json = "{""source"":""asp-order-portal"",""to"":[""" & sTo & """]," & _
           """subject"":""" & JsonEscape(sSubject) & """," & _
           """body"":""" & JsonEscape(sBody) & """,""isHtml"":false}"

    Set http = Server.CreateObject("MSXML2.ServerXMLHTTP.6.0")
    http.setTimeout 5000, 5000, 15000, 15000
    http.open "POST", "http://mailrelay.internal/mail", False
    http.setRequestHeader "Content-Type", "application/json"
    http.send json
    SendMail = (http.status = 200)
    Set http = Nothing
End Function

Write JsonEscape yourself; at minimum escape backslash, double quote, and control characters. VB6 uses the same MSXML2.ServerXMLHTTP.6.0 object. Access VBA can use it too, which is usually simpler than fighting Outlook automation on a machine with no Outlook profile.

For SQL Server Database Mail, do not try to make T-SQL call an API. Point the Database Mail account at the relay's SMTP listener if you add one, or move the notification into the .NET worker that owns the job. In a strangler migration the batch jobs are moving anyway.

The order to convert callers in: lowest volume and lowest consequence first. The internal nightly report is your smoke test. The customer order confirmation is last, after the harness is green and you have watched the relay for a week.

Step 6: prove parity before you cut over

Mail is easy to get almost right and hard to notice when it is wrong. Three tests, all of which belong in the characterization harness:

  1. Rendered output. For each sender, capture the message the legacy path produces and the message the relay produces for identical input, and diff subject, body, recipients and attachment bytes with timestamps normalized out. Header order and MIME boundaries will differ; body and attachments must not.
  2. Encoding. Legacy CDO code frequently sets charset to windows-1252 or leaves it at the server default. Push a message containing an accented name, a curly apostrophe and an em dash through both paths and read the result in a real client. This is the defect that reaches customers.
  3. Failure behavior. Give both paths a bad recipient address and record what the caller sees. A lot of legacy code treats mail failure as fatal to the whole transaction. If the relay now returns a non-200 where CDO silently swallowed the error, you have changed the behavior of the order screen. Decide that deliberately.

While you are here, check SPF, DKIM and DMARC for the sending domain. Changing the submission path changes the source of the mail. An app that has been quietly failing DMARC through an on-premises relay will keep failing, or start failing differently, and now it is your change that gets blamed.

What this costs and what it buys

One endpoint, a worker, a queue table and the caller edits is normally a week or two of a senior engineer's time for a system with five or six senders, most of it in step 1 and step 6 rather than in the code.

What you get is worth more than the outage you avoided. You now have one .NET 8 service in production, deployed by your pipeline, with logs and a dead-letter queue, doing real work for the legacy system. Every later slice gets to reuse that deployment path. And the day the provider changes the rules again, you change one service instead of finding CDO calls in a VB6 project nobody can build.

If you want a second opinion on the sender inventory or the cutover order for your system, describe the stack and the mail paths in an email and a senior engineer will reply within one business day.