2026年1月3日土曜日

PythonでOutlook下書き自動作成|エクセルから一括生成するコード公開

 業務分析を行って負荷のかかっている単純作業があった場合、AIを活用してpythonプログラムを作成し、コストをかけずに解決策を提示することも可能です。
ここでは顧客への大量のメール送信を自動化する例を示します。

■想定シーン
既存顧客向けにチラシ等の添付ファイル付お知らせメールを大量に送信する。ここでは下書き作成まで自動化し、送信は目視確認兼ねて手動を想定する。

■事前準備
1.python開発環境Anacondaのインストール
 以下のサイト等を参考に実施ください。
 https://www.python.jp/install/anaconda/
2.ライブラリの事前インストール
 メール操作に必要なライブラリを先にインストールする必要があります。
 以下のコマンドをAnaconda Promptから入力ください。
 pip install pywin32 pandas openpyxl xlrd
3.データの準備
 以下のようなエクセルシートにデータを準備ください。


 以下のテキストを準備ください。
  テキストデータA ⇒件名を記載したもの
  テキストデータB ⇒本文を記載したもの
 添付ファイルを保存するフォッルダに添付ファイル(複数可能)を保存
 下書格納用のフォルダ

4.プログラミング
以下のプロンプトでAI(今回はcopilot)に指示してください。
---------------
アウトルック(windows)メールの下書き(.msg)をGUIから任意に選択するフォルダに作成するpythonプログラム
を作って下さい。(送信は決してしない)
GUIから任意のエクセルファイルを選択、そのエクセルの1行目は項目名、
エクセルの2行目以降の全行についてアウトルックメール下書きを作成してください。下書きメールの各項目を以下のように設定する。
宛先(T)には選択したエクセルファイルの項目「メールアドレス」を設定
件名(U)にはGUIで任意に選択するテキストデータA
添付ファイル GUIから任意に選択するフォルダーにある全てのファイルを添付する 
本文 1行目 エクセルファイルの項目「メール宛先」複数行の場合は改行を生かして複数行に記載
   空白行を1行挿入
   GUIで任意に選択するテキストデータBを貼り付ける
ファイル名:1行目に設定したエクセルファイルの項目「メール宛先」改行なし
---------------
以下のようにプログラムを生成してくれます。



⇒コードをコピーしてメモ帳でファイル名「create_outlook_drafts_msg.py」で保存

5.実行
 以下のコマンドをAnaconda Promptから入力ください。
 cd プログラムの存在するフォルダ名
 python create_outlook_drafts_msg.py
 ⇒GUIの指示に従って準備したリストなどを選択

全て選択すればメール下書きが指定フォルダに自動作成されます。


■エラー時の対処
 Anaconda Promptに表示されるエラーメッセージをAIに読み込ませるとデバックしてくれます。
 例えば指定するファイルが開かれているとエラーになります。

■コード全文

# -*- coding: utf-8 -*-
import os
import re
import sys
import traceback

import pandas as pd

EXCEL_ENGINE_XLSX = "openpyxl"
EXCEL_ENGINE_XLS = "xlrd"
OL_SAVEAS_MSG = 3

# GUI
import tkinter as tk
from tkinter import filedialog, messagebox

# Outlook COM
import win32com.client as win32
import pythoncom


def log(msg):
    print(msg, flush=True)


def read_text_file(path):
    for enc in ("utf-8", "cp932"):
        try:
            with open(path, "r", encoding=enc) as f:
                return f.read()
        except Exception:
            continue
    with open(path, "rb") as f:
        return f.read().decode("utf-8", errors="replace")


def sanitize_filename(name):
    """Windows の禁止文字を置換し、長過ぎる場合は短縮"""
    name = re.sub(r'[\\/:*?"<>|]+', "_", str(name))
    name = name.strip()
    return (name[:100] or "no_subject")


def load_excel_dataframe(excel_path):
    ext = os.path.splitext(excel_path)[1].lower()
    if ext == ".xlsx":
        df = pd.read_excel(excel_path, engine=EXCEL_ENGINE_XLSX)
    elif ext == ".xls":
        df = pd.read_excel(excel_path, engine=EXCEL_ENGINE_XLS)
    else:
        try:
            df = pd.read_excel(excel_path, engine=EXCEL_ENGINE_XLSX)
        except Exception:
            df = pd.read_excel(excel_path)
    return df


def ensure_outlook():
    try:
        pythoncom.CoInitialize()
    except Exception:
        pass
    log("[INFO] Outlook.Application を取得します...")
    outlook = win32.Dispatch("Outlook.Application")
    log("[INFO] Outlook.Application 取得済み")
    return outlook


