Outlook .msg を StgOpenStorageEx で安全に .oft テンプレート化する方法【CLSID 変更と VB.NET 実装】

Outlook の .msg ファイルを自前で .oft テンプレート化したい――そんなときに使えるのが構造化ストレージ API(StgOpenStorageEx / StgCreateStorageEx)ですが、P/Invoke の宣言を少しでも間違えると AccessViolationException でクラッシュしてしまいます。本記事では、CLSID 変更の仕組みから VB.NET 実装例、よくある落とし穴まで、実務でそのまま使えるレベルで詳しく解説します。

目次

Outlook .msg を .oft テンプレート化したい典型シナリオ

まずは「なぜ .msg → .oft 化したいのか」を整理しておきます。実際の現場では、次のようなケースがよくあります。

  • 営業やサポート部門で、ほぼ同じ内容のメールを多人数が繰り返し送信している。
  • Word / Excel からメールを自動生成して .msg として保存しているが、最終的には Outlook テンプレート (.oft) として再利用したい。
  • 既存の .msg の内容はそのままに、ルート CLSID だけを書き換えてテンプレート扱いさせたい。

Outlook の .oft テンプレートは、件名・本文・宛先などをあらかじめ設定しておき、ダブルクリックですぐ新規メール作成画面を開ける便利な仕組みです。ユーザー操作だけなら Outlook の「名前を付けて保存」で簡単に作れますが、プログラムから一括変換したい場合は、構造化ストレージと CLSID を理解しておく必要があります。

.msg と .oft は「中身ほぼ同じ」でルート CLSID が違う

Outlook の .msg / .oft は、どちらも COM の 構造化ストレージ (Structured Storage) を使ったファイル形式です。ざっくり言うと、1 つのファイルの中に「フォルダー+ファイル」を丸ごと埋め込める仕組みで、ルートに CLSID が埋め込まれています。

ポイントは次の 2 つです。

  • .msg と .oft の違いは、主にルート CLSID と一部 MAPI プロパティにある。
  • 構造化ストレージとして中身を丸ごとコピーし、ルート CLSID だけ変更することができる。

今回焦点になる CLSID を表に整理すると次のようになります。

用途CLSID説明
通常の Outlook メッセージ (.msg)00020D0B-0000-0000-C000-000000000046標準のメールメッセージなどに用いられる CLSID
Outlook テンプレート (.oft)0006F046-0000-0000-C000-000000000046Outlook テンプレート用とされる CLSID

つまり、ルート CLSID を「00020D0B → 0006F046」に変えるのが本記事の主眼です。

StgOpenStorageEx / StgCreateStorageEx の役割と全体像

CLSID を書き換えるためには、ファイルを Structured Storage として扱う必要があります。その入口になるのが次の 2 つの API です。

  • StgOpenStorageEx:既存の構造化ストレージを開く
  • StgCreateStorageEx:新しい構造化ストレージを作成する

この 2 つの API から IStorage インターフェースを取得し、IStorage のメソッドを使ってコピーや CLSID 変更を行います。全体の流れを図にすると、次のようなイメージです。

ステップ処理内容使用 API / メソッド
1入力 .msg を構造化ストレージとして開くStgOpenStorageEx → IStorage (src)
2出力ファイル (.oft) を新規作成StgCreateStorageEx → IStorage (dst)
3中身をまるごとコピーsrc.CopyTo(…, dst)
4出力側の CLSID を変更dst.SetClass(new Guid(“0006F046-…”))
5コミットして閉じるdst.Commit(0)、ReleaseComObject

この「コピーしてから SetClass」という流れは、同一ファイルを書き換える in-place 手法に比べて、失敗しても元ファイルを壊さないので非常に重要です。

AccessViolationException の正体:P/Invoke シグネチャのわずかなズレ

質問で問題になっているのは、VB.NET から StgOpenStorageEx / StgCreateStorageEx を呼んだときに発生する AccessViolationException(保護メモリへの読み書き)です。これは、C# / VB.NET の P/Invoke 宣言が少しでも間違っていると発生しがちな例外です。

よくある原因を一覧にすると次のようになります。

