UIがMac風のwinmail.dat展開ツールをPyQt6で作ってみた

## はじめに

Windows環境のOutlookから送信されたメールに添付されることがある `winmail.dat` ファイル。macOSや他のメールクライアントでは直接開けず、中の添付ファイルを取り出すのに困った経験はありませんか？

今回は、そんな `winmail.dat` ファイルから簡単に添付ファイルを展開できるデスクトップアプリケーションをPythonとPyQt6で作成しました。特にmacOSユーザーが直感的に使えるような、シンプルでリッチなUIを目指しました。

## 作成したツール

こんな感じのツールです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50332/27d798ac-4467-4146-a76a-54c97720a642.png)

主な機能は以下の通りです。

*   `winmail.dat` ファイルを選択
*   添付ファイルの保存先フォルダを選択（指定しない場合はカレントディレクトリ）
*   展開処理を実行し、結果をメッセージで表示

## こだわったポイント：macOS風のUI

GUIフレームワークにはPyQt6を採用し、macOSの標準アプリケーションのようなルックアンドフィールを目指しました。

*   **フォント**: macOSではシステムフォント (SF Pro Text) を優先的に使用するように設定。
*   **配色とスタイル**: ウィンドウ背景、ボタン、入力フィールドなどをQSS (Qt Style Sheets) を使って調整し、macOSのデザイントーンに近づけました。
    *   ウィンドウ背景: ライトグレー (`#f0f0f0`)
    *   入力フィールド: 白背景に丸みを帯びた角、フォーカス時に青い枠線
    *   「選択...」ボタン: 標準的なボタンスタイル
    *   「展開実行」ボタン: プライマリボタンとして目立つ青色 (`#007AFF`)
*   **レイアウト**: 各要素の配置や間隔を調整し、スッキリとした印象に。
*   **ウィンドウ**: 適切な最小サイズを設定し、ウィンドウタイトルも分かりやすくしました。

## 使い方

1.  **winmail.dat ファイルの選択**:
    *   「winmail.dat ファイル:」の右側にある「選択...」ボタンをクリックします。
    *   ファイルダイアログが表示されるので、展開したい `winmail.dat` ファイルを選択します。
2.  **保存先フォルダの選択**:
    *   「保存先フォルダ:」の右側にある「選択...」ボタンをクリックします。
    *   添付ファイルの保存先となるフォルダを選択します。
    *   何も選択しない場合、アプリケーションを実行したカレントディレクトリに保存されます。
3.  **展開実行**:
    *   ウィンドウ下部にある青い「展開実行」ボタンをクリックします。
    *   処理が完了すると、結果がメッセージボックスで表示されます。
    *   詳細なログ（デコード試行など）はコンソールに出力されます。

## 技術的な側面

*   **GUI**: PyQt6
*   **TNEFパーサー**: `tnefparse` ライブラリ
    *   `winmail.dat` (TNEF形式) の解析と添付ファイルデータの抽出に使用しています。
*   **ファイル名デコード**:
    *   `tnefparse` がデフォルトでデコードを試みますが、文字化け対策として、いくつかの一般的なエンコーディング (cp932, euc_jp, utf-8など) でのフォールバックデコード処理も実装しています。
    *   ファイル名に使用できない文字のサニタイズや、長すぎるファイル名の切り詰めも行っています。

## ソースコード

以下に全ソースコードを掲載します。

