【PowerShell】Outlookの.msgをJSONに変換する「MSG Studio v3」── GUIルール編集・一括変換・Jenkins連携

Outlookのメールを問い合わせ票へ転記する際に、チケット番号や問い合わせ者を手作業で探していないでしょうか。この記事では、Outlookの.msgをPowerShellで読み込み、ルールに沿ってJSONに変換するツールを紹介します。設定画面(GUI)で抽出ルールを変更でき、普段の処理は画面を開かずスクリプトモードで実行できます。

公開するコードについて:以下のコードブロックは MSG Studio v3 の最終配布ZIP内のファイルを全文掲載しています。省略・修正していません。.ps1 の原本はShift-JIS(CP932)、この記事のMarkdownとJSONはUTF-8です。記事からコピーして保存する場合、Windows PowerShell 5.1向けの.ps1はCP932で保存してください。

動作検証:本記事のコードは実機Windows上でのGUI・COM動作確認をしていないサンプルです。Classic Outlookが必要で、New Outlook単体では動きません。

何ができる?

機能 内容
GUIルール編集 正規表現・キーワード・直接取得のルールを追加、編集、複数行削除、保存
解析プレビュー メール本文を模したサンプルで抽出を確認
単一ファイル変換 1つの.msgからJSONを生成
フォルダ一括変換 指定フォルダ内の.msgを一括処理
CLI・無人実行 GUIなしで実行、ログと終了コードに対応
プリザンター向け出力 Title、ClassHash、DescriptionHash、Statusなどの構造でJSON化

このツールはJSON生成までです。プリザンターAPIへのPOSTや元の.msgの削除は行いません。

処理の流れ

Outlook .msg
   │
   ▼
Classic Outlook COM(件名・本文・送信者など)
   │
   ▼
rules.json(GUIで管理できる抽出条件)
   │
   ▼
MsgRules.Core.ps1(Regex / Keywords / Literal)
   │
   ├── GUIから個別変換
   └── Run-Script.ps1から一括変換
             │
             ▼
          JSONファイル

動作環境と導入

Windows 11、Windows PowerShell 5.1、Classic Outlookを用意します。標準のWindows Formsを利用し、PowerShell追加モジュールは不要です。ZIP内のファイルはすべて同じフォルダに展開してください。

MSG Studio v3/
├─ Start-GUI.bat
├─ Start-GUI.ps1
├─ Run-Script.bat
├─ Run-Script.ps1
├─ Convert-MsgToJson.ps1
├─ MsgRules.Core.ps1
└─ rules.json

GUIから操作する

Start-GUI.bat を実行します。ルール一覧で抽出元・抽出方式・パターン・出力先を編集します。追加・選択行削除・複数行削除に対応します。「ルールを保存」を押して初めて rules.json に反映されます。解析プレビューで正規表現をテストし、.msg を選択して変換できます。

画面を開かずに変換する

単一ファイルの変換:

powershell.exe -NoProfile -File .\Run-Script.ps1 -Path 'C:\mail est.msg' -PleasanterPayload

フォルダ一括変換とログ保存:

powershell.exe -NoProfile -File .\Run-Script.ps1 -Path 'C:\mail\inbox' -OutputDirectory 'C:\mail\json' -PleasanterPayload -Overwrite -LogPath 'C:\mail\logs
un.log'

サブフォルダも対象にする場合:

powershell.exe -NoProfile -File .\Run-Script.ps1 -Path 'C:\mail\inbox' -Recurse -OutputDirectory 'C:\mail\json'

既存JSONは原則スキップし、-Overwrite で上書きします。終了コードは 0 正常、1 ファイル単位の失敗、2 初期化・設定失敗です。Jenkinsからは終了コードで判定できます。Outlook COMを使うため、対話型のWindowsログオン環境で実行してください。

抽出ルールの考え方

rules.json の source は Subject / Body / Subject+Body / SenderName / SenderEmailAddress / To / ReceivedTime に対応します。mode は Regex(正規表現)・Keywords(|で区切った候補)・Literal(テキスト全文)の3種類です。

