Outlookの未達メール(NDR)をC#で確実に検出しCSV保存する方法|ReportItemとMessageClassでスキップ回避

Outlook Object Model で未達メール(NDR)だけを抜き出して CSV に保存したいのに、件名「Undeliverable:」や MessageClass = "IPM.Note.NDR" のメールがコード上でスキップされる――この症状の多くは、NDR が MailItem ではなく ReportItem として届くことが原因です。本記事では「なぜ拾えないのか」「どう直すか」を、実運用で落ちない判定ロジック、正規表現、Items.Restrict の高速化、CSV 安全出力まで具体的な C# コードとともに詳説します。

目次

質問概要

Outlook Object Model(C#)でバウンス(未達)メールを検出し、宛先アドレスを CSV に書き出したい。通常の受信メールは取得できるが、件名が 「Undeliverable:」 のメールや MessageClass = "IPM.Note.NDR" のメールがループ内で スキップされてしまう。NDR だけを確実に拾い、宛先メールアドレスを CSV に保存する方法は?

結論と全体像(先に要点)

  • NDR は多くの環境で MailItem ではなく ReportItem として到着する。列挙は foreach (object item in folder.Items) のように object で受け、is パターン マッチで分岐する。
  • MessageClass でふるい分け(例:ReportItem.MessageClass が「NDR」を含む)。件名だけでの判定は各言語・各サーバで揺れるため補助指標に留める。
  • 宛先抽出は本文の DSN フィールド(Final-Recipient / Original-Recipient)を優先し、見つからない場合のみ To: 行などをフォールバックに使う。
  • CSV は UTF-8(BOM あり)で追記し、重複排除・Excel での「式注入」対策も行う。
  • 大量処理は Items.Restrict で「MessageClass が NDR を含む」条件に絞って高速化。

NDR が「見えない」理由を理解する

Outlook のアイテムは「メッセージ クラス」によって型(表現)が分かれます。一般的な受信メールは MailItem(IPM.Note)ですが、配信不能通知(NDR)は ReportItem として届くことが多く、MailItem だけを前提にキャストしていると InvalidCastException を避けるためにスキップされます。

代表的なメッセージ クラス型用途・意味備考
IPM.NoteMailItem通常のメール本文・添付・送信者等を扱う標準
Report.IPM.Note.NDR/IPM.Note.NDRReportItem配信不能(Non-Delivery Report)環境により接頭辞 Report. の有無や表記ゆれあり
IPM.Note.IPNRNReportItem開封通知(Read)混同に注意
IPM.Note.IPNNRNReportItem未開封通知(Not Read)混同に注意

従って「folder.Items を MailItem と仮定して列挙」する実装は NDR を取りこぼします。必ず object として受け取り、is ReportItem と is MailItem で分岐しましょう。

最小実装:ReportItem を拾い、本文から宛先を抽出して CSV 追記

まずは最短で「拾えて書ける」コードです。本文の Final-Recipient/Original-Recipient を優先的に拾い、無いときは To: を補助的に使います。

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Outlook = Microsoft.Office.Interop.Outlook;

class NdrExporter
{
    // DSN を優先的に狙う
    private static readonly Regex ReDsnRecipient =
        new Regex(@"(?im)^(?:Final-Recipient|Original-Recipient)\s*:\s*[^;]+;\s*(?<addr>[^\s;<>]+@[^\s;<>]+)\s*$");

    // 角括弧付き <user@domain> を拾う補助
    private static readonly Regex ReAngle =
        new Regex(@"<(?<addr>[^>]+@[^>]+)>");

    // フォールバック:To:(環境によっては送信者自身になる点に注意)
    private static readonly Regex ReToLine =
        new Regex(@"(?im)^(?:To|宛先)\s*:\s*(?<addr>[^\s,;:<>]+@[^\s,;:<>]+)");

    static void Main()
    {
        var app = new Outlook.Application();
        var ns  = app.Session;
        Outlook.MAPIFolder inbox = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
        Outlook.Items items = inbox.Items;

        string csvPath = Path.Combine(Environment.CurrentDirectory, "bounced.csv");
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        using (var writer = new StreamWriter(csvPath, true, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)))
        {
            foreach (object it in items)
            {
                if (it is Outlook.ReportItem rep && IsNdr(rep.MessageClass))
                {
                    foreach (var addr in ExtractRecipients(rep.Body))
                    {
                        if (seen.Add(addr))
                        {
                            writer.WriteLine(SanitizeCsv(addr));
                        }
                    }
                }
                else if (it is Outlook.MailItem mail && LooksLikeNdrSubject(mail.Subject))
                {
                    foreach (var addr in ExtractRecipients(mail.Body))
                    {
                        if (seen.Add(addr))
                        {
                            writer.WriteLine(SanitizeCsv(addr));
                        }
                    }
                }
            }
        }
    }

    private static bool IsNdr(string messageClass)
        => !string.IsNullOrEmpty(messageClass)
           && messageClass.IndexOf("NDR", StringComparison.OrdinalIgnoreCase) >= 0;

    private static bool LooksLikeNdrSubject(string subject)
        => !string.IsNullOrEmpty(subject)
           && (subject.StartsWith("Undeliverable", StringComparison.OrdinalIgnoreCase)
                || subject.IndexOf("Delivery Status Notification", StringComparison.OrdinalIgnoreCase) >= 0
                || subject.IndexOf("配信不能", StringComparison.OrdinalIgnoreCase) >= 0);

    private static IEnumerable<string> ExtractRecipients(string body)
    {
        if (string.IsNullOrEmpty(body)) yield break;

        // 1) DSN フィールドを優先
        foreach (Match m in ReDsnRecipient.Matches(body))
            yield return m.Groups["addr"].Value;

        // 2) 角括弧 <...> の中のアドレス(本文説明文に含まれるケース)
        foreach (Match m in ReAngle.Matches(body))
            yield return m.Groups["addr"].Value;

        // 3) フォールバック:To: 行
        var to = ReToLine.Match(body);
        if (to.Success)
            yield return to.Groups["addr"].Value;
    }

    private static string SanitizeCsv(string value)
    {
        if (string.IsNullOrEmpty(value)) return "";
        // Excel 式注入対策:先頭が = + - @ の場合は ' を付与
        return Regex.IsMatch(value, @"^[=\+\-@]") ? "'" + value : value;
    }
}