```python
import sys
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox,
    QSpacerItem, QSizePolicy
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont, QIcon
from tnefparse.tnef import TNEF
import os

class WinmailExtractorWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Winmail.dat 展開ツール")
        self.setMinimumSize(550, 230)

        self.central_widget = QWidget()
        self.setCentralWidget(self.central_widget)
        
        self.main_layout = QVBoxLayout(self.central_widget)
        self.main_layout.setContentsMargins(20, 20, 20, 20)
        self.main_layout.setSpacing(15)

        self._create_ui()
        self._apply_styles()

        # アプリケーションアイコンの設定 (オプション)
        # try:
        #     # app_icon = QIcon("path/to/your/app_icon.png") 
        #     # if not app_icon.isNull():
        #     #     self.setWindowIcon(app_icon)
        # except Exception as e:
        #     print(f"アイコンの読み込みに失敗: {e}")


    def _create_ui(self):
        # --- File selection ---
        file_input_layout = QHBoxLayout()
        file_input_layout.setSpacing(10)
        self.winmail_file_label = QLabel("winmail.dat ファイル:")
        self.winmail_file_path_edit = QLineEdit()
        self.winmail_file_path_edit.setPlaceholderText("winmail.dat を選択してください")
        self.select_winmail_button = QPushButton("選択...")
        self.select_winmail_button.setObjectName("SelectButton")
        self.select_winmail_button.clicked.connect(self.select_winmail_file)

        file_input_layout.addWidget(self.winmail_file_label)
        file_input_layout.addWidget(self.winmail_file_path_edit, 1)
        file_input_layout.addWidget(self.select_winmail_button)
        self.main_layout.addLayout(file_input_layout)

        # --- Output directory selection ---
        output_dir_layout = QHBoxLayout()
        output_dir_layout.setSpacing(10)
        self.output_dir_label = QLabel("保存先フォルダ:")
        self.output_dir_path_edit = QLineEdit()
        self.output_dir_path_edit.setPlaceholderText("保存先フォルダを選択 (デフォルト: カレント)")
        self.select_output_dir_button = QPushButton("選択...")
        self.select_output_dir_button.setObjectName("SelectButton")
        self.select_output_dir_button.clicked.connect(self.select_output_dir)

        output_dir_layout.addWidget(self.output_dir_label)
        output_dir_layout.addWidget(self.output_dir_path_edit, 1)
        output_dir_layout.addWidget(self.select_output_dir_button)
        self.main_layout.addLayout(output_dir_layout)

        # --- Spacer ---
        self.main_layout.addSpacerItem(QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding))

        # --- Extract button ---
        self.extract_button = QPushButton("展開実行")
        self.extract_button.setObjectName("ExtractButton")
        self.extract_button.setFixedHeight(40)
        self.extract_button.clicked.connect(self.run_extraction)
        
        extract_button_layout = QHBoxLayout()
        extract_button_layout.addStretch()
        extract_button_layout.addWidget(self.extract_button)
        extract_button_layout.addStretch()
        self.main_layout.addLayout(extract_button_layout)

    def _apply_styles(self):
        default_font = QFont()
        if sys.platform == "darwin":
            default_font.setFamily("SF Pro Text")
            default_font.setPointSize(13)
        else:
            default_font.setPointSize(10)
        QApplication.setFont(default_font)

        label_font = QFont(default_font)
        if sys.platform == "darwin":
            label_font.setPointSize(13)
        else:
            label_font.setPointSize(10)

        self.winmail_file_label.setFont(label_font)
        self.output_dir_label.setFont(label_font)

        self.setStyleSheet("""
            QMainWindow, QWidget {
                background-color: #f0f0f0;
            }
            QLabel {
                color: #333333;
                padding-top: 5px;
            }
            QLineEdit {
                padding: 7px;
                border: 1px solid #cccccc;
                border-radius: 5px;
                background-color: #ffffff;
            }
            QLineEdit:focus {
                border: 1px solid #007AFF;
            }
            QPushButton#SelectButton {
                background-color: #ffffff;
                color: #333333;
                border: 1px solid #bbbbbb;
                padding: 7px 15px;
                border-radius: 5px;
                min-width: 70px;
            }
            QPushButton#SelectButton:hover {
                background-color: #f5f5f5;
            }
            QPushButton#SelectButton:pressed {
                background-color: #e0e0e0;
            }
            QPushButton#ExtractButton {
                background-color: #007AFF;
                color: white;
                border: none;
                padding: 0px 20px;
                font-weight: 500;
                border-radius: 5px;
                min-width: 130px;
            }
            QPushButton#ExtractButton:hover {
                background-color: #006ee5;
            }
            QPushButton#ExtractButton:pressed {
                background-color: #005cc8;
            }
        """)

    def select_winmail_file(self):
        current_path = self.winmail_file_path_edit.text()
        initial_dir = os.path.dirname(current_path) if current_path and os.path.exists(os.path.dirname(current_path)) else os.path.expanduser("~")
        
        path, _ = QFileDialog.getOpenFileName(
            self,
            "winmail.dat ファイルを選択",
            initial_dir,
            "winmail.dat files (*.dat);;全てのファイル (*.*)"
        )
        if path:
            self.winmail_file_path_edit.setText(path)

    def select_output_dir(self):
        current_path = self.output_dir_path_edit.text()
        initial_dir = current_path if current_path and os.path.exists(current_path) else os.path.expanduser("~")

        path = QFileDialog.getExistingDirectory(
            self,
            "保存先フォルダを選択",
            initial_dir
        )
        if path:
            self.output_dir_path_edit.setText(path)

    def run_extraction(self):
        winmail = self.winmail_file_path_edit.text()
        output_dir = self.output_dir_path_edit.text()

        if not winmail:
            QMessageBox.warning(self, "注意", "winmail.dat ファイルを選択してください。")
            return
        if not output_dir:
            output_dir = os.path.abspath(".")
            self.output_dir_path_edit.setText(output_dir)

        self.extract_attachments_qt(winmail, output_dir)

    def extract_attachments_qt(self, winmail_path, output_dir="."):
        try:
            with open(winmail_path, "rb") as f:
                tnef = TNEF(f.read())
        except FileNotFoundError:
            QMessageBox.critical(self, "エラー", f"ファイルが見つかりません: {winmail_path}")
            return
        except Exception as e:
            QMessageBox.critical(self, "エラー", f"ファイルの読み込み中にエラーが発生しました: {e}")
            return

        if not tnef.attachments:
            QMessageBox.information(self, "情報", "添付ファイルは見つかりませんでした。")
            return

        print(f"{len(tnef.attachments)} 件の添付ファイルが見つかりました。")

        if not os.path.exists(output_dir):
            try:
                os.makedirs(output_dir)
                print(f"出力フォルダを作成しました: {output_dir}")
            except Exception as e:
                QMessageBox.critical(self, "エラー", f"出力フォルダの作成に失敗しました: {e}")
                return

        saved_files_count = 0
        for i, attachment in enumerate(tnef.attachments):
            filename_str = None
            raw_filename_bytes = None

            try:
                filename_str_raw = attachment.name
                if filename_str_raw:
                    filename_str = "".join(c for c in filename_str_raw if ord(c) >= 32 or c in '\r\n\t').strip()
                    if filename_str:
                        print(f"ライブラリデフォルトでファイル名をデコード成功 (クリーニング後): {filename_str}")
                    else:
                        print(f"ライブラリデフォルトでデコードされた文字列はクリーニング後、空になりました。元文字列 (先頭50文字): '{filename_str_raw[:50]}...'")
                        filename_str = None
            except UnicodeDecodeError as ude:
                print(f"ライブラリデフォルトのデコードに失敗: {ude}")
                if hasattr(attachment, '_name') and isinstance(attachment._name, bytes):
                    raw_filename_bytes = attachment._name
                else:
                    print("attachment._name が利用不可、またはバイト型ではありません。属性を手動で検索します。")
                    for att_id in [0x3707, 0x3704, 0x3001]: 
                        if att_id in attachment.attributes:
                            attr_data = attachment.attributes[att_id].data
                            if isinstance(attr_data, bytes):
                                raw_filename_bytes = attr_data
                                break
            except Exception as e:
                print(f"attachment.name のアクセス中にエラーが発生: {e}")

            if raw_filename_bytes and not filename_str:
                cleaned_raw_bytes = raw_filename_bytes.rstrip(b'\x00')
                if not cleaned_raw_bytes:
                    print("raw_filename_bytes はヌルバイトのみでした。")
                else:
                    encodings_to_try = ['cp932', 'euc_jp', 'utf-8', 'cp1252', 'latin1']
                    for enc in encodings_to_try:
                        try:
                            decoded_name = cleaned_raw_bytes.decode(enc)
                            cleaned_name = "".join(c for c in decoded_name if ord(c) >= 32 or c in '\r\n\t').strip()
                            if cleaned_name:
                                filename_str = cleaned_name
                                print(f"{enc} でファイル名をデコード成功: {filename_str}")
                                break
                            else:
                                print(f"{enc} でデコード後、クリーニングしたら空文字列になりました。元バイト列 (先頭30バイト): {cleaned_raw_bytes[:30]}...")
                        except UnicodeDecodeError:
                            print(f"{enc} でのファイル名デコードに失敗。バイト列 (先頭30バイト, ヌル除去後): {cleaned_raw_bytes[:30]}...")
                        except Exception as e:
                            print(f"{enc} でのファイル名デコード中に予期せぬエラー: {e}")
                if not filename_str:
                     print(f"全てのフォールバックデコードに失敗。バイト列 (先頭50バイト, ヌル除去後): {cleaned_raw_bytes[:50]}...")

            if not filename_str:
                filename_str = f"attachment_{i+1}"
                print(f"汎用ファイル名を使用: {filename_str}")

            invalid_os_chars = r'<>:"/\|?*' + "".join(map(chr, range(32)))
            sanitized_filename = "".join(c if c not in invalid_os_chars else '_' for c in filename_str)
            sanitized_filename = sanitized_filename.strip(' ._')

            if not sanitized_filename:
                 sanitized_filename = f"attachment_{i+1}_sanitized"
            
            MAX_FILENAME_LEN = 200
            if len(sanitized_filename) > MAX_FILENAME_LEN:
                name_part, ext_part = os.path.splitext(sanitized_filename)
                name_part = name_part[:MAX_FILENAME_LEN - len(ext_part) - (1 if ext_part else 0)]
                sanitized_filename = name_part + ext_part
                print(f"ファイル名を切り詰めました: {sanitized_filename}")

            output_path = os.path.join(output_dir, sanitized_filename)
            try:
                with open(output_path, "wb") as out_file:
                    out_file.write(attachment.data)
                print(f"保存しました: {output_path}")
                saved_files_count += 1
            except Exception as e:
                error_message = f"ファイルの保存に失敗しました: {output_path}\nエラー: {e}"
                print(error_message)
                QMessageBox.critical(self, "エラー", error_message)

        if saved_files_count > 0:
            QMessageBox.information(self, "完了", f"{saved_files_count} 件のファイルを指定されたフォルダに保存しました。\n詳細はコンソール出力を確認してください。")
        elif tnef.attachments:
            QMessageBox.warning(self, "注意", "添付ファイルはありましたが、保存できませんでした。\n詳細はコンソール出力を確認してください。")


def main_qt():
    app = QApplication(sys.argv)
    app.setStyle("Fusion") # Fusionスタイルを適用
    window = WinmailExtractorWindow()
    window.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main_qt()