6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

メールで大量に送った画像を一度にダウンロードするスクリプトを久しぶりに動かしたがoutlookが新しくてびびった

Last updated at Posted at 2025-12-03

本記事はプロトアウト 年末のお仕事改善制作 Advent Calendar 2025 4日目 + わたなべの5日目 の記事です!

お仕事改善がテーマとのことで、最近業務中にあったメールと画像に関するtips的なものを紹介したいと思います。

オフィスでダウンロード中の少女.png

昔実装した画像一括ダウンロードのスクリプト

今は昔、画像を一括ダウンロードするスクリプトありけり。
というくらい大昔にメールに添付した画像を一括ダウンロードするpythonのスクリプトをGPTが作成したんですよね。

自分がサクッと使えれば良い感じだったのでCLIツールっぽい感じでuvで実行する感じにして、ファイルもmain.py単独にしてました。

main.py
main.py
# save_mail_attachments.py
# -*- coding: utf-8 -*-
import argparse
import os
import re
import sys
from datetime import datetime
try:
    import win32com.client  # pywin32
except ImportError:
    print("pywin32 が見つかりません。`pip install pywin32` を実行してください。")
    sys.exit(1)

IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".webp", ".heic"}

def sanitize_filename(name: str) -> str:
    # Windows 禁止文字を置換
    return re.sub(r'[\\/:*?"<>|]', "_", name)

def ensure_dir(path: str):
    os.makedirs(path, exist_ok=True)

def connect_outlook():
    # Outlook(MAPI)へ接続
    outlook = win32com.client.Dispatch("Outlook.Application")
    namespace = outlook.GetNamespace("MAPI")
    return namespace

def get_folder(namespace, store_name: str | None, folder_path: str):
    """
    folder_path 例:
      - "Inbox"(受信トレイ)
      - "Inbox/サブフォルダ"
      - "受信トレイ/請求書"
    store_name を指定すると別メールボックス(共有/追加)も選択可能。
    """
    if store_name:
        root = None
        for f in namespace.Folders:
            if f.Name == store_name:
                root = f
                break
        if root is None:
            raise RuntimeError(f"メールボックス '{store_name}' が見つかりません。")
    else:
        root = namespace.GetDefaultFolder(6).Parent  # 6 = olFolderInbox のストアルート

    parts = folder_path.replace("\\", "/").split("/")
    current = root
    for p in parts:
        if not p:
            continue
        if p.lower() in ("inbox", "受信トレイ"):  # 言語差吸収
            current = namespace.GetDefaultFolder(6) if current == root else current.Folders[p]
        else:
            current = current.Folders[p]
    return current

def iter_target_mails(items, subject: str, exact: bool):
    # 新しい順で安定取得(Outlook Items は並びに敏感)
    items.Sort("[ReceivedTime]", True)
    for item in items:
        # メール以外(会議通知など)をスキップ
        if item.Class != 43:  # 43 = olMail
            continue
        subj = (item.Subject or "")
        if exact:
            if subj == subject:
                yield item
        else:
            if subject.lower() in subj.lower():
                yield item

def save_attachments_from_mail(mail, outdir: str, images_only: bool):
    saved = []
    # メールごとにサブフォルダを切ると管理しやすい(任意)
    mail_dir = os.path.join(
        outdir,
        sanitize_filename(f"{mail.ReceivedTime:%Y%m%d_%H%M%S}_{mail.EntryID[-8:]}")
    )
    ensure_dir(mail_dir)

    for att in mail.Attachments:
        # ファイル名
        name = sanitize_filename(att.FileName or "attachment")
        root, ext = os.path.splitext(name)
        ext_lower = ext.lower()

        if images_only and ext_lower not in IMAGE_EXTS:
            # 画像のみ保存オプション
            continue

        # 同名回避
        target = os.path.join(mail_dir, name)
        i = 1
        while os.path.exists(target):
            target = os.path.join(mail_dir, f"{root}({i}){ext}")
            i += 1

        att.SaveAsFile(target)
        saved.append(target)

    return saved

def main():
    parser = argparse.ArgumentParser(
        description="Outlook(Win32 MAPI)から件名でメールを指定し、添付画像を一括保存します。"
    )
    parser.add_argument(
        "-s", "--subject", required=False,
        help="対象メールの件名(部分一致)。未指定の場合は対話的に入力します。"
    )
    parser.add_argument(
        "-o", "--outdir", required=True,
        help="保存先フォルダのパス(例: C:\\temp\\mail_attachments)"
    )
    parser.add_argument(
        "-f", "--folder", default="Inbox",
        help="検索対象フォルダ(例: 'Inbox', 'Inbox/請求書' など)"
    )
    parser.add_argument(
        "--store", default=None,
        help="メールボックス名(共有/別アカウント等を使う場合に指定。未指定で既定のメールボックス)"
    )
    parser.add_argument(
        "--exact", action="store_true",
        help="件名を完全一致にする(デフォルトは部分一致)"
    )
    parser.add_argument(
        "--all", action="store_true",
        help="画像以外の添付も含めて全て保存(デフォルトは画像のみ)"
    )
    args = parser.parse_args()

    subject = args.subject
    if not subject:
        try:
            subject = input("対象メールの件名(部分一致可)を入力してください: ").strip()
        except KeyboardInterrupt:
            print("\nキャンセルしました。")
            return
    if not subject:
        print("件名が空です。終了します。")
        return

    ensure_dir(args.outdir)

    print("Outlook に接続中...")
    ns = connect_outlook()
    print(f"フォルダ取得中: store={args.store or '(既定)'} / {args.folder}")
    folder = get_folder(ns, args.store, args.folder)

    print(f"メール検索中: 件名{'(完全一致)' if args.exact else '(部分一致)'}='{subject}'")
    matched = list(iter_target_mails(folder.Items, subject, args.exact))
    if not matched:
        print("該当するメールが見つかりませんでした。")
        return

    total_saved = 0
    for mail in matched:
        saved_paths = save_attachments_from_mail(mail, args.outdir, images_only=not args.all)
        if saved_paths:
            print(f"保存: {len(saved_paths)} 件 / 受信日時: {mail.ReceivedTime} / 件名: {mail.Subject}")
            for p in saved_paths:
                print(f"  - {p}")
            total_saved += len(saved_paths)

    print(f"完了: 添付保存合計 {total_saved}")