この最小実装だけでも「ReportItem を対象にする」「MessageClass に NDR を含むか」「本文から宛先を抽出」「CSV へ追記」の要件を満たし、従来スキップされていた NDR を確実に拾えます。

より堅牢に:MessageClass の表記ゆれと高速化

NDR の MessageClass は環境により Report.IPM.Note.NDR、IPM.Note.NDR、一部では大文字・小文字やロケール差分が混在します。完全一致よりも「NDR を含む」包含判定で吸収しましょう。また、全件列挙は遅いので Items.Restrict と DASL(MAPI プロパティ)で絞り込むのが実運用では有効です。

// PR_MESSAGE_CLASS (0x001A001E) に対して "NDR" を前方一致/部分一致
Outlook.Items all = inbox.Items;
all.Sort("[ReceivedTime]", true);

// DASL 例:MessageClass に "NDR" を含む
string filter = "@SQL=" +
    "\"http://schemas.microsoft.com/mapi/proptag/0x001A001E\" like '%NDR%'";
Outlook.Items ndrOnly = all.Restrict(filter);

foreach (object it in ndrOnly)
{
    if (it is Outlook.ReportItem rep)
    {
        // 処理…
    }
}
絞り込み方法利点注意点
Items.Restrict(DASL)高速・安定。MAPI プロパティで厳密に検索可能フィルタ文字列のクォートやワイルドカードに注意
件名の部分一致簡単に導入可能言語依存(「Undeliverable:」「配信不能」など揺れる)
型判定(ReportItem)NDR 系を広く拾える開封通知等の他レポートも含むため、MessageClass 併用が安全