症状主な原因対処
StgOpenStorageEx 呼び出し直後に AccessViolation予約引数が宣言から抜けていて、引数が 1 個ずれている原型どおりに reserved As IntPtr を含める
戻り値が 0 以外(失敗)にもかかわらず、そのまま IStorage として扱おうとして落ちるHRESULT を無視している戻り値をチェックし、失敗時は Marshal.ThrowExceptionForHR などで例外化
ppObject を IntPtr 経由で扱っているうちにクラッシュCOM インターフェースとして扱っていないIStorage を直接受け取り、ReleaseComObject で解放
CLSID を変えたいのにうまくいかないriid に CLSID を渡している(IID と CLSID を混同)riid は常に IID_IStorage。CLSID 変更は IStorage.SetClass で行う

特に多いのが、StgOpenStorageEx の「予約引数」を宣言から省略してしまうパターンです。これにより引数の並びがずれ、C 側は本来 riid である位置を別の値として解釈し、結果として AccessViolation が発生します。

VB.NET での正しい P/Invoke 宣言

それでは、VB.NET での具体的な P/Invoke 宣言例を示します。シグネチャを一度きちんと整えてしまえば、以降は安定して利用できます。


Imports System.Runtime.InteropServices
Imports ComTypes = System.Runtime.InteropServices.ComTypes

Friend Module NativeMethods

    <DllImport("ole32.dll", CharSet:=CharSet.Unicode, PreserveSig:=True)>
    Friend Function StgOpenStorageEx(
        <MarshalAs(UnmanagedType.LPWStr)> pwcsName As String,
        grfMode As UInteger,
        stgfmt As UInteger,
        grfAttrs As UInteger,
        pStgOptions As IntPtr,
        reserved As IntPtr, ' ★予約引数。必ず含める
        ByRef riid As Guid,
        <MarshalAs(UnmanagedType.Interface)> ByRef ppObject As ComTypes.IStorage
    ) As Integer ' HRESULT
    End Function

    <DllImport("ole32.dll", CharSet:=CharSet.Unicode, PreserveSig:=True)>
    Friend Function StgCreateStorageEx(
        <MarshalAs(UnmanagedType.LPWStr)> pwcsName As String,
        grfMode As UInteger,
        stgfmt As UInteger,
        grfAttrs As UInteger,
        pStgOptions As IntPtr,
        pSecurity As IntPtr,
        ByRef riid As Guid,
        <MarshalAs(UnmanagedType.Interface)> ByRef ppObject As ComTypes.IStorage
    ) As Integer ' HRESULT
    End Function

    Friend ReadOnly IID_IStorage As New Guid("0000000B-0000-0000-C000-000000000046")

    ' STGM フラグの一部
    Friend Const STGM_READ As UInteger = &H0UI
    Friend Const STGM_WRITE As UInteger = &H1UI
    Friend Const STGM_READWRITE As UInteger = &H2UI
    Friend Const STGM_SHARE_EXCLUSIVE As UInteger = &H10UI
    Friend Const STGM_CREATE As UInteger = &H1000UI

    ' STGFMT
    Friend Const STGFMT_STORAGE As UInteger = 0UI

End Module

ポイントを整理します。

  • CharSet:=CharSet.Unicode と LPWStr を指定し、Wide 文字列で呼び出す。
  • reserved As IntPtr を必ず宣言に含める(ここを省略しない)。
  • riid には常に IID_IStorage を渡す。
  • ppObject は ComTypes.IStorage として受け取る。
  • 戻り値は HRESULT(Integer)なので、呼び出し側で 0(S_OK)かどうかをチェックする。

安全な「別名保存」方式で CLSID を変更する手順

ここからは、実際に .msg の CLSID を変更して .oft 相当のファイルを作成する実装を示します。まずはもっとも安全な「別名保存」方式からです。

手順をもう一度簡単にまとめると次のとおりです。

  1. 入力 .msg を StgOpenStorageEx で開き、IStorage を取得する。
  2. 出力ファイルを StgCreateStorageEx で作成し、IStorage を取得する。
  3. src.CopyTo で中身をまるごとコピーする。
  4. 出力側の IStorage.SetClass で CLSID を 0006F046... に変更する。
  5. Commit(0) を呼び、COM オブジェクトを解放する。

変換処理の VB.NET 実装例(別名保存)

