400 lines
15 KiB
Python
400 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Email to Discord Webhook Forwarder
|
||
メールサーバーを監視してDiscordに転送するアプリケーション
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import time
|
||
import imaplib
|
||
import email
|
||
import json
|
||
import logging
|
||
import requests
|
||
from datetime import datetime
|
||
from typing import Dict, List, Optional
|
||
import signal
|
||
import threading
|
||
from dataclasses import dataclass
|
||
|
||
# ログ設定
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||
handlers=[
|
||
logging.StreamHandler(sys.stdout)
|
||
]
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
@dataclass
|
||
class EmailMessage:
|
||
"""メールメッセージのデータクラス"""
|
||
subject: str
|
||
sender: str
|
||
date: str
|
||
body: str
|
||
uid: str
|
||
|
||
class EmailMonitor:
|
||
"""メールサーバー監視クラス"""
|
||
|
||
def __init__(self):
|
||
self.running = False
|
||
self.mail_client = None
|
||
self.last_processed_uid = None
|
||
|
||
# 環境変数から設定を取得
|
||
self.imap_server = os.getenv('IMAP_SERVER', 'imap.gmail.com')
|
||
self.imap_port = int(os.getenv('IMAP_PORT', '993'))
|
||
self.email_user = os.getenv('EMAIL_USER')
|
||
self.email_password = os.getenv('EMAIL_PASSWORD')
|
||
self.discord_webhook_url = os.getenv('DISCORD_WEBHOOK_URL')
|
||
self.check_interval = int(os.getenv('CHECK_INTERVAL', '60')) # 秒
|
||
self.mailbox = os.getenv('MAILBOX', 'INBOX')
|
||
self.use_ssl = os.getenv('USE_SSL', 'true').lower() == 'true'
|
||
|
||
# 設定の検証
|
||
self._validate_config()
|
||
|
||
def _validate_config(self):
|
||
"""設定の検証"""
|
||
required_vars = ['EMAIL_USER', 'EMAIL_PASSWORD', 'DISCORD_WEBHOOK_URL']
|
||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||
|
||
if missing_vars:
|
||
logger.error(f"必要な環境変数が設定されていません: {', '.join(missing_vars)}")
|
||
sys.exit(1)
|
||
|
||
logger.info("設定の検証が完了しました")
|
||
|
||
def connect_to_email(self) -> bool:
|
||
"""メールサーバーに接続"""
|
||
try:
|
||
if self.use_ssl:
|
||
self.mail_client = imaplib.IMAP4_SSL(self.imap_server, self.imap_port)
|
||
else:
|
||
self.mail_client = imaplib.IMAP4(self.imap_server, self.imap_port)
|
||
|
||
self.mail_client.login(self.email_user, self.email_password)
|
||
self.mail_client.select(self.mailbox)
|
||
logger.info(f"メールサーバーに接続しました: {self.imap_server}:{self.imap_port}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"メールサーバーへの接続に失敗しました: {str(e)}")
|
||
return False
|
||
|
||
def disconnect_from_email(self):
|
||
"""メールサーバーから切断"""
|
||
if self.mail_client:
|
||
try:
|
||
self.mail_client.close()
|
||
self.mail_client.logout()
|
||
logger.info("メールサーバーから切断しました")
|
||
except Exception as e:
|
||
logger.warning(f"メールサーバーからの切断でエラーが発生しました: {str(e)}")
|
||
|
||
def get_latest_email_uid(self) -> Optional[str]:
|
||
"""最新のメールUIDを取得"""
|
||
try:
|
||
typ, data = self.mail_client.search(None, 'ALL')
|
||
if typ != 'OK' or not data[0]:
|
||
logger.warning("メールボックスが空です")
|
||
return None
|
||
|
||
email_ids = data[0].split()
|
||
return email_ids[-1].decode() # 最新のUIDを取得
|
||
except Exception as e:
|
||
logger.error(f"最新メールUIDの取得に失敗しました: {str(e)}")
|
||
return None
|
||
|
||
def get_new_emails_since_uid(self, last_uid: str) -> List[EmailMessage]:
|
||
"""指定したUID以降の新しいメールを取得"""
|
||
try:
|
||
typ, data = self.mail_client.search(None, f'UID {last_uid}:*')
|
||
if typ != 'OK':
|
||
logger.warning("メール検索に失敗しました")
|
||
return []
|
||
|
||
email_ids = data[0].split()[1:] # 最初のUIDは除外
|
||
new_emails = []
|
||
|
||
for email_id in email_ids:
|
||
try:
|
||
typ, msg_data = self.mail_client.fetch(email_id, '(RFC822)')
|
||
if typ != 'OK':
|
||
continue
|
||
|
||
email_body = msg_data[0][1]
|
||
email_message = email.message_from_bytes(email_body)
|
||
|
||
parsed_email = self._parse_email(email_message, email_id.decode())
|
||
if parsed_email:
|
||
new_emails.append(parsed_email)
|
||
except Exception as e:
|
||
logger.error(f"メール解析エラー (ID: {email_id}): {str(e)}")
|
||
continue
|
||
|
||
return new_emails
|
||
except Exception as e:
|
||
logger.error(f"新しいメールの取得に失敗しました: {str(e)}")
|
||
return []
|
||
|
||
def get_new_emails(self) -> List[EmailMessage]:
|
||
"""新しいメールを取得"""
|
||
try:
|
||
# UNSEENフラグのメールを検索
|
||
typ, data = self.mail_client.search(None, 'UNSEEN')
|
||
|
||
if typ != 'OK':
|
||
logger.warning("メール検索に失敗しました")
|
||
return []
|
||
|
||
email_ids = data[0].split()
|
||
new_emails = []
|
||
|
||
for email_id in email_ids:
|
||
try:
|
||
# メールを取得
|
||
typ, msg_data = self.mail_client.fetch(email_id, '(RFC822)')
|
||
|
||
if typ != 'OK':
|
||
continue
|
||
|
||
email_body = msg_data[0][1]
|
||
email_message = email.message_from_bytes(email_body)
|
||
|
||
# メールデータを解析
|
||
parsed_email = self._parse_email(email_message, email_id.decode())
|
||
if parsed_email:
|
||
new_emails.append(parsed_email)
|
||
|
||
except Exception as e:
|
||
logger.error(f"メール解析エラー (ID: {email_id}): {str(e)}")
|
||
continue
|
||
|
||
return new_emails
|
||
|
||
except Exception as e:
|
||
logger.error(f"新しいメールの取得に失敗しました: {str(e)}")
|
||
return []
|
||
|
||
def _parse_email(self, email_message, uid: str) -> Optional[EmailMessage]:
|
||
"""メールメッセージを解析"""
|
||
try:
|
||
# ヘッダー情報を取得
|
||
subject = self._decode_header(email_message.get('Subject', ''))
|
||
sender = self._decode_header(email_message.get('From', ''))
|
||
date = email_message.get('Date', '')
|
||
|
||
# メール本文を取得
|
||
body = self._get_email_body(email_message)
|
||
|
||
return EmailMessage(
|
||
subject=subject,
|
||
sender=sender,
|
||
date=date,
|
||
body=body,
|
||
uid=uid
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"メール解析エラー: {str(e)}")
|
||
return None
|
||
|
||
def _decode_header(self, header: str) -> str:
|
||
"""メールヘッダーをデコード"""
|
||
if not header:
|
||
return ''
|
||
|
||
try:
|
||
decoded_header = email.header.decode_header(header)
|
||
result = ''
|
||
|
||
for text, encoding in decoded_header:
|
||
if isinstance(text, bytes):
|
||
if encoding:
|
||
text = text.decode(encoding, errors='ignore')
|
||
else:
|
||
text = text.decode('utf-8', errors='ignore')
|
||
result += text
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.warning(f"ヘッダーデコードエラー: {str(e)}")
|
||
return str(header)
|
||
|
||
def _get_email_body(self, email_message) -> str:
|
||
"""メール本文を取得"""
|
||
body = ''
|
||
|
||
try:
|
||
if email_message.is_multipart():
|
||
for part in email_message.walk():
|
||
content_type = part.get_content_type()
|
||
content_disposition = str(part.get('Content-Disposition'))
|
||
|
||
if content_type == 'text/plain' and 'attachment' not in content_disposition:
|
||
charset = part.get_content_charset() or 'utf-8'
|
||
body_bytes = part.get_payload(decode=True)
|
||
if body_bytes:
|
||
body = body_bytes.decode(charset, errors='ignore')
|
||
break
|
||
else:
|
||
charset = email_message.get_content_charset() or 'utf-8'
|
||
body_bytes = email_message.get_payload(decode=True)
|
||
if body_bytes:
|
||
body = body_bytes.decode(charset, errors='ignore')
|
||
|
||
# 本文が長い場合は切り詰める(Discord制限対策)
|
||
if len(body) > 1900:
|
||
body = body[:1900] + '\n...(本文が切り詰められました)'
|
||
|
||
return body
|
||
|
||
except Exception as e:
|
||
logger.warning(f"メール本文取得エラー: {str(e)}")
|
||
return '本文の取得に失敗しました'
|
||
|
||
def send_to_discord(self, email_msg: EmailMessage) -> bool:
|
||
"""DiscordにWebhookでメールを送信"""
|
||
try:
|
||
# Discord Embed形式でメッセージを構築
|
||
embed = {
|
||
"title": "📧 新しいメール",
|
||
"color": 0x3498db,
|
||
"fields": [
|
||
{
|
||
"name": "件名",
|
||
"value": email_msg.subject or "件名なし",
|
||
"inline": False
|
||
},
|
||
{
|
||
"name": "送信者",
|
||
"value": email_msg.sender or "送信者不明",
|
||
"inline": True
|
||
},
|
||
{
|
||
"name": "日時",
|
||
"value": email_msg.date or "日時不明",
|
||
"inline": True
|
||
},
|
||
{
|
||
"name": "本文",
|
||
"value": email_msg.body[:1000] if email_msg.body else "本文なし",
|
||
"inline": False
|
||
}
|
||
],
|
||
"timestamp": datetime.utcnow().isoformat()
|
||
}
|
||
|
||
webhook_data = {
|
||
"username": "Email Monitor",
|
||
"avatar_url": "https://cdn-icons-png.flaticon.com/512/732/732200.png",
|
||
"embeds": [embed]
|
||
}
|
||
|
||
response = requests.post(
|
||
self.discord_webhook_url,
|
||
json=webhook_data,
|
||
timeout=10
|
||
)
|
||
|
||
if response.status_code == 204:
|
||
logger.info(f"Discordにメールを送信しました: {email_msg.subject}")
|
||
return True
|
||
else:
|
||
logger.error(f"Discord送信エラー: {response.status_code} - {response.text}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"Discord送信エラー: {str(e)}")
|
||
return False
|
||
|
||
def send_to_discord_with_rate_limit(self, email_msgs: List[EmailMessage], rate_limit: float = 1.0, batch_size: int = 10):
|
||
"""Discordにメールを送信(レート制限対応)"""
|
||
for i in range(0, len(email_msgs), batch_size):
|
||
batch = email_msgs[i:i + batch_size]
|
||
for email_msg in batch:
|
||
if self.send_to_discord(email_msg):
|
||
logger.info(f"処理完了: {email_msg.subject}")
|
||
self.last_processed_uid = email_msg.uid # 最後に処理したUIDを更新
|
||
else:
|
||
logger.warning(f"Discord送信失敗: {email_msg.subject}")
|
||
time.sleep(rate_limit) # レート制限のための待機
|
||
|
||
def start_monitoring(self):
|
||
"""メール監視を開始"""
|
||
logger.info("メール監視を開始します...")
|
||
self.running = True
|
||
|
||
while self.running:
|
||
try:
|
||
if not self.connect_to_email():
|
||
logger.warning(f"{self.check_interval}秒後に再試行します...")
|
||
time.sleep(self.check_interval)
|
||
continue
|
||
|
||
if self.last_processed_uid is None:
|
||
self.last_processed_uid = self.get_latest_email_uid()
|
||
logger.info(f"初期UID: {self.last_processed_uid}")
|
||
|
||
new_emails = self.get_new_emails_since_uid(self.last_processed_uid)
|
||
|
||
if new_emails:
|
||
logger.info(f"{len(new_emails)}件の新しいメールが見つかりました")
|
||
self.send_to_discord_with_rate_limit(new_emails)
|
||
else:
|
||
logger.debug("新しいメールはありません")
|
||
|
||
self.disconnect_from_email()
|
||
logger.debug(f"{self.check_interval}秒後に次のチェックを実行します")
|
||
time.sleep(self.check_interval)
|
||
|
||
except KeyboardInterrupt:
|
||
logger.info("キーボード割り込みを受信しました")
|
||
break
|
||
except Exception as e:
|
||
logger.error(f"監視ループでエラーが発生しました: {str(e)}")
|
||
self.disconnect_from_email()
|
||
time.sleep(self.check_interval)
|
||
|
||
logger.info("メール監視を停止しました")
|
||
|
||
def stop_monitoring(self):
|
||
"""メール監視を停止"""
|
||
self.running = False
|
||
self.disconnect_from_email()
|
||
|
||
def signal_handler(signum, frame, monitor):
|
||
"""シグナルハンドラー"""
|
||
logger.info(f"シグナル {signum} を受信しました。アプリケーションを終了します...")
|
||
monitor.stop_monitoring()
|
||
sys.exit(0)
|
||
|
||
def main():
|
||
"""メイン関数"""
|
||
logger.info("Email to Discord Webhook Forwarder を開始します")
|
||
logger.info(f"チェック間隔: {os.getenv('CHECK_INTERVAL', '60')}秒")
|
||
logger.info(f"メールボックス: {os.getenv('MAILBOX', 'INBOX')}")
|
||
logger.info(f"IMAPサーバー: {os.getenv('IMAP_SERVER', 'imap.gmail.com')}")
|
||
|
||
# メール監視インスタンスを作成
|
||
monitor = EmailMonitor()
|
||
|
||
# シグナルハンドラーを設定
|
||
signal.signal(signal.SIGINT, lambda s, f: signal_handler(s, f, monitor))
|
||
signal.signal(signal.SIGTERM, lambda s, f: signal_handler(s, f, monitor))
|
||
|
||
try:
|
||
# 監視開始
|
||
monitor.start_monitoring()
|
||
except Exception as e:
|
||
logger.error(f"アプリケーションエラー: {str(e)}")
|
||
sys.exit(1)
|
||
|
||
if __name__ == "__main__":
|
||
main() |