実運用テンプレート:重複排除・ログ・CSV 安全化まで

以下は「Inbox を NDR に限定して巡回 → 宛先を抽出 → 重複排除 → CSV に追記 → ログも残す」という運用テンプレートです。Excel 想定で UTF-8(BOM あり) にし、式注入対策、例外時のフォールバックも入れています。

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Outlook = Microsoft.Office.Interop.Outlook;

public class NdrCollector
{
    private static readonly Regex ReDsnRecipient =
        new Regex(@"(?im)^(?:Final-Recipient|Original-Recipient)\s*:\s*[^;]+;\s*(?<addr>[^\s;<>]+@[^\s;<>]+)\s*$");

    private static readonly Regex ReAltRecipient =
        new Regex(@"(?im)^(?:Recipient|受信者|宛先)\s*:\s*(?<addr>[^\s,;:<>]+@[^\s,;:<>]+)");

    private static readonly Regex ReAngle =
        new Regex(@"<(?<addr>[^>]+@[^>]+)>");

    private static readonly Regex ReToLine =
        new Regex(@"(?im)^(?:To|宛先)\s*:\s*(?<addr>[^\s,;:<>]+@[^\s,;:<>]+)");

    public void Run()
    {
        var app = new Outlook.Application();
        var ns  = app.Session;
        var inbox = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
        var items = inbox.Items;
        items.Sort("[ReceivedTime]", true);

        // NDR だけに絞り込む
        string filter = "@SQL=" +
            "\"http://schemas.microsoft.com/mapi/proptag/0x001A001E\" like '%NDR%'";
        var ndrItems = items.Restrict(filter);

        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        string csv = Path.Combine(Environment.CurrentDirectory, "bounced.csv");
        string log = Path.Combine(Environment.CurrentDirectory, "bounced.log");

        using var writer = new StreamWriter(csv, true, new UTF8Encoding(true));
        using var logger = new StreamWriter(log, true, new UTF8Encoding(true));

        foreach (object it in ndrItems)
        {
            try
            {
                if (it is Outlook.ReportItem rep)
                {
                    foreach (var addr in Extract(rep.Body))
                        WriteOnce(addr, rep.Subject, rep.CreationTime, writer, logger, seen);
                }
                else if (it is Outlook.MailItem mail && LooksLikeNdrSubject(mail.Subject))
                {
                    foreach (var addr in Extract(mail.Body))
                        WriteOnce(addr, mail.Subject, mail.ReceivedTime, writer, logger, seen);
                }
            }
            catch (Exception ex)
            {
                logger.WriteLine($"{DateTime.Now:O}\tERROR\t{ex.Message}");
            }
        }
    }

    private static bool LooksLikeNdrSubject(string subject)
        => !string.IsNullOrEmpty(subject) &&
           (subject.StartsWith("Undeliverable", StringComparison.OrdinalIgnoreCase)
         || subject.IndexOf("Delivery Status Notification", StringComparison.OrdinalIgnoreCase) >= 0
         || subject.IndexOf("配信不能", StringComparison.OrdinalIgnoreCase) >= 0
         || subject.IndexOf("Mail delivery failed", StringComparison.OrdinalIgnoreCase) >= 0);

    private static IEnumerable<string> Extract(string body)
    {
        if (string.IsNullOrEmpty(body)) yield break;

        foreach (Match m in ReDsnRecipient.Matches(body))
            yield return m.Groups["addr"].Value;

        foreach (Match m in ReAltRecipient.Matches(body))
            yield return m.Groups["addr"].Value;

        foreach (Match m in ReAngle.Matches(body))
            yield return m.Groups["addr"].Value;

        var to = ReToLine.Match(body);
        if (to.Success) yield return to.Groups["addr"].Value;
    }