def collect_attachments(attach_folder):
    files = []
    try:
        entries = os.listdir(attach_folder)
    except Exception:
        entries = []
    for entry in entries:
        fp = os.path.join(attach_folder, entry)
        if os.path.isfile(fp):
            files.append(fp)
    return files


def build_body_linebreaks(text):
    if text is None:
        return ""
    s = str(text)
    s = s.replace("\r\n", "\n").replace("\r", "\n")
    return s.replace("\n", "\r\n")


def create_msg_draft(outlook, to_addr, subject, body_text, attachments, save_dir, index, filename_label=""):
    """
    1件分の MailItem を作成し、.msg で保存(送信しない)。
    ファイル名ラベルに「メール宛先」1行目(改行なし)を使う。
    """
    mail = outlook.CreateItem(0)  # olMailItem
    mail.To = to_addr or ""
    mail.Subject = subject or ""
    mail.Body = body_text or ""

    for fp in attachments:
        try:
            mail.Attachments.Add(fp)
        except Exception as e:
            log(f"[WARN] 添付失敗: {fp} => {e}")

    # ---- ファイル名の組み立て(指定仕様)----
    # メール宛先(1行目・改行なし)をラベルに
    safe_label = sanitize_filename(filename_label or "")
    safe_subject = sanitize_filename(subject or "")
    base_name = f"{index:03d}_{safe_label}_{safe_subject}"
    out_path = os.path.join(save_dir, base_name + ".msg")

    mail.SaveAs(out_path, OL_SAVEAS_MSG)
    return out_path


def ask_file(title, patterns, parent):
    path = filedialog.askopenfilename(parent=parent, title=title, filetypes=patterns)
    log(f"[DEBUG] 選択結果: {title} => {path if path else '(未選選択)'}")
    return path


def ask_dir(title, parent):
    path = filedialog.askdirectory(parent=parent, title=title)
    log(f"[DEBUG] 選択結果: {title} => {path if path else '(未選択)'}")
    return path


def console_fallback(prompt):
    log(f"[FALLBACK] {prompt}")
    try:
        path = input("> 入力(空でスキップ): ").strip()
    except EOFError:
        path = ""
    return path


