Recipe - Failed Communication Page - Who Didn't Get It and Why

Skill level: Beginner

Organization: Houston's First Baptist Church

Requires Rock: 18.6


{# strip images & classes from the HTML but otherwise leave structure #}

Failed Communication List - Who Didn't Get It and Why

This recipe adds one staff page that answers "why didn't they get my email?" Pick a communication you sent, see the failures grouped by reason, and get the people in a grid you can text, update, or export.

What Does This Thing Do?

It's a staff page with a searchable dropdown of your own sends and a date range that starts on the current month. Pick a send and you get a small dashboard and a grid of people.

  • The dropdown lists your sends with failures in the date range, like 10/29/26 Weekly Enews (Email) - 1,346 of 9,561 failed, and you can type to search it
  • Five cards across the top show the total failed and the count for each Kind, so you can tell what's worth fixing
  • Click a card to narrow everything to that Kind
  • Failure Reasons lists the top reasons by status note, with a count for each
  • Failed Recipients lists the people with their email status, mobile number, and whether SMS is on
  • Click a reason and the people grid narrows down to that reason
  • Rock's grid actions do the rest: Communicate, Bulk Update, Merge Template, and export

So if an email failed for someone with no address on file, you can select those people and send them a text instead.

The Kinds work like this:

Kind What it means Examples
Fixable The person's record can be corrected No email address, inactive email, no SMS number, 55x bounces
Preference The person chose this, so leave it alone No Bulk, Do Not Send, marked as spam, unsubscribed
Record It's the record status, not the contact info Person is inactive, person is deceased
Provider Everything else, usually nothing to fix on the person 4xx SMTP errors, messages from your email or SMS provider

Before You Start

I built and tested this on Rock 19.7 with the Obsidian version of the Dynamic Data block. It may work on other versions, but test it out first.


The Setup in 4 Steps

Work through these in order. Each one builds on the last. All the code is in this recipe, and it's also attached as FailedCommunicationList.zip if you'd rather download it.

Step 1 - Add the Page

  1. Go to Admin Tools > CMS Configuration > Pages.
  2. Add a child page named Failed Communication List wherever you keep your reports, on your internal site, this would make sense along side the other Communication reports, but you know where you want this to live.
  3. Set its Layout to Full Width.
  4. Set the page's security so only staff who should see this can open it. See "A Note on Who Can See What" below.

Step 2 - Add the Dashboard

  1. On the new page, open Page Zones and add a Dynamic Data block to the Main zone.
  2. Open its settings (you want the Obsidian version).
  3. Paste this into SQL Query:
DECLARE @Cid INT = TRY_CAST(NULLIF(@CommunicationId, '') AS INT);
DECLARE @Start DATE = ISNULL(TRY_CAST(NULLIF(@StartDate, '') AS DATE), DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1));
DECLARE @End DATE = ISNULL(TRY_CAST(NULLIF(@EndDate, '') AS DATE), EOMONTH(GETDATE()));

SELECT TOP 200
    c.Id AS [Value],
    FORMAT(c.SendDateTime, 'M/d/yy') + '  '
        + LEFT(ISNULL(NULLIF(c.[Name], ''), ISNULL(NULLIF(c.[Subject], ''), 'Communication ' + CAST(c.Id AS NVARCHAR(20)))), 60)
        + CASE c.CommunicationType WHEN 1 THEN ' (Email)' WHEN 2 THEN ' (SMS)' WHEN 3 THEN ' (Push)' ELSE '' END
        + '  -  ' + FORMAT(f.Failed, 'N0') + ' of ' + FORMAT(f.Recips, 'N0') + ' failed' AS [Text]
FROM Communication c
CROSS APPLY (
    SELECT COUNT(*) AS Recips, SUM(CASE WHEN cr.Status = 2 THEN 1 ELSE 0 END) AS Failed
    FROM CommunicationRecipient cr
    WHERE cr.CommunicationId = c.Id
) f
WHERE c.SenderPersonAliasId IN (
        SELECT pa.Id
        FROM PersonAlias pa
        WHERE pa.PersonId = TRY_CAST('{{ CurrentPerson.Id }}' AS INT)
    )
    AND c.Status = 3
    AND c.SendDateTime >= @Start
    AND c.SendDateTime < DATEADD(DAY, 1, @End)
    AND f.Recips >= 10
    AND f.Failed > 0
ORDER BY c.SendDateTime DESC;

SELECT
    @Cid AS CommunicationId,
    r.Medium,
    r.Reason,
    r.Kind,
    COUNT(*) AS Failed,
    CHECKSUM(r.Reason) AS ReasonKey