    private static void WriteOnce(
        string addr, string subject, DateTime time,
        StreamWriter csv, StreamWriter log, HashSet<string> seen)
    {
        if (string.IsNullOrWhiteSpace(addr)) return;

        // 簡易バリデーション
        if (!Regex.IsMatch(addr, @"^[^@\s]+@[^@\s]+$")) return;

        if (seen.Add(addr))
        {
            string safe = SanitizeCsv(addr);
            csv.WriteLine(safe);

            log.WriteLine($"{DateTime.Now:O}\tOK\t{addr}\t{time:yyyy-MM-dd HH:mm:ss}\t{TrimForLog(subject)}");
        }
    }

    private static string SanitizeCsv(string value)
        => Regex.IsMatch(value, @"^[=\+\-@]") ? "'" + value : value;

    private static string TrimForLog(string s)
        => string.IsNullOrEmpty(s) ? "" : (s.Length > 120 ? s.Substring(0, 120) + "…" : s);
}

フォールバック強化:添付の元メッセージから宛先を拾う

多くの NDR には「元メール(message/rfc822)」が添付されます。Outlook Object Model では MIME タイプは見えませんが、埋め込みアイテム(olEmbeddeditem)として添付される場合があり、これを一時保存し OpenSharedItem で開けば、MailItem.Recipients を直接参照できます。

private static IEnumerable<string> ExtractFromEmbeddedOriginal(Outlook.ReportItem rep, Outlook.Application app)
{
    foreach (Outlook.Attachment att in rep.Attachments)
    {
        if (att.Type == Outlook.OlAttachmentType.olEmbeddeditem)
        {
            string tmp = Path.GetTempFileName() + ".msg";
            att.SaveAsFile(tmp);

            object opened = app.Session.OpenSharedItem(tmp);
            if (opened is Outlook.MailItem original)
            {
                foreach (Outlook.Recipient rc in original.Recipients)
                {
                    if (!string.IsNullOrEmpty(rc.Address))
                        yield return rc.Address;
                }
            }
            try { File.Delete(tmp); } catch { /* no-op */ }
        }
    }
}

この手法は本文の表現がカスタム テンプレートで変わる環境でも強力に機能します(ただし添付がない NDR では効かないため、本文解析との併用が安全です)。

件名・本文の揺れに備える実践パターン集

ソース判定・抽出例信頼度補足
MessageClassContains("NDR")(大文字小文字無視)高最優先。開封通知と区別のため「NDR」を明示
件名Undeliverable: / Delivery Status Notification (Failure) / 配信不能中ロケール依存。補助として
本文(DSN)Final-Recipient: / Original-Recipient:高宛先抽出はここが最も正確
本文(自然文)「Delivery to <[email protected]> failed」など中角括弧内の正規表現で拾う
本文(To 行)To: [email protected]低〜中環境によっては送信者自身になる。最終手段

セキュリティ プロンプトと運用上の注意

  • プログラムによるアクセスの警告:Outlook のセキュリティ ポリシーでプロンプトが出る場合があります。企業環境では管理ポリシーで許可するか、MAPI/Redemption 等の代替ライブラリを検討します。
  • 大量メールの処理:Items.Restrict を必ず使用。対象期間(受信日時)で絞り、1 回のバッチで処理する件数を制限すると安定します。
  • Excel で開く CSV:UTF-8(BOM あり)が無難。先頭が「=」「+」「-」「@」の値はプレフィックスに ' を付けて式解釈を抑止します(上記コード参照)。
  • Bitness(32/64bit):開発機と実行機で Office のビット数が異なると参照設定でビルドが通らないことがあるため、AnyCPU+COM 参照を適切に設定。
  • IMAP/POP と Exchange:NDR のフォーマットはサーバ側で差が出ます。本文の DSN と添付の両取りでカバー範囲を広げます。

リアルタイム処理:受信時に自動で CSV 追記する

バッチではなく受信タイミングで即時追記したい場合は、Items.ItemAdd または Application.NewMailEx を使います。

public class NdrWatcher
{
    private Outlook.Items _inboxItems;
    private StreamWriter _writer;
    private Outlook.Application _app;