group には正規表現の取得グループ番号を指定できます。target は Title、ClassA~ClassZ、DescriptionA~DescriptionZ、DateA~DateZ、Ignore です。未一致時の fallback には $BODY、$SUBJECT、$SENDER などを使えます。

設定の変更は基本的にGUIで行い、日常実行にはRun-Script.ps1を使うと、ルールと処理を分離できます。

ソースコード全文(最終版と同一)

以下は配布ZIPから読み取った全実装ファイルです。短縮した擬似コードではありません。コピーしたファイル名とエンコーディングに注意してください。

rules.json(全文)

{
  "version": 1,
  "siteId": 12345,
  "defaultStatus": 100,
  "includeOriginalBody": true,
  "rules": [
    {
      "enabled": true,
      "name": "チケット番号",
      "source": "Subject+Body",
      "mode": "Regex",
      "pattern": "\\b(?:INC|REQ|TCK)-\\d{4}-\\d+\\b",
      "group": 0,
      "target": "ClassA",
      "fallback": ""
    },
    {
      "enabled": true,
      "name": "問い合わせ者",
      "source": "Body",
      "mode": "Regex",
      "pattern": "(?m)^問い合わせ者[::]\\s*(.+?)\\s*$",
      "group": 1,
      "target": "ClassB",
      "fallback": ""
    },
    {
      "enabled": true,
      "name": "問い合わせ内容",
      "source": "Body",
      "mode": "Regex",
      "pattern": "(?ms)^問い合わせ内容[::]\\s*(.*?)(?=^連絡先[::]|\\z)",
      "group": 1,
      "target": "DescriptionA",
      "fallback": "$BODY"
    },
    {
      "enabled": true,
      "name": "抽出キーワード",
      "source": "Subject+Body",
      "mode": "Keywords",
      "pattern": "障害|ログイン|アカウント|権限|至急|エラー",
      "group": 0,
      "target": "ClassD",
      "fallback": ""
    }
  ]
}

MsgRules.Core.ps1(全文)

function Get-RuleSource {
 param($Mail,[string]$Source)
 switch ($Source) {
   'Subject' { return [string]$Mail.Subject }
   'Body' { return [string]$Mail.Body }
   'SenderName' { return [string]$Mail.SenderName }
   'SenderEmailAddress' { return [string]$Mail.SenderEmailAddress }
   'To' { return [string]$Mail.To }
   'ReceivedTime' { return [string]$Mail.ReceivedTime }
   'Subject+Body' { return ([string]$Mail.Subject + "`n" + [string]$Mail.Body) }
   default { throw "Unknown source: $Source" }
 }
}
function Apply-ExtractionRule {
 param($Mail,$Rule)
 $text=Get-RuleSource $Mail ([string]$Rule.source)
 $value=''
 switch ([string]$Rule.mode) {
  'Regex' {
   $m=[regex]::Match($text,[string]$Rule.pattern,'IgnoreCase')
   if ($m.Success) {
    $group=[int]$Rule.group
    if ($group -ge 0 -and $group -lt $m.Groups.Count) { $value=$m.Groups[$group].Value.Trim() }
   }
  }
  'Keywords' {
   $hits=@()
   foreach ($word in ([string]$Rule.pattern -split '\|')) {
    $word=$word.Trim()
    if ($word -and $text.IndexOf($word,[StringComparison]::OrdinalIgnoreCase) -ge 0) { $hits += $word }
   }
   $value=($hits -join '、')
  }
  'Literal' { $value=$text.Trim() }
  default { throw "Unknown mode: $($Rule.mode)" }
 }
 if (-not $value) {
  $fallback=[string]$Rule.fallback
  switch ($fallback) {
    '$BODY' { $value=[string]$Mail.Body }
    '$SUBJECT' { $value=[string]$Mail.Subject }
    '$SENDER' { $value=[string]$Mail.SenderName }
    default { $value=$fallback }
  }
 }
 return $value
}
function Convert-MailData {
 param($Mail,$Config,[switch]$PleasanterPayload)
 $extracted=[ordered]@{}
 $classHash=[ordered]@{}; $descriptionHash=[ordered]@{}; $dateHash=[ordered]@{}
 foreach ($rule in @($Config.rules)) {
   if (-not $rule.enabled) { continue }
   $value=Apply-ExtractionRule $Mail $rule
   $extracted[[string]$rule.name]=$value
   $target=[string]$rule.target
   if ($target -match '^Class[A-Z]$') { $classHash[$target]=$value }
   elseif ($target -match '^Description[A-Z]$') { $descriptionHash[$target]=$value }
   elseif ($target -match '^Date[A-Z]$') { if ($value) { $dateHash[$target]=$value } }
   elseif ($target -eq 'Title') { } # Title is handled below
   elseif ($target -eq 'Ignore') { }
   else { throw "Target must be ClassA..Z, DescriptionA..Z, DateA..Z, Title, or Ignore: $target" }
 }
 if ($PleasanterPayload) {
   $title=[string]$Mail.Subject
   foreach ($rule in @($Config.rules)) {
     if ($rule.enabled -and $rule.target -eq 'Title') { $title=[string]$extracted[[string]$rule.name] }
   }
   $result=[ordered]@{ Title=$title; ClassHash=$classHash; DescriptionHash=$descriptionHash; Status=[int]$Config.defaultStatus }
   if ($dateHash.Count) { $result['DateHash']=$dateHash }
   # These are payload fields, not a complete API request (ApiKey is not included).
   return $result
 }
 return [ordered]@{ Mail=$Mail; Extracted=$extracted }
}