FROM CommunicationRecipient cr
LEFT JOIN EntityType et ON et.Id = cr.MediumEntityTypeId
CROSS APPLY (
    SELECT
        CASE
            WHEN et.[Name] LIKE '%.Email' THEN 'Email'
            WHEN et.[Name] LIKE '%Sms' THEN 'SMS'
            WHEN et.[Name] LIKE '%PushNotification' THEN 'Push'
            ELSE ISNULL(et.FriendlyName, '')
        END AS Medium,
        LEFT(ISNULL(NULLIF(cr.StatusNote, ''), '(no status note)'), 200) AS Reason,
        CASE
            WHEN cr.StatusNote LIKE 'No Email Address%'
                OR cr.StatusNote LIKE 'Recipient Email Address is not active%'
                OR cr.StatusNote LIKE 'Not delivering to previously bounced%'
                OR cr.StatusNote LIKE 'No Phone Number with Messaging Enabled%'
                OR cr.StatusNote LIKE 'Invalid%'
                OR cr.StatusNote LIKE '55[0-9] %'
                OR cr.StatusNote LIKE '%mailbox not found%'
                THEN 'Fixable'
            WHEN cr.StatusNote LIKE 'Communication Preference%'
                OR cr.StatusNote LIKE 'Email Preference%'
                OR cr.StatusNote LIKE 'Spam Reporting Address%'
                OR cr.StatusNote LIKE '%marked your messages as spam%'
                OR cr.StatusNote LIKE '%unsubscribed recipient%'
                THEN 'Preference'
            WHEN cr.StatusNote LIKE 'Person is inactive%'
                OR cr.StatusNote LIKE 'Person is deceased%'
                THEN 'Record'
            ELSE 'Provider'
        END AS Kind
) r
WHERE cr.CommunicationId = @Cid
    AND cr.Status = 2
GROUP BY r.Medium, r.Reason, r.Kind
ORDER BY COUNT(*) DESC, r.Reason;

SELECT
    FORMAT(@Start, 'yyyy-MM-dd') AS StartDate,
    FORMAT(@End, 'yyyy-MM-dd') AS EndDate;
  1. Set Query Parameters to CommunicationId=;StartDate=;EndDate=.
  2. Under Results Formatting, switch from Grid to Lava Template.
  3. Leave Enabled Lava Commands set to none.
  4. Paste this into Lava Template, then save:
{%- assign communicationId = 'Global' | PageParameter:'CommunicationId' | Default:'' -%}
{%- assign kind = 'Global' | PageParameter:'Kind' | Default:'' -%}
{%- assign selectedKey = 'Global' | PageParameter:'ReasonKey' -%}
{%- assign rows = table2.rows -%}
{%- assign dateRange = table3.rows | First -%}
{%- assign reasonCount = rows | Size -%}
<div class="panel panel-default">
    <div class="panel-body">
        <div class="row">
            <div class="col-md-6">
                <div class="form-group rock-drop-down-list">
                    <label class="control-label" for="failed-communication">Communication</label>
                    <div class="control-wrapper">
                        <select id="failed-communication" class="form-control chosen-select" data-placeholder="Pick a communication">
                            <option value=""></option>
                            {%- assign isListed = false -%}
                            {%- for sent in table1.rows -%}
                                {%- assign sentId = sent.Value | AsString -%}
                                {%- if sentId == communicationId -%}
                                    {%- assign isListed = true -%}
                                    <option value="{{ sentId }}" selected>{{ sent.Text | Escape }}</option>
                                {%- else -%}
                                    <option value="{{ sentId }}">{{ sent.Text | Escape }}</option>
                                {%- endif -%}
                            {%- endfor -%}
                            {%- if isListed == false and reasonCount > 0 -%}
                                <option value="{{ communicationId | Escape }}" selected>Communication {{ communicationId | Escape }}</option>
                            {%- endif -%}
                        </select>
                    </div>
                </div>
            </div>
            <div class="col-md-3">
                <div class="form-group">
                    <label class="control-label" for="failed-start-date">From</label>
                    <input id="failed-start-date" type="date" class="form-control" value="{{ dateRange.StartDate }}">
                </div>
            </div>
            <div class="col-md-3">
                <div class="form-group">
                    <label class="control-label" for="failed-end-date">To</label>
                    <input id="failed-end-date" type="date" class="form-control" value="{{ dateRange.EndDate }}">
                </div>
            </div>
        </div>
    </div>