    public void Start()
    {
        _app = new Outlook.Application();
        var ns = _app.Session;
        var inbox = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
        _inboxItems = inbox.Items;
        _inboxItems.ItemAdd += OnItemAdd;

        _writer = new StreamWriter("bounced.csv", true, new UTF8Encoding(true));
    }

    private void OnItemAdd(object item)
    {
        try
        {
            if (item is Outlook.ReportItem rep &amp;&amp; rep.MessageClass?.IndexOf("NDR", StringComparison.OrdinalIgnoreCase) &gt;= 0)
            {
                foreach (var addr in Extract(rep.Body))
                    _writer.WriteLine(addr);
                _writer.Flush();
            }
            else if (item is Outlook.MailItem mail &amp;&amp; LooksLikeNdrSubject(mail.Subject))
            {
                foreach (var addr in Extract(mail.Body))
                    _writer.WriteLine(addr);
                _writer.Flush();
            }
        }
        catch { /* ログ推奨 */ }
    }
}

NewMailEx を使う方式は EntryID からアイテムを取り出す追加コストがある反面、迷惑メール フォルダなど複数フォルダを横断したいケースで有用です。

正規表現のチューニング例

国際化メール(IDN)やプラスアドレッシング([email protected])を許容するために、抽出正規表現の字句範囲を広めに取るのがコツです。以下はより寛容な例です。

// 「ほぼ何でもあり」を許容しつつ &lt; &gt; , ; 空白 を除外
private static readonly Regex ReLaxEmail =
    new Regex(@"[^\s&lt;&gt;,;:@]+(?:\.[^\s&lt;&gt;,;:@]+)*@[^\s&lt;&gt;,;:@]+\.[^\s&lt;&gt;,;:@]+");

// 本文から一括抽出し、重複を削るユーティリティ
static IEnumerable&lt;string&gt; ExtractAllEmails(string body)
{
    var seen = new HashSet&lt;string&gt;(StringComparer.OrdinalIgnoreCase);
    foreach (Match m in ReLaxEmail.Matches(body ?? ""))
    {
        if (seen.Add(m.Value)) yield return m.Value;
    }
}

よくある落とし穴と対策

  • 「SenderEmailAddress が空」:NDR はシステムが作るレポートなので、送信者アドレスは信頼できません。本文の DSN フィールドから宛先を取るのが正攻法です。
  • 「件名だけに依存」:管理者がテンプレートを変更している環境では全く一致しないことがあります。MessageClass での判定を必ず併用。
  • 「NDR 以外のレポートを誤検出」:開封通知等(IPNRN/IPNNRN)も ReportItem。MessageClass に NDR を含むかどうかで絞り込みましょう。
  • 「日本語&英語が混在」:本文が多言語混在でも DSN のヘッダ行は英語固定のことが多く、Final-Recipient など英語キーでのパースが安定します。
  • 「CSV が文字化け」:Excel での既定動作に合わせて UTF-8(BOM あり)で出力。既存ファイルへ追記時も同一エンコーディングを維持。

検証チェックリスト

  • Inbox 直下・サブフォルダ・迷惑メール・ルール移動済みの各場所で NDR が拾えるか。
  • 件名が「Undeliverable:」「配信不能」「Delivery Status Notification (Failure)」など複数パターンで抽出できるか。
  • Report.IPM.Note.NDR と IPM.Note.NDR の双方で動作するか。
  • 添付の元メッセージ(埋め込みアイテム)からの抽出フォールバックが機能するか。
  • CSV への重複書き込みがないか(HashSet による排除)。
  • Excel で開いたときに先頭記号による式解釈が起きないか(SanitizeCsv の適用)。

FAQ

Q. 「To:」から抽出したら自分のアドレスが入ってしまいます。
A. DSN の仕様上、NDR 自体の宛先は「元の送信者(あなた)」です。宛先(失敗したアドレス)は Final-Recipient/Original-Recipient、または本文記述・添付の元メッセージから取る必要があります。本記事の順序(DSN > 角括弧内 > To)に従ってください。

Q. 件名が全く違う(社内カスタム)環境です。
A. 件名に頼らず MessageClass の NDR 含有で判定してください。Items.Restrict が最も安定します。