実際のコード全体を示します。エラーハンドリングや using 的な構造も含めて、そのままプロジェクトに組み込めるレベルのものです。


Imports System
Imports System.Runtime.InteropServices
Imports ComTypes = System.Runtime.InteropServices.ComTypes

Public Class MsgToOftConverter

    Private Shared ReadOnly CLSID_OFT As New Guid("0006F046-0000-0000-C000-000000000046")

    ''' <summary>
    ''' Outlook .msg を .oft 相当のファイルとして保存する
    ''' (内容はそのまま、ルート CLSID だけ変更)
    ''' </summary>
    Public Shared Sub ConvertMsgToOft(msgPath As String, oftPath As String)

        Dim src As ComTypes.IStorage = Nothing
        Dim dst As ComTypes.IStorage = Nothing

        Try
            ' 入力 .msg を開く(読み取り専用で OK)
            Dim hr As Integer = NativeMethods.StgOpenStorageEx(
                msgPath,
                NativeMethods.STGM_READ Or NativeMethods.STGM_SHARE_EXCLUSIVE,
                NativeMethods.STGFMT_STORAGE,
                0UI,
                IntPtr.Zero,
                IntPtr.Zero,
                NativeMethods.IID_IStorage,
                src
            )

            If hr <> 0 Then
                Marshal.ThrowExceptionForHR(hr)
            End If

            ' 出力 .oft(新規ファイル)を作成
            hr = NativeMethods.StgCreateStorageEx(
                oftPath,
                NativeMethods.STGM_CREATE Or NativeMethods.STGM_READWRITE Or NativeMethods.STGM_SHARE_EXCLUSIVE,
                NativeMethods.STGFMT_STORAGE,
                0UI,
                IntPtr.Zero,
                IntPtr.Zero,
                NativeMethods.IID_IStorage,
                dst
            )

            If hr <> 0 Then
                Marshal.ThrowExceptionForHR(hr)
            End If

            ' 中身をまるごとコピー
            ' 第 1 引数:除外する IID の数(0 なら何も除外しない)
            ' 第 2 引数:除外する IID の配列(Nothing で OK)
            ' 第 3 引数:除外するストレージの名前一覧(snb, 今回は不要なので IntPtr.Zero)
            src.CopyTo(0, Nothing, IntPtr.Zero, dst)

            ' ルート CLSID を .oft 用に変更
            dst.SetClass(CLSID_OFT)

            ' 変更をコミット
            dst.Commit(0)

        Finally
            ' COM オブジェクトを解放(重要)
            If dst IsNot Nothing Then
                Marshal.ReleaseComObject(dst)
                dst = Nothing
            End If
            If src IsNot Nothing Then
                Marshal.ReleaseComObject(src)
                src = Nothing
            End If
        End Try

    End Sub

End Class

このメソッドを呼び出すことで、元の .msg はそのまま残しつつ、内容が同じで CLSID だけが変更された .oft 相当ファイルを作成できます。

実装のチェックポイント

上記実装の中で、特に注意したいポイントをあらためて表にまとめます。

ポイントコード上の箇所理由
riid = IID_IStorage を渡すStgOpenStorageEx / StgCreateStorageEx 呼び出しCLSID と IID を混同すると AccessViolation や E_NOINTERFACE の原因になる
ppObject を IStorage で受けるStgOpenStorageEx / StgCreateStorageEx の引数IntPtr で受けて Marshal.GetObjectForIUnknown などを行うより安全でシンプル
HRESULT をチェックするIf hr <> 0 Then Marshal.ThrowExceptionForHR(hr)API 失敗時にそのまま IStorage を触るとクラッシュの元になる
CopyTo → SetClass → Commit の順番変換処理全体中身をコピーした後に CLSID を変え、最後に Commit するのがもっとも安全な流れ
COM オブジェクトを ReleaseComObject で解放Finally ブロックGC 任せにするとハンドルリークやロック解放遅延につながりやすい

in-place(同一ファイル上書き)で CLSID を変更する場合

「別名保存」は安全ですが、要件によっては「同じファイルをその場で書き換えたい」こともあります。その場合は、次のように READWRITE モードで開いて SetClass を呼ぶだけでも実現できます。