Convert-MsgToJson.ps1(全文)

[CmdletBinding()]
param(
  [Parameter(Mandatory)][string]$Path,
  [string]$RulesPath = (Join-Path $PSScriptRoot 'rules.json'),
  [string]$OutputDirectory = '',
  [switch]$PleasanterPayload,
  [switch]$PassThru
)
$ErrorActionPreference='Stop'
. (Join-Path $PSScriptRoot 'MsgRules.Core.ps1')
$config = Get-Content -LiteralPath $RulesPath -Raw -Encoding UTF8 | ConvertFrom-Json
$files = if (Test-Path -LiteralPath $Path -PathType Container) {
  @(Get-ChildItem -LiteralPath $Path -Filter '*.msg' -File)
} else { @(Get-Item -LiteralPath $Path) }
if (-not $files.Count) { throw 'MSG files were not found.' }
$outlook = $null
try {
  $outlook = New-Object -ComObject Outlook.Application
  foreach ($file in $files) {
    $msg = $null
    try {
      if ($file.Extension -ine '.msg') { throw 'Only .msg files are supported.' }
      $msg = $outlook.Session.OpenSharedItem($file.FullName)
      if ([int]$msg.Class -ne 43) { throw 'Not an Outlook MailItem.' }
      $received = ''
      try { $received=([datetime]$msg.ReceivedTime).ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
      $mailData = [ordered]@{
        FileName=$file.Name; Subject=[string]$msg.Subject; Body=[string]$msg.Body
        SenderName=[string]$msg.SenderName; SenderEmailAddress=[string]$msg.SenderEmailAddress
        To=[string]$msg.To; ReceivedTime=$received
      }
      $result = Convert-MailData -Mail $mailData -Config $config -PleasanterPayload:$PleasanterPayload
      $directory = if ($OutputDirectory) { $OutputDirectory } else { $file.DirectoryName }
      [IO.Directory]::CreateDirectory($directory) | Out-Null
      $suffix = if ($PleasanterPayload) { '.pleasanter.json' } else { '.json' }
      $output = Join-Path $directory ($file.BaseName+$suffix)
      [IO.File]::WriteAllText($output, ($result | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding $false))
      Write-Host "Saved: $output"
      if ($PassThru) { $result }
    } catch { Write-Error "Error in $($file.FullName): $($_.Exception.Message)" -ErrorAction Continue }
    finally { if ($msg) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($msg) } }
  }
} finally { if ($outlook) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($outlook) } }

Run-Script.ps1(全文)

