Init project

This commit is contained in:
2025-10-13 14:20:07 +09:00
commit 9ab919064a
8 changed files with 1436 additions and 0 deletions

348
app.py Normal file
View File

@@ -0,0 +1,348 @@
#!/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_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 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
# 新しいメールをチェック
new_emails = self.get_new_emails()
if new_emails:
logger.info(f"{len(new_emails)}件の新しいメールが見つかりました")
for email_msg in new_emails:
if self.send_to_discord(email_msg):
logger.info(f"処理完了: {email_msg.subject}")
else:
logger.warning(f"Discord送信失敗: {email_msg.subject}")
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()