Public Shared Sub ChangeMsgClsidInPlace(path As String)

    Dim stg As ComTypes.IStorage = Nothing

    Try
        Dim hr As Integer = NativeMethods.StgOpenStorageEx(
            path,
            NativeMethods.STGM_READWRITE Or NativeMethods.STGM_SHARE_EXCLUSIVE,
            NativeMethods.STGFMT_STORAGE,
            0UI,
            IntPtr.Zero,
            IntPtr.Zero,
            NativeMethods.IID_IStorage,
            stg
        )

        If hr &lt;&gt; 0 Then
            Marshal.ThrowExceptionForHR(hr)
        End If

        ' ルート CLSID の変更のみ
        stg.SetClass(New Guid("0006F046-0000-0000-C000-000000000046"))
        stg.Commit(0)

    Finally
        If stg IsNot Nothing Then
            Marshal.ReleaseComObject(stg)
        End If
    End Try

End Sub

ただし、この in-place 方式には次のようなリスクがあります。

  • 途中でプロセスが落ちたり、ディスクエラーが起きた場合、元ファイルが破損する可能性がある。
  • ユーザーのウイルス対策ソフトやバックアップソフトとの相性によって、排他制御がうまくいかないケースがある。

そのため、基本方針としては次のような戦略をおすすめします。

  • まずは別名保存で .oft を作成し、問題なく開けることを確認してから 元ファイルを削除する。
  • どうしても in-place が必要な場合は、処理前に バックアップコピーを作成しておく。

よくある間違いと対処法まとめ

ここまでの内容を踏まえて、AccessViolation や「変換したけど Outlook でうまく開かない」といった問題の典型例を一覧にしておきます。

現象原因対処
AccessViolationException が即座に発生するStgOpenStorageEx の P/Invoke で予約引数が抜けている、引数順が違うAPI ドキュメントどおりに IntPtr の引数を含めて宣言し直す
COMException (E_NOINTERFACE) が返るriid に CLSID を渡しているなど、IID が間違っているIID_IStorage を渡すように修正する
変換したファイルを開くと「このアイテムは開けません」などのエラーCommit を呼んでいない/途中で例外になり書き込みが中途半端SetClass の後に必ず Commit を呼ぶ。例外時はファイルを破棄する
Outlook で開けるが、テンプレートとして期待どおり動作しないCLSID は変わっているが、MAPI プロパティがテンプレート向けになっていない後述の「MAPI プロパティの調整」を併用する
たまにファイルがロックされていて失敗する別プロセス(Outlook 本体など)が同じ .msg を開いている処理前にファイルが開かれていないか確認し、STGM_SHARE_EXCLUSIVE で開く

.msg → .oft を「より確実」にするための MAPI プロパティ調整

ルート CLSID を変更するだけでも、多くのケースでは「テンプレートのように扱える」ファイルになります。しかし、ケースによっては Outlook が完全にはテンプレート扱いしてくれないことがあります。

より確実に .oft と同等の挙動をさせたい場合、MAPI プロパティの一部も合わせて調整しておくと安心です。代表的なものを挙げます。

  • MSGFLAG_UNSENT(未送信フラグ)を立てる
  • PR_MESSAGE_CLASS を IPM.Note など適切なクラスに設定する
  • ドラフト保存時にだけ付くようなプロパティをクリアする

これらのプロパティ操作は、純粋な構造化ストレージだけではなく、MAPI レベルでの読み書きが必要になります。実装方法としては次のような選択肢があります。

  • Outlook オブジェクトモデルで .msg を開き、MailItem プロパティを操作して SaveAs(..., olTemplate) を使う。
  • MAPI ライブラリ(例:Extended MAPI ラッパー)を利用して直接プロパティを書き換える。

Outlook がインストールされている前提で良いなら、オブジェクトモデルで SaveAs(olTemplate) を使うのが最も簡単かつサポートされている方法です。

Outlook オブジェクトモデルを利用した .oft 作成例

参考までに、VB.NET から Outlook を自動操作して .oft を作成する簡単な例も載せておきます。


Imports Outlook = Microsoft.Office.Interop.Outlook