#Requires -Version 5.1
[CmdletBinding()]
param(
 [Parameter(Mandatory)][string]$Path,
 [string]$RulesPath = (Join-Path $PSScriptRoot 'rules.json'),
 [string]$OutputDirectory = '',
 [switch]$PleasanterPayload,
 [switch]$Recurse,
 [switch]$Overwrite,
 [string]$LogPath = '',
 [switch]$Quiet
)
$ErrorActionPreference = 'Stop'
$success = 0; $failed = 0; $skipped = 0
function Log([string]$level, [string]$message) {
 $line = ('{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$level,$message)
 if ($LogPath) { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 }
 if (-not $Quiet -or $level -eq 'ERROR') { if ($level -eq 'ERROR') { [Console]::Error.WriteLine($line) } else { Write-Host $line } }
}
try {
 . (Join-Path $PSScriptRoot 'MsgRules.Core.ps1')
 if (-not (Test-Path -LiteralPath $RulesPath -PathType Leaf)) { throw "ルールファイルがありません: $RulesPath" }
 $config = Get-Content -LiteralPath $RulesPath -Raw -Encoding UTF8 | ConvertFrom-Json
 if (-not $config.rules) { throw 'rules.json にルールがありません。' }
 if (-not (Test-Path -LiteralPath $Path)) { throw "入力パスがありません: $Path" }
 $source = (Resolve-Path -LiteralPath $Path).Path
 if ((Get-Item -LiteralPath $source).PSIsContainer) {
   $files = @(Get-ChildItem -LiteralPath $source -File -Filter '*.msg' -Recurse:$Recurse)
 } else {
   if ([IO.Path]::GetExtension($source) -ine '.msg') { throw '入力ファイルは .msg を指定してください。' }
   $files = @(Get-Item -LiteralPath $source)
 }
 if ($files.Count -eq 0) { throw '処理対象の.msgがありません。' }
 if ($OutputDirectory) { [void][IO.Directory]::CreateDirectory($OutputDirectory) }
 if ($LogPath) {
   $logDir = Split-Path -Parent $LogPath
   if ($logDir) { [void][IO.Directory]::CreateDirectory($logDir) }
 }
 $outlook = $null
 try {
   $outlook = New-Object -ComObject Outlook.Application
   foreach ($file in $files) {
     $mail = $null
     try {
       $outdir = if ($OutputDirectory) { $OutputDirectory } else { $file.DirectoryName }
       $suffix = if ($PleasanterPayload) { '.pleasanter.json' } else { '.json' }
       $dest = Join-Path $outdir ($file.BaseName + $suffix)
       if ((Test-Path -LiteralPath $dest) -and -not $Overwrite) {
         $skipped++; Log 'SKIP' "出力先が存在: $dest (上書きには -Overwrite)"; continue
       }
       $mail = $outlook.Session.OpenSharedItem($file.FullName)
       if ([int]$mail.Class -ne 43) { throw 'Outlook MailItemではありません。' }
       $received = ''
       try { $received = ([datetime]$mail.ReceivedTime).ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
       $mailData = [ordered]@{
         FileName=$file.Name; Subject=[string]$mail.Subject; Body=[string]$mail.Body
         SenderName=[string]$mail.SenderName; SenderEmailAddress=[string]$mail.SenderEmailAddress
         To=[string]$mail.To; ReceivedTime=$received
       }
       $result = Convert-MailData -Mail $mailData -Config $config -PleasanterPayload:$PleasanterPayload
       $json = ConvertTo-Json -InputObject $result -Depth 30
       [IO.File]::WriteAllText($dest,$json,(New-Object Text.UTF8Encoding $false))
       $success++; Log 'OK' "$($file.Name) => $dest"
     } catch { $failed++; Log 'ERROR' "$($file.FullName): $($_.Exception.Message)" }
     finally { if ($mail) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($mail) } }
   }
 } finally { if ($outlook) { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($outlook) } }
 Log 'SUMMARY' "成功=$success 失敗=$failed スキップ=$skipped"
 if ($failed -gt 0) { exit 1 }
 exit 0
} catch { Log 'ERROR' $_.Exception.Message; exit 2 }

Start-GUI.ps1(全文)

#Requires -Version 5.1
param([string]$RulesPath=(Join-Path $PSScriptRoot 'rules.json'))
$ErrorActionPreference='Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
. (Join-Path $PSScriptRoot 'MsgRules.Core.ps1')
if ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') {
 [Windows.Forms.MessageBox]::Show('STA が必要です。Start-GUI.bat から起動してください。') | Out-Null
 return
}
$form=New-Object Windows.Forms.Form
$form.Text='MSG Studio  |  Outlook to JSON'
$form.Size=New-Object Drawing.Size(1210,840)
$form.StartPosition='CenterScreen'
$form.MinimumSize=New-Object Drawing.Size(1080,740)
$font=New-Object Drawing.Font('Meiryo UI',9)
$bg=[Drawing.Color]::FromArgb(17,24,39)
$surface=[Drawing.Color]::FromArgb(31,41,55)
$surface2=[Drawing.Color]::FromArgb(45,55,72)
$fg=[Drawing.Color]::FromArgb(241,245,249)
$muted=[Drawing.Color]::FromArgb(156,163,175)
$accent=[Drawing.Color]::FromArgb(79,135,247)
$form.BackColor=$bg
$form.ForeColor=$fg
$form.AutoScaleMode='Dpi'
$form.Font=$font
$grid=New-Object Windows.Forms.DataGridView
$grid.Location=New-Object Drawing.Point(22,148)
$grid.Size=New-Object Drawing.Size(1148,292)
$grid.Anchor='Top,Left,Right'
$grid.BackgroundColor=$surface
$grid.BorderStyle='None'
$grid.GridColor=$surface2
$grid.EnableHeadersVisualStyles=$false
$grid.ColumnHeadersDefaultCellStyle.BackColor=$surface2
$grid.ColumnHeadersDefaultCellStyle.ForeColor=$fg
$grid.ColumnHeadersDefaultCellStyle.SelectionBackColor=$surface2
$grid.ColumnHeadersHeight=40
$grid.RowTemplate.Height=32
$grid.DefaultCellStyle.BackColor=$surface
$grid.DefaultCellStyle.ForeColor=$fg
$grid.DefaultCellStyle.SelectionBackColor=$accent
$grid.DefaultCellStyle.SelectionForeColor=[Drawing.Color]::White
$grid.AlternatingRowsDefaultCellStyle.BackColor=[Drawing.Color]::FromArgb(36,46,61)
$grid.AlternatingRowsDefaultCellStyle.ForeColor=$fg
$grid.SelectionMode='FullRowSelect'
$grid.MultiSelect=$true
$grid.AllowUserToAddRows=$true
$grid.AllowUserToDeleteRows=$false
$grid.AutoSizeColumnsMode='Fill'
$grid.RowHeadersVisible=$false
$grid.Columns.Add((New-Object Windows.Forms.DataGridViewCheckBoxColumn -Property @{Name='enabled';HeaderText='有効';FillWeight=40})) | Out-Null
$grid.Columns.Add('name','ルール名') | Out-Null
foreach ($name in @('source','mode','target')) {
 $col=New-Object Windows.Forms.DataGridViewComboBoxColumn
 $col.Name=$name
 $col.HeaderText=@{source='解析対象';mode='種類';target='出力先'}[$name]
 $col.FlatStyle='Flat'
 $values=switch($name){
  source {@('Subject','Body','Subject+Body','SenderName','SenderEmailAddress','To','ReceivedTime')}
  mode {@('Regex','Keywords','Literal')}
  target {@('Title','Ignore')}
 }
 if ($name -eq 'target') { $values=@('Title','Ignore'); foreach($prefix in @('Class','Description','Date')){foreach($code in 65..90){$values+=($prefix+[char]$code)}} }
 foreach ($v in $values) { [void]$col.Items.Add([string]$v) }
 [void]$grid.Columns.Add($col)
}
$grid.Columns.Add('pattern','正規表現 / キーワード(|区切り)') | Out-Null
$grid.Columns.Add('group','取得グループ') | Out-Null
$grid.Columns.Add('fallback','不一致時($BODY 等)') | Out-Null
$grid.Columns['pattern'].FillWeight=190
$grid.Columns['name'].FillWeight=90
$grid.Columns['source'].FillWeight=100
$grid.Columns['mode'].FillWeight=75
$grid.Columns['target'].FillWeight=90
$grid.Columns['group'].FillWeight=52
$grid.Columns['fallback'].FillWeight=90
$form.Controls.Add($grid)
$lbl=New-Object Windows.Forms.Label
$lbl.Text='EXTRACTION RULES  /  上から順に評価 ・ Ctrl/Shiftで複数選択 ・ Regexのgroup=0は全体一致'
$lbl.Location=New-Object Drawing.Point(22,121);$lbl.AutoSize=$true;$lbl.ForeColor=$muted;$form.Controls.Add($lbl)
$heading=New-Object Windows.Forms.Label
$heading.Text='MSG Studio'
$heading.Font=New-Object Drawing.Font('Meiryo UI',19,[Drawing.FontStyle]::Bold)
$heading.ForeColor=$fg
$heading.Location=New-Object Drawing.Point(22,16)
$heading.AutoSize=$true
$form.Controls.Add($heading)
$subtitle=New-Object Windows.Forms.Label
$subtitle.Text='Outlookメールを解析し、Pleasanter用JSONへ変換'
$subtitle.ForeColor=$muted
$subtitle.Location=New-Object Drawing.Point(24,58)
$subtitle.AutoSize=$true
$form.Controls.Add($subtitle)
$siteLabel=New-Object Windows.Forms.Label -Property @{Text='Site ID';Location=(New-Object Drawing.Point(26,458));AutoSize=$true}
$site=New-Object Windows.Forms.NumericUpDown -Property @{Location=(New-Object Drawing.Point(88,452));Width=105;Minimum=1;Maximum=999999999}
$statusLabel=New-Object Windows.Forms.Label -Property @{Text='初期Status';Location=(New-Object Drawing.Point(226,458));AutoSize=$true}
$status=New-Object Windows.Forms.NumericUpDown -Property @{Location=(New-Object Drawing.Point(320,452));Width=75;Minimum=0;Maximum=999999}
foreach ($x in @($siteLabel,$site,$statusLabel,$status)) { $form.Controls.Add($x) }
function New-Button($title,$x,$y,$width,$callback) {
 $b=New-Object Windows.Forms.Button
 $b.Text=$title;$b.Location=New-Object Drawing.Point($x,$y);$b.Size=New-Object Drawing.Size($width,30)
 $b.FlatStyle='Flat'
 $b.FlatAppearance.BorderSize=0
 $b.BackColor=$surface2
 $b.ForeColor=$fg
 $b.Cursor=[Windows.Forms.Cursors]::Hand
 $b.Add_Click($callback);$form.Controls.Add($b);return $b
}
$sample=New-Object Windows.Forms.TextBox
$sample.Multiline=$true;$sample.ScrollBars='Vertical'
$sample.Location=New-Object Drawing.Point(22,544);$sample.Size=New-Object Drawing.Size(548,230)
$sample.Anchor='Top,Bottom,Left';$sample.BackColor=$surface;$sample.ForeColor=$fg;$sample.BorderStyle='FixedSingle';$sample.Text="件名:【障害】ログインできない INC-2026-001`r`n問い合わせ者:山田太郎`r`n問い合わせ内容:`r`nログイン時にエラーが発生。至急対応をお願いします。"
$form.Controls.Add($sample)
$preview=New-Object Windows.Forms.TextBox
$preview.Multiline=$true;$preview.ScrollBars='Both';$preview.WordWrap=$false
$preview.Location=New-Object Drawing.Point(584,544);$preview.Size=New-Object Drawing.Size(586,230)
$preview.Anchor='Top,Bottom,Left,Right';$preview.BackColor=$surface;$preview.ForeColor=$fg;$preview.BorderStyle='FixedSingle';$preview.ReadOnly=$true;$form.Controls.Add($preview)
$form.Controls.Add((New-Object Windows.Forms.Label -Property @{Text='テスト本文(先頭の 件名: は件名として扱います)';Location=(New-Object Drawing.Point(22,514));AutoSize=$true}))
$form.Controls.Add((New-Object Windows.Forms.Label -Property @{Text='解析プレビュー(APIキーは含みません)';Location=(New-Object Drawing.Point(584,514));AutoSize=$true}))
function Read-Rules {
 $data=Get-Content -LiteralPath $RulesPath -Raw -Encoding UTF8 | ConvertFrom-Json
 $grid.Rows.Clear();$site.Value=[decimal]$data.siteId;$status.Value=[decimal]$data.defaultStatus
 foreach ($r in $data.rules) { [void]$grid.Rows.Add([bool]$r.enabled,[string]$r.name,[string]$r.source,[string]$r.mode,[string]$r.target,[string]$r.pattern,[string]$r.group,[string]$r.fallback) }
}
function Build-Rules {
 $grid.EndEdit()
 $rules=@()
 foreach ($row in $grid.Rows) {
  if ($row.IsNewRow) {continue}
  $v=@{};foreach($key in @('enabled','name','source','mode','target','pattern','group','fallback')){$v[$key]=$row.Cells[$key].Value}
  if (-not $v.name) {throw 'ルール名は必須です。'}
  if (-not $v.source -or -not $v.mode -or -not $v.target) {throw "ルール「$($v.name)」の種類・出力先が未指定です。"}
  if ($v.mode -eq 'Regex') { [void][regex]::new([string]$v.pattern) }
  $rules+= [ordered]@{enabled=[bool]$v.enabled;name=[string]$v.name;source=[string]$v.source;mode=[string]$v.mode;pattern=[string]$v.pattern;group=if($v.group){[int]$v.group}else{0};target=[string]$v.target;fallback=[string]$v.fallback}
 }
 return [ordered]@{version=1;siteId=[int]$site.Value;defaultStatus=[int]$status.Value;includeOriginalBody=$true;rules=$rules}
}
$loadBtn=New-Button '再読込' 22 86 104 {try { Read-Rules } catch {[Windows.Forms.MessageBox]::Show($_.Exception.Message)|Out-Null}}
$saveBtn=New-Button 'ルールを保存' 244 86 148 {
 try { $data=Build-Rules; $json=ConvertTo-Json -InputObject $data -Depth 15;[IO.File]::WriteAllText($RulesPath,$json,(New-Object Text.UTF8Encoding $false));[Windows.Forms.MessageBox]::Show('rules.json に保存しました。')|Out-Null }
 catch{[Windows.Forms.MessageBox]::Show($_.Exception.Message)|Out-Null}
}
$previewBtn=New-Button '解析プレビュー' 405 86 148 {
 try {
  $data=Build-Rules
  $body=$sample.Text;$subject=''
  if ($body -match '(?m)^件名[::]\s*(.*)') {$subject=$Matches[1];$body=($body -replace '(?m)^件名[::]\s*.*\r?\n?','')}
  $mail=[ordered]@{Subject=$subject;Body=$body;SenderName='';SenderEmailAddress='';To='';ReceivedTime=''}
  $result=Convert-MailData $mail $data -PleasanterPayload
  $preview.Text=ConvertTo-Json -InputObject $result -Depth 20
 } catch{[Windows.Forms.MessageBox]::Show($_.Exception.Message)|Out-Null}
}
$convertBtn=New-Button 'MSGを選択してJSON出力' 566 86 232 {
 try {
  $data=Build-Rules
  $json=ConvertTo-Json -InputObject $data -Depth 15
  [IO.File]::WriteAllText($RulesPath,$json,(New-Object Text.UTF8Encoding $false))
  $dialog=New-Object Windows.Forms.OpenFileDialog
  $dialog.Filter='Outlook MSG (*.msg)|*.msg';$dialog.Multiselect=$true
  if($dialog.ShowDialog() -ne 'OK'){return}
  foreach($file in $dialog.FileNames){& (Join-Path $PSScriptRoot 'Convert-MsgToJson.ps1') -Path $file -RulesPath $RulesPath -PleasanterPayload}
  [Windows.Forms.MessageBox]::Show('MSGの変換が終了しました。結果はMSGと同じフォルダです。')|Out-Null
 } catch{[Windows.Forms.MessageBox]::Show($_.Exception.Message)|Out-Null}
}
$addBtn=New-Button '+ ルール追加' 982 86 174 {
 [void]$grid.Rows.Add($true,'新しいルール','Body','Regex','Ignore','','0','')
 $grid.ClearSelection()
 $index=$grid.Rows.Count-2
 if ($index -ge 0) { $grid.Rows[$index].Selected=$true; $grid.CurrentCell=$grid.Rows[$index].Cells['name'];$grid.BeginEdit($true)|Out-Null }
}
$deleteBtn=New-Button '選択行を削除' 818 86 152 {
 try {
  $grid.EndEdit()
  $rows=@($grid.SelectedRows | Where-Object { -not $_.IsNewRow } | Sort-Object Index -Descending)
  if ($rows.Count -eq 0 -and $grid.CurrentCell -and -not $grid.CurrentCell.OwningRow.IsNewRow) { $rows=@($grid.CurrentCell.OwningRow) }
  if ($rows.Count -eq 0) { [Windows.Forms.MessageBox]::Show('削除するルール行を選択してください。','MSG Studio')|Out-Null;return }
  $answer=[Windows.Forms.MessageBox]::Show("選択された $($rows.Count) 件のルールを削除しますか?`n保存ボタンを押すと rules.json に反映されます。",'ルール削除の確認',[Windows.Forms.MessageBoxButtons]::YesNo,[Windows.Forms.MessageBoxIcon]::Question)
  if ($answer -ne [Windows.Forms.DialogResult]::Yes) { return }
  foreach ($row in $rows) { $grid.Rows.RemoveAt($row.Index) }
 } catch { [Windows.Forms.MessageBox]::Show($_.Exception.Message)|Out-Null }
}
$saveBtn.BackColor=$accent
$convertBtn.BackColor=[Drawing.Color]::FromArgb(24,105,88)
$deleteBtn.BackColor=[Drawing.Color]::FromArgb(130,48,60)
$site.BackColor=$surface;$site.ForeColor=$fg
$status.BackColor=$surface;$status.ForeColor=$fg
foreach ($ctl in @($siteLabel,$statusLabel)) { $ctl.ForeColor=$muted }
foreach ($ctl in @($form.Controls | Where-Object { $_ -is [Windows.Forms.Label] -and $_ -ne $heading })) { $ctl.ForeColor=$muted }
$grid.Add_DataError({ param($sender,$e) $e.ThrowException=$false })
Read-Rules
[void]$form.ShowDialog()

