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
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から読み取った全実装ファイルです。短縮した擬似コードではありません。コピーしたファイル名とエンコーディングに注意してください。
{
"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": ""
}
]
}
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 }
}
[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) } }
#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 }
#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()
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -STA -ExecutionPolicy Bypass -File "%~dp0Start-GUI.ps1"
pause
@echo off
cd /d "%~dp0"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Run-Script.ps1" -Path "%~1" -PleasanterPayload
exit /b %ERRORLEVEL%
-PleasanterPayload ではプリザンターの項目構造に寄せたJSONを生成しますが、サイトIDやAPIキーを指定して自動POSTするわけではありません。送信する場合はAPIの仕様に合わせてペイロードやサイトの項目設定を確認し、別途登録処理を構築してください。
.ps1 はCP932で保存。rules.json はUTF-8で保存。group、抽出元 source、メール本文の改行を確認。-Overwrite 指定と同名ファイルの扱いを確認。MSG Studio v3 はGUIで抽出ルールを調整し、CLIで繰り返し処理するための土台です。問い合わせ受付やチケット管理に使う場合は、本番メールを投入する前にテストメールで抽出結果、個人情報の扱い、同名ファイル時の挙動を確認してください。