Q. 1 日あたり数十万件を捌きたいです。
A. バッチでは 1 回の範囲(日時)で Restrict を掛け、ReceivedTime でソート。並列化は COM オブジェクトのスレッド境界に注意が必要なため推奨しません。フォルダ単位の分割や、Graph/EWS 等のサーバ側 API での取得も検討を。

まとめ

「NDR が CSV に保存されない」最大の原因は、NDR を MailItem と誤解していることです。ReportItem として受け・MessageClass による NDR 判定を行い、本文の DSN フィールドを優先して宛先を抽出、CSV は UTF-8(BOM あり)で安全に追記する――この 4 点を実装すれば、言語・環境の差分にも強い堅牢なエクスポートが実現します。最後に、実装の「核」となる最小抜粋を再掲します。

foreach (object item in inbox.Items)
{
    if (item is Outlook.ReportItem rep &amp;&amp; rep.MessageClass?.IndexOf("NDR", StringComparison.OrdinalIgnoreCase) &gt;= 0)
    {
        var m = Regex.Match(rep.Body, @"(?im)^(?:Final-Recipient|Original-Recipient)\s*:\s*[^;]+;\s*([^\s;&lt;&gt;]+@[^\s;&lt;&gt;]+)");
        if (m.Success) writer.WriteLine(m.Groups[1].Value);
    }
    else if (item is Outlook.MailItem mail &amp;&amp; mail.Subject?.StartsWith("Undeliverable:", StringComparison.OrdinalIgnoreCase) == true)
    {
        var m = Regex.Match(mail.Body ?? "", @"(?im)^To:\s*([^\s,;:&lt;&gt;]+@[^\s,;:&lt;&gt;]+)");
        if (m.Success) writer.WriteLine(m.Groups[1].Value);
    }
}

上記をベースに、本文の追加パターンや添付解析、Items.Restrict のフィルタでチューニングしていけば、NDR の取りこぼしは確実に減らせます。現場で困っている「スキップ問題」は、この設計変更だけで解消できるはずです。

付録:簡易テスト用のダミー実装(ワンクリック検証)

動作確認だけ行いたい場合の、最短・一体型のプログラム例です。エントリポイントを差し替えるだけで即検証できます。

using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using Outlook = Microsoft.Office.Interop.Outlook;

class Program
{
    static void Main()
    {
        var app = new Outlook.Application();
        var ns  = app.Session;
        var inbox = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
        var items = inbox.Items;

        using var writer = new StreamWriter("bounced.csv", true, new UTF8Encoding(true));

        foreach (object it in items)
        {
            if (it is Outlook.ReportItem rep &amp;&amp; rep.MessageClass?.IndexOf("NDR", StringComparison.OrdinalIgnoreCase) &gt;= 0)
            {
                WriteIfFound(writer, rep.Body);
            }
            else if (it is Outlook.MailItem mail &amp;&amp; (mail.Subject?.StartsWith("Undeliverable:", StringComparison.OrdinalIgnoreCase) ?? false))
            {
                WriteIfFound(writer, mail.Body);
            }
        }
    }

    static void WriteIfFound(StreamWriter writer, string body)
    {
        if (string.IsNullOrEmpty(body)) return;

        var m = Regex.Match(body, @"(?im)^(?:Final-Recipient|Original-Recipient)\s*:\s*[^;]+;\s*([^\s;&lt;&gt;]+@[^\s;&lt;&gt;]+)");
        if (!m.Success) m = Regex.Match(body, @"(?im)^To:\s*([^\s,;:&lt;&gt;]+@[^\s,;:&lt;&gt;]+)");

        if (m.Success)
        {
            string addr = m.Groups[1].Value;
            if (!string.IsNullOrWhiteSpace(addr)) writer.WriteLine(addr);
        }
    }
}

この付録コードで CSV にアドレス行が出力されることを確認できれば、先述のテンプレートへ置き換えるだけで本番運用に耐える構成へ移行できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次