Public Class OutlookTemplateHelper

    Public Shared Sub SaveMsgAsTemplate(msgPath As String, oftPath As String)
        Dim app As Outlook.Application = Nothing
        Dim item As Object = Nothing

        Try
            app = New Outlook.Application()

            ' .msg を開く
            item = app.CreateItemFromTemplate(msgPath)

            ' メールアイテムとして扱える場合のみテンプレート保存
            Dim mail As Outlook.MailItem = TryCast(item, Outlook.MailItem)
            If mail IsNot Nothing Then
                mail.SaveAs(oftPath, Outlook.OlSaveAsType.olTemplate)
            Else
                Throw New InvalidOperationException("MailItem ではないため .oft に保存できません。")
            End If

        Finally
            If item IsNot Nothing Then
                Marshal.ReleaseComObject(item)
            End If
            If app IsNot Nothing Then
                Marshal.ReleaseComObject(app)
            End If
        End Try
    End Sub

End Class

この方式は Outlook 依存にはなりますが、MAPI プロパティやテンプレートとしての挙動を Outlook に任せられるため、「確実さ」だけで言えば最も安全です。一方、サーバー側処理や Outlook 非インストール環境を想定する場合は、前述の構造化ストレージ+CLSID 変更というアプローチが有効です。

運用に効く実践的な TIPS

最後に、実運用で .msg → .oft 変換を大量に行うときに役立つポイントをいくつか挙げておきます。

STA スレッドで実行する

構造化ストレージ API 自体は必ずしも STA 固定ではありませんが、Outlook オブジェクトモデルや他の COM コンポーネントと組み合わせる場合、STA スレッドで実行しておくとトラブルを避けやすくなります。WinForms / WPF アプリであれば基本的に UI スレッドは STA なので、そのままで問題ないケースが多いです。

バッチ変換時は一度に大量の IStorage を抱え込まない

数百件~数千件単位で .msg → .oft 変換を行う場合、IStorage オブジェクトをため込まず、1 件ずつ開いて変換し、すぐ解放するようにしましょう。単純なコードでも、ReleaseComObject を確実に呼んでいればメモリ使用量は安定しやすくなります。

ログを残しておくとトラブルシュートが楽になる

変換処理をバッチ運用するなら、次のような情報をログに残しておくと、後から原因究明が格段に楽になります。

  • 変換対象ファイルパス (.msg)
  • 出力ファイルパス (.oft)
  • 変換開始・終了時刻
  • HRESULT(失敗時)とその説明
  • AccessViolation など致命的例外のスタックトレース

特に HRESULT は Marshal.ThrowExceptionForHR で例外に変換しても良いですが、元の数値も併記しておくと、MSDN や公式ドキュメントとの突き合わせがしやすくなります。

チェックリスト:実装前にここだけ確認

最後に、本記事で説明した内容を「実装前チェックリスト」として再掲します。コードを書く前に、このチェックリストを一通りチェックしておくと、AccessViolation や微妙なバグをかなり防げます。

  • StgOpenStorageEx / StgCreateStorageEx の P/Invoke 宣言が
    • CharSet.Unicode
    • pStgOptions As IntPtr
    • reserved As IntPtr
    • ByRef riid As Guid
    • ByRef ppObject As IStorage
    の形になっている。
  • riid に渡しているのは IID_IStorage(0000000B-0000-0000-C000-000000000046)である。
  • ppObject を ComTypes.IStorage で受け取っている(IntPtr ではない)。
  • CLSID の変更は IStorage.SetClass で行っており、StgOpenStorageEx の riid で CLSID を渡していない。
  • 変換フローが CopyTo → SetClass → Commit の順番になっている。
  • COM オブジェクトは Marshal.ReleaseComObject で明示的に解放している。
  • 可能なら 別名保存方式で変換し、元 .msg は正常に .oft が生成されたあとで削除している。
  • Outlook 依存を許容できる場合は、オブジェクトモデルの SaveAs(..., olTemplate) も選択肢に含めている。

これらを押さえておけば、StgOpenStorageEx / StgCreateStorageEx を使った .msg → .oft 変換で躓くポイントはほぼ回避できます。構造化ストレージ+CLSID 変更という仕組みさえ理解してしまえば、Outlook 以外の COM ストレージ形式にも応用できるので、ぜひこの機会に腰を据えてマスターしてみてください。

この記事を書いた人

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

コメント

コメントする

目次