if __name__ == "__main__":
    main()

そもそもなぜこんなものを作ったのかといえば、スマホから送ったメールで送った大量の写真を一括でPCにダウンロードするためです。

小売事業者は店舗を巡回してスマホで大量に写真を撮影するのですが、社内PCの都合上その写真をPCに送る手段がないんですよね。ですので、私はメールに貼り付けて送っていました。スマホから送ると画像サイズを選択できるので、適度に圧縮できてそれはそれで良かったのですが、30枚もある画像が本文にベタ貼りのような感じで、1枚1枚落とすのが面倒に感じました。

そこで、巡回しながらその場でGPTが実装した感じです。

思い出して久しぶりに動かした、、、が動かない

そして最近、店舗の現地調査に行き同様にスマホで写真を撮影してPCに送信した際、昔実装したスクリプトの存在を思い出したので、git cloneして動かしてみました。

ですが、なんと動かなかったのです!

pywintypes.com_error: (-2146959355, 'サーバーの実行に失敗しました', None, None)

というエラーが表示され、GPT様は

# 🧪 まず試すべき最短の解決手順(重要度順)

## ✔ ステップ1:Outlook を完全終了 → 再起動

1. Outlook を閉じる
2. タスクマネージャー → OUTLOOK.EXE を全部 Kill
3. Outlook をふつうに起動
4. `uv run main.py ...` を再実行

---

## ✔ ステップ2:Python/uv を “管理者権限で実行していない状態” で動かす

PowerShell を **管理者で開かず**に実行してください。

⚠️ Outook と Python の権限が一致しないと COM で落ちます。

---

## ✔ ステップ3:Outlook が既定メールアプリであることを確認

Windows 設定 → アプリ → 既定のアプリ
Outlook を選び → "メール" を Outlook に変更

---

## ✔ ステップ4:Outlook のプログラムによるアクセスの設定を確認

企業環境でここが「警告表示」や「拒否」になっていると接続できません。

---

# 📝 追記:

## Outlook が起動していないとこのエラーが出ることが非常に多い

実は Outlook は **非起動状態でも COM 経由で起動できる**のですが、
企業PCや M365 のキッティングだと **self-host が禁止**されていて失敗します。

その場合:

👉 **Outlook を明示的に起動した状態で実行する必要があります。**

---

# 👉 次のステップ

まず、以下を試して結果教えてください:

1. Outlook を完全終了してタスクマネージャーから OUTLOOK.EXE を全部 kill
2. Outlook を手動で起動
3. 標準ユーザーの PowerShell で

```sh
uv run main.py -o "C:\temp\mail_attachments" -s "のしろ"


これで動くケースが非常に多いです。

とのことでした。
絶対嘘だろと思いました。

new OutlookはPWA

勘の良い方はお気づきかもしれませんが、結論outlookの使用が365になり変更となったことが原因です。

main.pyの依存にある通り、Pythonよりwin32APIを動かすpywin32を利用しているのですが、新しいoutlookはPWA(webアプリをデスクトップやスマホのアプリなどのネイティブのアプリのように見せる技術)で実装されているため、pywin32ではコントロールできないようです!

種類 アイコン 技術 COM/MAPI 添付抽出 Python 連携
新しい Outlook 青い O WebView2(PWA)
従来 Outlook (Classic) 濃い青 O Win32

なので、outlookの右上にあるトグルスイッチをoffにして、旧outlookを起動した状態にした上でスクリプトを実行してみました!

download.png

https://office54.net/iot/office365/new-outlook-try
画像見やすかったのでお借りしました

無事実行完了

結果、無事実行できました!

PS C:\Users\myname\.src\get_mail_image> uv run main.py -o "C:\temp\mail_attachments" -s "のしろ"
Outlook に接続中...
フォルダ取得中: store=(既定) / Inbox
メール検索中: 件名(部分一致)='のしろ'
保存: 1 件 / 受信日時: 2025-11-27 15:47:26.844000+00:00 / 件名: のしろ
  - C:\temp\mail_attachments\20251127_154726_A0CC0000\IMG_3791.jpg
完了: 添付保存合計 1 件

UX的には新しいOutlookの方が好きなのでトグルスイッチを戻しておきました。

と、いうことで冒頭共有したスクリプトは動きそうですので皆さんもよければuvをインストールして使ってみてください!使い方はREADMEに書いてますので、合わせてお読みください!

6
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?