Init project
This commit is contained in:
348
app.py
Normal file
348
app.py
Normal 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()
|
||||
Reference in New Issue
Block a user