</div>
<script>
    (function () {
        var setParameter = function (name, value) {
            var parameters = new URLSearchParams(window.location.search);
            parameters.set(name, value);
            if (name === 'CommunicationId') {
                parameters.delete('Kind');
                parameters.delete('ReasonKey');
            }
            window.location.search = parameters.toString();
        };
        var setDate = function (name, value) {
            if (value > '2000') {
                setParameter(name, value);
            }
        };
        $('#failed-communication').chosen({ width: '100%', search_contains: true }).on('change', function () {
            setParameter('CommunicationId', this.value);
        });
        $('#failed-start-date').on('change', function () {
            setDate('StartDate', this.value);
        });
        $('#failed-end-date').on('change', function () {
            setDate('EndDate', this.value);
        });
    })();
</script>
{%- if reasonCount > 0 -%}
    {%- assign firstRow = rows | First -%}
    {%- assign baseUrl = '?StartDate=' | Append:dateRange.StartDate | Append:'&EndDate=' | Append:dateRange.EndDate | Append:'&CommunicationId=' | Append:firstRow.CommunicationId -%}
    {%- assign totalFailed = 0 -%}
    {%- assign listCount = 0 -%}
    {%- for row in rows -%}
        {%- assign totalFailed = totalFailed | Plus:row.Failed -%}
        {%- if kind == '' or row.Kind == kind -%}
            {%- assign listCount = listCount | Plus:1 -%}
        {%- endif -%}
    {%- endfor -%}
    {%- assign totalText = totalFailed | Format:'#,##0' -%}
    {%- assign kindCards = 'Fixable|ti-tool|yellow-700|Can be corrected on the record,Preference|ti-bell-off|blue-700|The person chose this,Record|ti-user-off|gray-700|Inactive or deceased records,Provider|ti-cloud-off|red-700|Errors from the email or SMS provider' | Split:',' -%}
    {[ kpis columncount:'5' columncountmd:'5' columncountsm:'2' columnmin:'170px' ]}
        [[ kpi icon:'ti-send-off' value:'{{ totalText }}' label:'Failed' color:'indigo-700' url:'{{ baseUrl }}' ]][[ endkpi ]]
        {%- for card in kindCards -%}
            {%- assign cardParts = card | Split:'|' -%}
            {%- assign cardKind = cardParts[0] -%}
            {%- assign cardTotal = 0 -%}
            {%- for row in rows -%}
                {%- if row.Kind == cardKind -%}
                    {%- assign cardTotal = cardTotal | Plus:row.Failed -%}
                {%- endif -%}
            {%- endfor -%}
            {%- assign cardText = cardTotal | Format:'#,##0' -%}
            {%- assign cardUrl = baseUrl | Append:'&Kind=' | Append:cardKind -%}
            {%- if kind == cardKind -%}{%- assign cardUrl = baseUrl -%}{%- endif -%}
            [[ kpi icon:'{{ cardParts[1] }}' value:'{{ cardText }}' label:'{{ cardKind }}' color:'{{ cardParts[2] }}' description:'{{ cardParts[3] }}' url:'{{ cardUrl }}' ]][[ endkpi ]]
        {%- endfor -%}
    {[ endkpis ]}
    {%- if listCount > 0 -%}
        <div class="panel panel-default margin-t-md">
            <div class="panel-heading">
                <h3 class="panel-title">Failure Reasons</h3>
            </div>
            <div class="list-group">
                {%- assign shownCount = 0 -%}
                {%- for row in rows -%}
                    {%- if kind != '' and row.Kind != kind -%}{%- continue -%}{%- endif -%}
                    {%- if kind == '' and shownCount == 10 -%}{%- break -%}{%- endif -%}
                    {%- assign shownCount = shownCount | Plus:1 -%}
                    {%- assign reasonKey = row.ReasonKey | AsString -%}
                    {%- case row.Kind -%}
                        {%- when 'Fixable' -%}{%- assign kindClass = 'label-warning' -%}
                        {%- when 'Preference' -%}{%- assign kindClass = 'label-info' -%}
                        {%- when 'Record' -%}{%- assign kindClass = 'label-default' -%}
                        {%- else -%}{%- assign kindClass = 'label-danger' -%}
                    {%- endcase -%}
                    {%- if reasonKey == selectedKey -%}
                        <a href="{{ baseUrl }}" title="{{ row.Reason | Escape }}" class="list-group-item active d-flex align-items-center">
                    {%- else -%}
                        <a href="{{ baseUrl }}&amp;Kind={{ row.Kind }}&amp;ReasonKey={{ reasonKey }}" title="{{ row.Reason | Escape }}" class="list-group-item d-flex align-items-center">
                    {%- endif -%}
                        <span class="flex-grow-1 min-w-0 line-clamp-1 mr-3">{{ row.Reason | Escape }}</span>
                        <span class="label {{ kindClass }} mr-3">{{ row.Kind }}</span>
                        <strong>{{ row.Failed | Format:'#,##0' }}</strong>
                    </a>
                {%- endfor -%}
            </div>
            {%- if kind == '' and listCount > 10 -%}
                <div class="panel-footer">
                    <small class="text-muted">Showing the top 10 of {{ listCount }} reasons. Pick a Kind to see all of them.</small>
                </div>
            {%- endif -%}
        </div>
    {%- endif -%}
{%- endif -%}
The cards come from Rock's kpis shortcode and everything else uses Bootstrap and Rock's utility classes, so it should match your theme ;). The links are relative, so there's no route to set up.