def main():
    print(f"[ENV] Python: {sys.executable}", flush=True)

    root = tk.Tk()
    root.title("Outlook下書き作成ツール")
    root.geometry("300x50+100+100")
    root.attributes("-topmost", True)
    root.lift()

    try:
        try:
            root.call("tk", "scaling", 1.3)
        except Exception:
            pass

        messagebox.showinfo("開始", "処理を開始します。ダイアログに従って選択してください。", parent=root)

        log("[STEP] エクセルファイル選択")
        excel_path = ask_file(
            "エクセルファイルを選択してください(1行目が項目名)",
            [("Excel files", "*.xlsx;*.xls"), ("All files", "*.*")],
            parent=root
        )
        if not excel_path:
            excel_path = console_fallback("GUIが表示されない場合、エクセルファイルのフルパスを入力してください。")
        if not excel_path or not os.path.isfile(excel_path):
            messagebox.showerror("エラー", "エクセルファイルが選択/入力されていません。処理を中止します。", parent=root)
            log("[ERROR] エクセル未選択")
            return

        log("[STEP] 件名用テキスト(A)選択")
        subject_text_path = ask_file(
            "件名用テキスト(A)ファイルを選択してください",
            [("Text files", "*.txt;*.csv;*.md"), ("All files", "*.*")],
            parent=root
        )
        if not subject_text_path:
            subject_text_path = console_fallback("件名用テキスト(A)ファイルのフルパスを入力してください。")
        if not subject_text_path or not os.path.isfile(subject_text_path):
            messagebox.showerror("エラー", "件名用テキスト(A)が選択/入力されていません。処理を中止します。", parent=root)
            log("[ERROR] 件名A未選択")
            return
        subject_text = read_text_file(subject_text_path).strip()

        log("[STEP] 本文用テキスト(B)選択")
        body_b_path = ask_file(
            "本文用テキスト(B)ファイルを選択してください",
            [("Text files", "*.txt;*.csv;*.md"), ("All files", "*.*")],
            parent=root
        )
        if not body_b_path:
            body_b_path = console_fallback("本文用テキスト(B)ファイルのフルパスを入力してください。")
        if not body_b_path or not os.path.isfile(body_b_path):
            messagebox.showerror("エラー", "本文用テキスト(B)が選択/入力されていません。処理を中止します。", parent=root)
            log("[ERROR] 本文B未選択")
            return
        body_b = read_text_file(body_b_path)

        log("[STEP] 添付フォルダ選択")
        attach_folder = ask_dir("添付ファイルを含むフォルダを選択してください", parent=root)
        if not attach_folder:
            attach_folder = console_fallback("添付フォルダのフルパスを入力してください。")
        if not attach_folder or not os.path.isdir(attach_folder):
            messagebox.showerror("エラー", "添付フォルダが選択/入力されていません。処理を中止します。", parent=root)
            log("[ERROR] 添付フォルダ未選択")
            return
        attachments = collect_attachments(attach_folder)
        log(f"[INFO] 添付件数: {len(attachments)}")

        log("[STEP] 保存先フォルダ選択")
        save_dir = ask_dir(".msg の保存先フォルダを選択してください", parent=root)
        if not save_dir:
            save_dir = console_fallback(".msg 保存先フォルダのフルパスを入力してください。")
        if not save_dir or not os.path.isdir(save_dir):
            messagebox.showerror("エラー", "保存先フォルダが選択/入力されていません。処理を中止します。", parent=root)
            log("[ERROR] 保存先未選択")
            return

        log("[STEP] エクセル読み込み")
        try:
            df = load_excel_dataframe(excel_path)
        except Exception as e:
            messagebox.showerror("エラー", f"エクセルの読込に失敗しました:\n{e}", parent=root)
            log(f"[ERROR] エクセル読込失敗: {e}")
            return

        required_cols = ["メールアドレス", "メール宛先"]
        missing = [c for c in required_cols if c not in df.columns]
        if missing:
            messagebox.showerror("エラー", f"エクセルに必須列がありません: {missing}\n(列名は完全一致で「メールアドレス」「メール宛先」)", parent=root)
            log(f"[ERROR] 必須列不足: {missing}")
            return

        outlook = ensure_outlook()

        created_paths = []
        for i, row in df.iterrows():
            to_addr = str(row.get("メールアドレス") or "").strip()
            dest_text = row.get("メール宛先")
            if pd.isna(dest_text):
                dest_text = ""
            # ---- ファイル名用の「メール宛先」1行目(改行なし)を抽出 ----
            dest_firstline = ""
            if str(dest_text).strip():
                # splitlines() は \r\n, \r, \n すべてに対応。先頭行のみ使用。
                dest_firstline = str(dest_text).splitlines()[0]
                # 改行は含めない仕様なので、念のため改行文字を除去
                dest_firstline = dest_firstline.replace("\r", "").replace("\n", "")

            # 本文 = 「メール宛先」(改行保持) + 空白行 + B
            body_full = build_body_linebreaks(dest_text) + "\r\n\r\n" + build_body_linebreaks(body_b)

            try:
                out_path = create_msg_draft(
                    outlook=outlook,
                    to_addr=to_addr,
                    subject=subject_text,
                    body_text=body_full,
                    attachments=attachments,
                    save_dir=save_dir,
                    index=i + 2,  # ヘッダーが1行目のため
                    filename_label=dest_firstline or to_addr or "no_label",  # 空ならメールアドレス等でフォールバック
                )
                created_paths.append(out_path)
                log(f"[OK] Saved: {out_path}")
            except Exception as e:
                log(f"[ERROR] 行 {i+2} の下書き作成に失敗: {e}")
                traceback.print_exc()

        message = f"下書き(.msg)の作成が完了しました。\n作成数: {len(created_paths)} 件\n保存先: {save_dir}"
        messagebox.showinfo("完了", message, parent=root)
        log("[DONE] " + message)

    except Exception as e:
        traceback.print_exc()
        messagebox.showerror("エラー", f"処理中にエラーが発生しました:\n{e}", parent=root)
        log(f"[FATAL] {e}")
    finally:
        try:
            root.destroy()
        except Exception:
            pass


if __name__ == "__main__":
    main()






0 件のコメント:

コメントを投稿

AI時代のセカンドライフ/(ケースで学ぶ)行政書士試験への挑戦(伊東市、地方自治法) 

 ■ケース概要  首長の学歴詐称疑惑を発端に、「首長 vs 議会」の対立が極限まで達し、不信任議決・議会解散・再度の不信任による失職、さらには百条委員会での偽証による刑事訴追にまで発展した、地方自治法史上でも極めて稀なケースです。 ■時系列と関連法規 年月 出来事 地方自治法上の...