受信したメールからgoogleカレンダーに予定登録をする仕組みを作った話

# 受信したメールからgoogleカレンダーに予定登録をする仕組みを作った話
社内であるPoCを実施するにあたり検証環境でメールサーバを構築する必要があったため、前からやってみようとしてた社内メールから送った件名と本文をローカルのメールサーバで受信しgoogleカレンダーにイベントを登録する仕組みを作ったので備忘メモとする。
※社内独自のスケジュール管理ツールを使っており、個人のカレンダーなどに登録ができない環境の為、このような事にチャレンジしております（古い会社なんです）

# 環境
Vmware Esxi7.0
OS：Rocky Linux release 9.3
Dovecont（※バージョンは非公開）
Postfix（※バージョンは非公開）

# ネットワークとセキュリティ周り
・ルータのポート開放
・メールサーバの証明書取得（安定のletsencrypt）
・DDNS登録（noipを使ってます）
こちらは、他に参考できるサイトがありますのでそちらをご参照下さい。

# コード
※8割型ChatGPTに作ってもらいました。
　コードに関してはド素人なので、ココ間違っているなどあればご指摘下さい。
```
import imaplib
import email
import quopri
import webbrowser
import os
import logging
import datefinder
import datetime
import re
from email.header import decode_header
from email import quoprimime
from google.oauth2 import service_account
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError

# ログの設定
logging.basicConfig(filename='ログの保存先',
                    level=logging.INFO,
                    format='%(asctime)s - %(levelname)s - %(message)s')

# メールサーバーの設定
mail_server = 'SMTPサーバのアドレス'
mail_port = 該当のポート
mail_user = '利用するアカウント'
mail_password = 'パスワード'

# Google Calendar APIの設定
SCOPES = ['https://www.googleapis.com/auth/calendar']
SERVICE_ACCOUNT_FILE = 'jsonファイルの保存先'
CALENDAR_ID = (
    'イベント登録を行いたいカレンダーID'
)

# 予定となるメールの件名に対する正規表現パターン
subject_pattern = re.compile(r'予定', re.IGNORECASE)
cancel_pattern = re.compile(r'キャンセル', re.IGNORECASE)

# IMAPセッションの開始
#mail = imaplib.IMAP4_SSL(mail_server, mail_port)
#mail.login(mail_user, mail_password)

# メールボックスの選択
#mail.select('inbox')

# 未読メールを検索
#status, messages = mail.search(None, '(UNSEEN)')
#if status == 'OK':

    # Google Calendar APIセッションの開始
#    creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
#    service = build('calendar', 'v3', credentials=creds)

#    for num in messages[0].split():
#        process_email(mail, num, service)

def process_email(mail, num, service):
    # メールの取得
    status, msg_data = mail.fetch(num, '(RFC822)')
    if status != 'OK':
        return

    raw_email = msg_data[0][1]
    msg = email.message_from_bytes(raw_email)

    # 件名のデコード
    subject, encoding = decode_header(msg.get("Subject"))[0]
    if isinstance(subject, bytes):
        try:
            subject = subject.decode(encoding or "utf-8")
        except LookupError:
            # もしエンコーディングが不明な場合は iso-2022-jp としてデコード
            subject = subject.decode("iso-2022-jp", errors='replace')
    logging.debug(f"Processing email with subject: {subject}")

    # 件名が予定またはキャンセルとなるものであるか確認
    if subject_pattern.search(subject) or cancel_pattern.search(subject):
        logging.info(f"Found email with subject: {subject}")

        # 本文の取得
        if msg.is_multipart():
            for part in msg.walk():
                if part.get_content_type() in ["text/plain", "text/html"]:
                    body = part.get_payload(decode=True)

                    # エンコーディングと Content-Transfer-Encoding の確認
                    charset = part.get_content_charset()
                    transfer_encoding = part.get('Content-Transfer-Encoding', '').lower()

                    if charset == 'iso-2022-jp' and transfer_encoding == 'quoted-printable':
                        body = quopri.decodestring(body).decode('iso-2022-jp', errors='replace')
                    else:
                        body = body.decode(charset or 'utf-8', errors='replace')
                    logging.debug(f"Email body:\n{body}")

                    # 予定またはキャンセルの処理
                    plan_content_match = re.search(r'予定内容:\s*(.+)', body)
                    if cancel_pattern.search(subject):
                        handle_cancel_event(plan_content_match, service)
                    else:
                        handle_add_event(plan_content_match, body, service)
                    break
        # メールを既読にする
        mail.store(num, '+FLAGS', '\Seen')

def handle_cancel_event(plan_content_match, service):
    if not plan_content_match:
        return

    plan_content = plan_content_match.group(1)

    # Google Calendar APIを使用して予定を削除
    try:
        events = service.events().list(calendarId=CALENDAR_ID).execute().get('items', [])

        for event in events:
            if 'summary' in event and event['summary'] == plan_content:
                service.events().delete(calendarId=CALENDAR_ID, eventId=event['id']).execute()
                logging.info(f"Cancelled event: {plan_content}")

    except HttpError as error:
        logging.error(f'An error occurred while cancelling event: {error}')

def handle_add_event(plan_content_match, body, service):
    if not (plan_content_match and re.search(r'時間:\s*(.+)', body)):
        return

    plan_content = plan_content_match.group(1)
    time_str = re.sub(r'\r', '', re.search(r'時間:\s*(.+)', body).group(1))

    start_time = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M')
    end_time = start_time + datetime.timedelta(hours=1)  # 1時間のイベントとして設定

    event = {
        'summary': plan_content,
        'description': f'予定内容: {plan_content}\n時間: {time_str}',
        'start': {'dateTime': start_time.strftime('%Y-%m-%dT%H:%M:%S'), 'timeZone': 'Asia/Tokyo'},
        'end': {'dateTime': end_time.strftime('%Y-%m-%dT%H:%M:%S'), 'timeZone': 'Asia/Tokyo'},
    }

    try:
        logging.debug(f"API Request: {event}")
        created_event = service.events().insert(calendarId=CALENDAR_ID, body=event).execute()
        logging.info(f"Event added: {created_event['htmlLink']}")
    except HttpError as error:
        logging.error(f'An error occurred while adding event: {error}')

# IMAPセッションの開始
mail = imaplib.IMAP4_SSL(mail_server, mail_port)
mail.login(mail_user, mail_password)

# メールボックスの選択
mail.select('inbox')

# 未読メールを検索
status, messages = mail.search(None, '(UNSEEN)')

if status == 'OK':
    # Google Calendar APIセッションの開始
    creds = service_account.Credentials.from_service_account_file(SERVICE_ACCOUNT_FILE, scopes=SCOPES)
    service = build('calendar', 'v3', credentials=creds)

    for num in messages[0].split():
        process_email(mail, num, service)

# セッションの終了
mail.logout()
```
# 仕組みを作って気づいたこと
社内メールサーバのContent-Typeとエンコーディングを確認せずに作っていたこともありgmailから送ったものは処理されるが、社内メールから送ったものは処理されないなど色々ハマってしまいました。
元のソースがどういうものなのか、確認した上でコードを作ると改めて勉強になりました。

# 参考
https://www.rem-system.com/mail-postfix03/
https://www.rem-system.com/mail-postfix01/
https://centossrv.com/postfix.shtml