Step 3 - Add the Failed Recipients Grid

  1. Add another Dynamic Data block to the Main zone, below the dashboard. Leave this one on Grid not custom lava template.
  2. Paste this into SQL Query:
DECLARE @Cid INT = TRY_CAST(NULLIF(@CommunicationId, '') AS INT);
DECLARE @RKey INT = TRY_CAST(NULLIF(@ReasonKey, '') AS INT);
DECLARE @MobileTypeId INT = (SELECT TOP 1 Id FROM DefinedValue WHERE [Guid] = '407E7E45-7B2E-4FCD-9605-ECB1339F2453');

SELECT
    p.Id AS Person,
    p.NickName + ' ' + p.LastName AS [Name],
    rs.[Value] AS RecordStatus,
    p.Email,
    CASE
        WHEN ISNULL(p.Email, '') = '' THEN ''
        WHEN p.IsEmailActive = 1 THEN 'Active'
        ELSE 'Inactive'
    END AS EmailStatus,
    CASE p.EmailPreference WHEN 0 THEN 'Allowed' WHEN 1 THEN 'No Bulk' WHEN 2 THEN 'Do Not Email' ELSE '' END AS EmailPreference,
    ph.NumberFormatted AS Mobile,
    CASE WHEN ph.Id IS NULL THEN '' WHEN ph.IsMessagingEnabled = 1 THEN 'On' ELSE 'Off' END AS Sms,
    r.Medium,
    r.Reason,
    r.Kind
FROM CommunicationRecipient cr
INNER JOIN PersonAlias pa ON pa.Id = cr.PersonAliasId
INNER JOIN Person p ON p.Id = pa.PersonId
LEFT JOIN DefinedValue rs ON rs.Id = p.RecordStatusValueId
LEFT JOIN EntityType et ON et.Id = cr.MediumEntityTypeId
OUTER APPLY (
    SELECT TOP 1 pn.Id, pn.NumberFormatted, pn.IsMessagingEnabled
    FROM PhoneNumber pn
    WHERE pn.PersonId = p.Id
    ORDER BY pn.IsMessagingEnabled DESC, CASE WHEN pn.NumberTypeValueId = @MobileTypeId THEN 0 ELSE 1 END, pn.Id
) ph
CROSS APPLY (
    SELECT
        CASE
            WHEN et.[Name] LIKE '%.Email' THEN 'Email'
            WHEN et.[Name] LIKE '%Sms' THEN 'SMS'
            WHEN et.[Name] LIKE '%PushNotification' THEN 'Push'
            ELSE ISNULL(et.FriendlyName, '')
        END AS Medium,
        LEFT(ISNULL(NULLIF(cr.StatusNote, ''), '(no status note)'), 200) AS Reason,
        CASE
            WHEN cr.StatusNote LIKE 'No Email Address%'
                OR cr.StatusNote LIKE 'Recipient Email Address is not active%'
                OR cr.StatusNote LIKE 'Not delivering to previously bounced%'
                OR cr.StatusNote LIKE 'No Phone Number with Messaging Enabled%'
                OR cr.StatusNote LIKE 'Invalid%'
                OR cr.StatusNote LIKE '55[0-9] %'
                OR cr.StatusNote LIKE '%mailbox not found%'
                THEN 'Fixable'
            WHEN cr.StatusNote LIKE 'Communication Preference%'
                OR cr.StatusNote LIKE 'Email Preference%'
                OR cr.StatusNote LIKE 'Spam Reporting Address%'
                OR cr.StatusNote LIKE '%marked your messages as spam%'
                OR cr.StatusNote LIKE '%unsubscribed recipient%'
                THEN 'Preference'
            WHEN cr.StatusNote LIKE 'Person is inactive%'
                OR cr.StatusNote LIKE 'Person is deceased%'
                THEN 'Record'
            ELSE 'Provider'
        END AS Kind
) r
WHERE cr.CommunicationId = @Cid
    AND cr.Status = 2
    AND (@Kind = '' OR r.Kind = @Kind)
    AND (@RKey IS NULL OR CHECKSUM(r.Reason) = @RKey)
