Asynchronous email delivery via Service Broker

Classic scenario: you have a stored procedure that does something (creates an order, registers a user, whatever) and at the end an email needs to go out. Instead of calling sp_send_dbmail directly (which blocks the caller until SMTP has responded), you put the mail request into a queue and a worker sends it in the background.

One-time Setup

 
 
sql
-- Demo DB
CREATE DATABASE MailDemo;
GO
ALTER DATABASE MailDemo SET ENABLE_BROKER WITH ROLLBACK IMMEDIATE;
GO
USE MailDemo;
GO

-- Message Type: simply XML with mail data
CREATE MESSAGE TYPE [//demo/MailRequest]
    VALIDATION = WELL_FORMED_XML;

-- Contract: Initiator sends MailRequest
CREATE CONTRACT [//demo/MailContract]
    ([//demo/MailRequest] SENT BY INITIATOR);

-- Target Queue (where mail requests land)
CREATE QUEUE MailTargetQueue;

-- Target Service (receiver)
CREATE SERVICE [//demo/MailTarget]
    ON QUEUE MailTargetQueue
    ([//demo/MailContract]);

-- Initiator Queue (for replies / EndDialog messages)
CREATE QUEUE MailInitQueue;

CREATE SERVICE [//demo/MailInit]
    ON QUEUE MailInitQueue;
GO

Helper Procedure for Sending

This is how your business logic triggers the mail send — fast, transactional, no SMTP wait time:

 
 
sql
CREATE OR ALTER PROCEDURE dbo.QueueMail
    @To      NVARCHAR(200),
    @Subject NVARCHAR(400),
    @Body    NVARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @h UNIQUEIDENTIFIER;
    DECLARE @msg XML = (
        SELECT
            @To      AS [to],
            @Subject AS [subject],
            @Body    AS [body]
        FOR XML PATH('mail'), TYPE
    );

    BEGIN DIALOG CONVERSATION @h
        FROM SERVICE [//demo/MailInit]
        TO   SERVICE '//demo/MailTarget'
        ON   CONTRACT [//demo/MailContract]
        WITH ENCRYPTION = OFF;

    SEND ON CONVERSATION @h
        MESSAGE TYPE [//demo/MailRequest] (@msg);
END;
GO

Worker Procedure (Activated Automatically)

This procedure reads messages from the queue and calls sp_send_dbmail:

 
 
sql
CREATE OR ALTER PROCEDURE dbo.ProcessMailQueue
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE
        @h        UNIQUEIDENTIFIER,
        @msgType  SYSNAME,
        @msgBody  XML,
        @to       NVARCHAR(200),
        @subject  NVARCHAR(400),
        @body     NVARCHAR(MAX);

    WHILE (1 = 1)
    BEGIN
        BEGIN TRANSACTION;

        WAITFOR (
            RECEIVE TOP(1)
                @h       = conversation_handle,
                @msgType = message_type_name,
                @msgBody = CAST(message_body AS XML)
            FROM MailTargetQueue
        ), TIMEOUT 5000;

        -- Queue empty -> exit
        IF @@ROWCOUNT = 0
        BEGIN
            ROLLBACK TRANSACTION;
            BREAK;
        END;

        IF @msgType = N'//demo/MailRequest'
        BEGIN
            SELECT
                @to      = @msgBody.value('(/mail/to)[1]',      'NVARCHAR(200)'),
                @subject = @msgBody.value('(/mail/subject)[1]', 'NVARCHAR(400)'),
                @body    = @msgBody.value('(/mail/body)[1]',    'NVARCHAR(MAX)');

            BEGIN TRY
                EXEC msdb.dbo.sp_send_dbmail
                    @profile_name = 'DefaultMailProfile',  -- you need to create this
                    @recipients   = @to,
                    @subject      = @subject,
                    @body         = @body,
                    @body_format  = 'HTML';

                END CONVERSATION @h;
            END TRY
            BEGIN CATCH
                -- Logging so you can see poison messages
                INSERT dbo.MailErrorLog (ConversationHandle, ErrorMsg, MsgBody)
                VALUES (@h, ERROR_MESSAGE(), CAST(@msgBody AS NVARCHAR(MAX)));

                END CONVERSATION @h WITH ERROR = 50001
                    DESCRIPTION = N'Mail send failed';
            END CATCH;
        END
        ELSE IF @msgType IN
            (N'http://schemas.microsoft.com/SQL/ServiceBroker/EndDialog',
             N'http://schemas.microsoft.com/SQL/ServiceBroker/Error')
        BEGIN
            END CONVERSATION @h;
        END;

        COMMIT TRANSACTION;
    END;
END;
GO

CREATE TABLE dbo.MailErrorLog (
    Id                 INT IDENTITY PRIMARY KEY,
    ConversationHandle UNIQUEIDENTIFIER,
    ErrorMsg           NVARCHAR(4000),
    MsgBody            NVARCHAR(MAX),
    CreatedAt          DATETIME2 DEFAULT SYSDATETIME()
);
GO

Enable Activation — This Is the Key Part

Now you tell the queue: "When messages arrive, automatically start up to 4 workers in parallel."

 
 
sql
ALTER QUEUE MailTargetQueue
WITH ACTIVATION (
    STATUS         = ON,
    PROCEDURE_NAME = dbo.ProcessMailQueue,
    MAX_QUEUE_READERS = 4,
    EXECUTE AS SELF
);
GO

From here on you don't need a Windows service, no SQL Agent job, no nothing. SQL Server monitors the queue itself and fires up the worker procedure.

Usage in Your Actual Stored Procedure

 
 
sql
CREATE OR ALTER PROCEDURE dbo.RegisterUser
    @UserName NVARCHAR(100),
    @Email    NVARCHAR(200)
AS
BEGIN
    SET NOCOUNT ON;

    BEGIN TRAN;

    INSERT dbo.Users (UserName, Email)
    VALUES (@UserName, @Email);

    -- Put mail in queue – returns immediately
    EXEC dbo.QueueMail
        @To      = @Email,
        @Subject = N'Welcome!',
        @Body    = N'<h1>Hello</h1><p>Your account is ready.</p>';

    COMMIT;  -- Insert AND mail request are atomic
END;
GO

What's Really Elegant Here

Transactional consistency. If the INSERT rolls back, the mail request disappears from the queue as well. You can never end up in a situation where "the user wasn't created, but the welcome mail went out anyway." Achieving this with an external queue (RabbitMQ etc.) requires significantly more effort.

Caller performance. RegisterUser returns after a few milliseconds, regardless of whether the SMTP server is currently slow or takes 30 seconds.

Automatic scaling. MAX_QUEUE_READERS = 4 means: under heavy load up to 4 workers run in parallel, under light load it scales down automatically.

Built-in poison message protection. If the worker procedure rolls back 5 times in a row, SQL Server automatically disables the queue (STATUS = OFF) to prevent endless retries.

Monitoring

 
 
sql
-- Current messages in the queue
SELECT * FROM MailTargetQueue WITH (NOLOCK);

-- Stuck outbound messages
SELECT * FROM sys.transmission_queue;

-- Open conversations
SELECT * FROM sys.conversation_endpoints
WHERE state <> 'CD'  -- CD = Closed/Disconnected
ORDER BY security_timestamp DESC;

-- Queue status (is activation still on?)
SELECT name, is_activation_enabled, activation_procedure
FROM sys.service_queues
WHERE name = 'MailTargetQueue';

Test

 
 
sql
EXEC dbo.QueueMail
    @To      = N'georg@example.com',
    @Subject = N'Test',
    @Body    = N'<p>Service Broker rocks</p>';

-- Should be processed almost immediately
WAITFOR DELAY '00:00:02';
SELECT COUNT(*) AS RemainingInQueue FROM MailTargetQueue WITH (NOLOCK);

The same pattern works for anything you want to do asynchronously: HTTP calls via sp_OACreate or CLR, file operations, cache invalidations, logging to external systems. You just swap out the EXEC sp_send_dbmail block in the worker procedure.

If you're interested, I can show you next the pattern with multiple message types per contract (e.g. mail + SMS + webhook on the same queue) or how to build scheduled/delayed messages — Service Broker has BEGIN CONVERSATION TIMER for that, which is nice for retry logic.