Start-GUI.bat(全文)

@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0Start-GUI.ps1"
pause

Run-Script.bat(全文)

@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Script.ps1" -Path "%~1" -PleasanterPayload
exit /b %ERRORLEVEL%

生成したJSONをプリザンターで使う場合

-PleasanterPayload ではプリザンターの項目構造に寄せたJSONを生成しますが、サイトIDやAPIキーを指定して自動POSTするわけではありません。送信する場合はAPIの仕様に合わせてペイロードやサイトの項目設定を確認し、別途登録処理を構築してください。

よくある問題

  • Outlook COMエラー:Classic Outlookのインストール・プロファイル・対話実行を確認。
  • 日本語が文字化け:.ps1 はCP932で保存。rules.json はUTF-8で保存。
  • 抽出されない:正規表現の group、抽出元 source、メール本文の改行を確認。
  • JSONが上書きされる:CLIの -Overwrite 指定と同名ファイルの扱いを確認。
  • 権限エラー:出力先フォルダとログ保存先の書き込み権限を確認。

まとめ

MSG Studio v3 はGUIで抽出ルールを調整し、CLIで繰り返し処理するための土台です。問い合わせ受付やチケット管理に使う場合は、本番メールを投入する前にテストメールで抽出結果、個人情報の扱い、同名ファイル時の挙動を確認してください。

Previous Post Next Post