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-000000000046 | Outlook テンプレート用とされる 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 相当のファイルを作成する実装を示します。まずはもっとも安全な「別名保存」方式からです。
手順をもう一度簡単にまとめると次のとおりです。
- 入力 .msg を
StgOpenStorageExで開き、IStorageを取得する。 - 出力ファイルを
StgCreateStorageExで作成し、IStorageを取得する。 src.CopyToで中身をまるごとコピーする。- 出力側の
IStorage.SetClassで CLSID を0006F046...に変更する。 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 <> 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.UnicodepStgOptions As IntPtrreserved As IntPtrByRef riid As GuidByRef 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 ストレージ形式にも応用できるので、ぜひこの機会に腰を据えてマスターしてみてください。

コメント