ORDER BY p.LastName, p.NickName
  1. Set these and leave the rest alone (you may want to adjust them to your case, and also set filtering):
Setting Value
Query Parameters CommunicationId=;Kind=;ReasonKey=
Panel Title Failed Recipients
Selection URL /Person/{Person}/Edit
Person Report On
Show Checkbox Selection Column On
Communicate, Bulk Update, Merge Template, Enable Export On
Launch Workflow, Merge Person Records Off
  1. Paste this into Grid Header Content:
{%- assign communicationId = 'Global' | PageParameter:'CommunicationId' -%}
{%- assign kind = 'Global' | PageParameter:'Kind' -%}
{%- assign reasonKey = 'Global' | PageParameter:'ReasonKey' -%}
{%- assign startDate = 'Global' | PageParameter:'StartDate' -%}
{%- assign endDate = 'Global' | PageParameter:'EndDate' -%}
{%- assign count = rows | Size -%}
{%- if communicationId == null or communicationId == '' -%}
<div class="alert alert-info alert-grid">Pick one of your communications above to see who did not receive it and why.</div>
{%- else -%}
<div class="panel-body">
    <strong>{{ count | Format:'#,##0' }}</strong> failed {{ 'recipient' | PluralizeForQuantity:count }}
    {%- if kind and kind != '' %} <span class="label label-info">{{ kind | Escape }}</span>{% endif -%}
    {%- if reasonKey and reasonKey != '' %} <span class="label label-default">{{ rows | First | Property:'Reason' | Default:'Selected reason' | Escape }}</span>
    <a href="?CommunicationId={{ communicationId | EscapeDataString }}&amp;StartDate={{ startDate | EscapeDataString }}&amp;EndDate={{ endDate | EscapeDataString }}" class="btn btn-default btn-xs">Show all failures</a>{% endif %}
</div>
{%- endif -%}
  1. Under Column Configuration, click Reload Columns From Query.
  2. Set the Person column's type to Person. This is what makes it a person report, and without it the grid actions don't show up.

Step 4 - Try It

  1. Reload the page. If it's early in the month, set From further back so the dropdown has something in it.
  2. Type part of a name in the dropdown and pick one of your communications.
  3. The dashboard and people fill in. Check that the Failed card matches the number in the dropdown.
  4. Click a "Kind" card. The list and people narrow to that Kind, and clicking the card again clears it.
  5. Click a reason. The page reloads with that reason highlighted and only those people in Failed Recipients.
  6. Click the reason again to go back to everyone. Show all failures in the Failed Recipients header does the same thing.
  7. Click a row to open their profile edit page.
Done. Your staff can see who didn't get a communication and why, without opening it up one recipient at a time.

Customize It to Your Instance

To change this Do this
Which reasons go in which Kind Edit the CASE that sets Kind. It's in both queries, so change both. Your SMS and email providers word their errors their own way, and anything that doesn't match lands in Provider.
Kind colors The cards take their icon and color from the kindCards line in the template, in the form Kind|icon|color|tooltip. The list's colors are the label-* classes further down.
The default date range Change the two ISNULL defaults on @Start and @End at the top of the dashboard query.
Hiding small sends Change f.Recips >= 10 in the dashboard query. This keeps one-off texts out of the list.

A Note on Who Can See What

  • The dropdown only lists the person's own sent communications.
  • Status notes are written by mail servers, and some of them include the recipient's email address. Treat the Reason column as personal data, including in screenshots.

Troubleshooting

  • The dropdown is empty. It only lists your own sends in the date range with 10 or more recipients and at least one failure, and the range starts on the current month. Try setting From further back.
  • Nothing fills in after you pick something. Check that Query Parameters is filled in on both blocks.
  • The dropdown has no search box. The script in the template didn't run. Check that the Lava Template still has the script tag, and test this on your Rock version.
  • No Communicate or Bulk Update buttons. The Person column has to be set to the Person type, and Person Report has to be on.
  • Clicking a person does nothing. Check the Selection URL is /Person/{Person}/Edit or just /Person/{Person} if you want it to go to their profile and not the edit page. The name in the braces has to match a column.
  • The grid is shorter than expected. A Kind of reason is still selected. Click Show all failures.

That's It!

Four steps and you've got a page that answers "why didn't they get my email?" On one of our sends, 2,768 of the 3,336 failures came down to No Email Address, so now we know who to mail (don't reach for the text unless they have expressly opted in!).

Download related file (FailedCommunicationList.zip)