Add mail_filter flow (manual export from Windmill API)
This commit is contained in:
290
workflows/f/mail/mail_filter__flow/flow.yaml
Normal file
290
workflows/f/mail/mail_filter__flow/flow.yaml
Normal file
@@ -0,0 +1,290 @@
|
||||
summary: メールフィルタリング
|
||||
description: IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。
|
||||
value:
|
||||
modules:
|
||||
- id: a
|
||||
value:
|
||||
lock: '# py: 3.12
|
||||
|
||||
anyio==4.12.1
|
||||
|
||||
certifi==2026.1.4
|
||||
|
||||
h11==0.16.0
|
||||
|
||||
httpcore==1.0.9
|
||||
|
||||
httpx==0.28.1
|
||||
|
||||
idna==3.11
|
||||
|
||||
typing-extensions==4.15.0
|
||||
|
||||
wmill==1.642.0'
|
||||
type: rawscript
|
||||
content: "import imaplib\nimport email\nimport email.header\nimport json\nimport\
|
||||
\ re\nimport ssl\nimport urllib.request\nimport urllib.parse\nfrom datetime\
|
||||
\ import datetime, timezone, timedelta\nfrom email.utils import parsedate_to_datetime\n\
|
||||
import wmill\n\nJST = timezone(timedelta(hours=9))\n\n# ============================================================\n\
|
||||
# アカウント設定\n# 新しいアカウントを追加する際は enabled: True にする\n# ============================================================\n\
|
||||
ACCOUNTS = [\n {\n \"name\": \"gmail\",\n \"account_code\"\
|
||||
: \"gmail\",\n \"host\": \"imap.gmail.com\",\n \"port\": 993,\n\
|
||||
\ \"user_var\": \"u/admin/GMAIL_IMAP_USER\",\n \"pass_var\"\
|
||||
: \"u/admin/GMAIL_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_GMAIL_LAST_UID\"\
|
||||
,\n \"mailbox\": \"[Gmail]/All Mail\",\n \"enabled\": True,\n\
|
||||
\ },\n {\n \"name\": \"gmail_service\",\n \"account_code\"\
|
||||
: \"gmail_service\",\n \"host\": \"imap.gmail.com\",\n \"port\"\
|
||||
: 993,\n \"user_var\": \"u/admin/GMAIL2_IMAP_USER\",\n \"pass_var\"\
|
||||
: \"u/admin/GMAIL2_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_GMAIL2_LAST_UID\"\
|
||||
,\n \"mailbox\": \"[Gmail]/All Mail\",\n \"enabled\": True,\n\
|
||||
\ },\n # Hotmail テスト後に有効化\n # {\n # \"name\": \"hotmail\"\
|
||||
,\n # \"account_code\": \"hotmail\",\n # \"host\": \"outlook.office365.com\"\
|
||||
,\n # \"port\": 993,\n # \"user_var\": \"u/admin/HOTMAIL_IMAP_USER\"\
|
||||
,\n # \"pass_var\": \"u/admin/HOTMAIL_IMAP_PASSWORD\",\n # \"\
|
||||
last_uid_var\": \"u/admin/MAIL_FILTER_HOTMAIL_LAST_UID\",\n # \"enabled\"\
|
||||
: False,\n # },\n # Xserver (keinafarm.com) 6アカウント\n {\n \"\
|
||||
name\": \"xserver_akiracraftwork\",\n \"account_code\": \"xserver\"\
|
||||
,\n \"host\": \"sv579.xserver.jp\",\n \"port\": 993,\n \
|
||||
\ \"user_var\": \"u/admin/XSERVER1_IMAP_USER\",\n \"pass_var\": \"\
|
||||
u/admin/XSERVER1_IMAP_PASSWORD\",\n \"last_uid_var\": \"u/admin/MAIL_FILTER_XSERVER1_LAST_UID\"\
|
||||
,\n \"enabled\": True,\n },\n {\n \"name\": \"xserver_service\"\
|
||||
,\n \"account_code\": \"xserver\",\n \"host\": \"sv579.xserver.jp\"\
|
||||
,\n \"port\": 993,\n \"user_var\": \"u/admin/XSERVER2_IMAP_USER\"\
|
||||
,\n \"pass_var\": \"u/admin/XSERVER2_IMAP_PASSWORD\",\n \"last_uid_var\"\
|
||||
: \"u/admin/MAIL_FILTER_XSERVER2_LAST_UID\",\n \"enabled\": True,\n\
|
||||
\ },\n {\n \"name\": \"xserver_midori\",\n \"account_code\"\
|
||||
: \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\"\
|
||||
: 993,\n \"user_var\": \"u/admin/XSERVER3_IMAP_USER\",\n \"\
|
||||
pass_var\": \"u/admin/XSERVER3_IMAP_PASSWORD\",\n \"last_uid_var\"\
|
||||
: \"u/admin/MAIL_FILTER_XSERVER3_LAST_UID\",\n \"enabled\": True,\n\
|
||||
\ },\n {\n \"name\": \"xserver_kouseiren\",\n \"account_code\"\
|
||||
: \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\"\
|
||||
: 993,\n \"user_var\": \"u/admin/XSERVER4_IMAP_USER\",\n \"\
|
||||
pass_var\": \"u/admin/XSERVER4_IMAP_PASSWORD\",\n \"last_uid_var\"\
|
||||
: \"u/admin/MAIL_FILTER_XSERVER4_LAST_UID\",\n \"enabled\": True,\n\
|
||||
\ },\n {\n \"name\": \"xserver_post\",\n \"account_code\"\
|
||||
: \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\"\
|
||||
: 993,\n \"user_var\": \"u/admin/XSERVER5_IMAP_USER\",\n \"\
|
||||
pass_var\": \"u/admin/XSERVER5_IMAP_PASSWORD\",\n \"last_uid_var\"\
|
||||
: \"u/admin/MAIL_FILTER_XSERVER5_LAST_UID\",\n \"enabled\": True,\n\
|
||||
\ },\n {\n \"name\": \"xserver_sales\",\n \"account_code\"\
|
||||
: \"xserver\",\n \"host\": \"sv579.xserver.jp\",\n \"port\"\
|
||||
: 993,\n \"user_var\": \"u/admin/XSERVER6_IMAP_USER\",\n \"\
|
||||
pass_var\": \"u/admin/XSERVER6_IMAP_PASSWORD\",\n \"last_uid_var\"\
|
||||
: \"u/admin/MAIL_FILTER_XSERVER6_LAST_UID\",\n \"enabled\": True,\n\
|
||||
\ },\n]\n\n\ndef main():\n # 共通変数取得\n api_key = wmill.get_variable(\"\
|
||||
u/admin/KEINASYSTEM_API_KEY\")\n api_url = wmill.get_variable(\"u/admin/KEINASYSTEM_API_URL\"\
|
||||
).rstrip(\"/\")\n gemini_key = wmill.get_variable(\"u/admin/GEMINI_API_KEY\"\
|
||||
)\n line_token = wmill.get_variable(\"u/admin/LINE_CHANNEL_ACCESS_TOKEN\"\
|
||||
)\n line_to = wmill.get_variable(\"u/admin/LINE_TO\")\n\n total_processed\
|
||||
\ = 0\n total_notified = 0\n\n for account in ACCOUNTS:\n if\
|
||||
\ not account[\"enabled\"]:\n continue\n print(f\"[{account['name']}]\
|
||||
\ 処理開始\")\n try:\n processed, notified = process_account(\n\
|
||||
\ account, api_key, api_url, gemini_key, line_token, line_to\n\
|
||||
\ )\n total_processed += processed\n total_notified\
|
||||
\ += notified\n print(f\"[{account['name']}] 処理完了: {processed}件処理,\
|
||||
\ {notified}件通知\")\n except Exception as e:\n print(f\"\
|
||||
[{account['name']}] エラー: {e}\")\n # 1アカウントが失敗しても他のアカウントは継続\n\n\
|
||||
\ return {\n \"total_processed\": total_processed,\n \"total_notified\"\
|
||||
: total_notified,\n }\n\n\ndef process_account(account, api_key, api_url,\
|
||||
\ gemini_key, line_token, line_to):\n user = wmill.get_variable(account[\"\
|
||||
user_var\"])\n password = wmill.get_variable(account[\"pass_var\"])\n\n\
|
||||
\ # 前回の最終UID取得\n try:\n last_uid_str = wmill.get_variable(account[\"\
|
||||
last_uid_var\"])\n last_uid = int(last_uid_str) if last_uid_str and\
|
||||
\ last_uid_str != \"0\" else None\n except Exception:\n last_uid\
|
||||
\ = None\n\n # IMAP接続\n ssl_ctx = ssl.create_default_context()\n \
|
||||
\ mail = imaplib.IMAP4_SSL(account[\"host\"], account[\"port\"], ssl_context=ssl_ctx)\n\
|
||||
\ mail.login(user, password)\n mailbox = account.get(\"mailbox\", \"\
|
||||
INBOX\")\n imap_mailbox = resolve_mailbox(mail, mailbox)\n\n try:\n\
|
||||
\ if last_uid is None:\n # 初回実行: 現在の最大UIDを記録して終了(既存メールは処理しない)\n\
|
||||
\ _, data = mail.uid(\"SEARCH\", None, \"ALL\")\n all_uids\
|
||||
\ = data[0].split() if data[0] else []\n max_uid = int(all_uids[-1])\
|
||||
\ if all_uids else 0\n wmill.set_variable(account[\"last_uid_var\"\
|
||||
], str(max_uid))\n print(f\"[{account['name']}] 初回実行: 最大UID={max_uid}\
|
||||
\ を記録、既存メールはスキップ\")\n return 0, 0\n\n # last_uid より大きい UID\
|
||||
\ を検索\n search_criterion = f\"UID {last_uid + 1}:*\"\n _, data\
|
||||
\ = mail.uid(\"SEARCH\", None, search_criterion)\n raw_uids = data[0].split()\
|
||||
\ if data[0] else []\n new_uids = [u for u in raw_uids if int(u) >\
|
||||
\ last_uid]\n\n if not new_uids:\n print(f\"[{account['name']}]\
|
||||
\ 新着メールなし\")\n return 0, 0\n\n print(f\"[{account['name']}]\
|
||||
\ 新着{len(new_uids)}件\")\n\n processed = 0\n notified = 0\n \
|
||||
\ max_processed_uid = last_uid\n\n for uid_bytes in new_uids:\n\
|
||||
\ uid = int(uid_bytes)\n try:\n result\
|
||||
\ = process_message(\n mail, uid, account,\n \
|
||||
\ api_key, api_url, gemini_key, line_token, line_to\n \
|
||||
\ )\n processed += 1\n if result == \"notified\"\
|
||||
:\n notified += 1\n max_processed_uid =\
|
||||
\ max(max_processed_uid, uid)\n except Exception as e:\n \
|
||||
\ print(f\"[{account['name']}] UID={uid} 処理エラー: {e}\")\n \
|
||||
\ # 個別メッセージのエラーは継続、UIDは進めない\n\n # 処理済み最大UIDを保存(正常完了時のみ)\n \
|
||||
\ if max_processed_uid > last_uid:\n wmill.set_variable(account[\"\
|
||||
last_uid_var\"], str(max_processed_uid))\n\n return processed, notified\n\
|
||||
\ finally:\n mail.logout()\n\n\ndef process_message(mail, uid, account,\
|
||||
\ api_key, api_url, gemini_key, line_token, line_to):\n \"\"\"メッセージを1通処理。戻り値:\
|
||||
\ 'skipped' / 'not_important' / 'notified'\"\"\"\n account_code = account[\"\
|
||||
account_code\"]\n forwarding_map = account.get(\"forwarding_map\", {})\n\
|
||||
\n # メール取得\n _, data = mail.uid(\"FETCH\", str(uid), \"(RFC822)\")\n\
|
||||
\ if not data or not data[0]:\n return \"skipped\"\n\n raw_email\
|
||||
\ = data[0][1]\n msg = email.message_from_bytes(raw_email)\n\n # ヘッダー解析\n\
|
||||
\ message_id = msg.get(\"Message-ID\", \"\").strip()\n if not message_id:\n\
|
||||
\ message_id = f\"{account_code}-uid-{uid}\"\n\n sender_raw = msg.get(\"\
|
||||
From\", \"\")\n sender_email_addr = extract_email_address(sender_raw)\n\
|
||||
\ sender_domain = sender_email_addr.split(\"@\")[-1] if \"@\" in sender_email_addr\
|
||||
\ else \"\"\n\n subject = decode_header_value(msg.get(\"Subject\", \"(件名なし)\"\
|
||||
))\n\n date_str = msg.get(\"Date\", \"\")\n try:\n received_at\
|
||||
\ = parsedate_to_datetime(date_str).isoformat()\n except Exception:\n \
|
||||
\ received_at = datetime.now(JST).isoformat()\n\n body_preview =\
|
||||
\ extract_body_preview(msg, max_chars=500)\n\n # 転送検出: To:ヘッダーのドメインが forwarding_map\
|
||||
\ に存在する場合は account_code を上書き\n if forwarding_map:\n to_raw = msg.get(\"\
|
||||
To\", \"\")\n if to_raw:\n to_addr = extract_email_address(to_raw)\n\
|
||||
\ to_domain = to_addr.split(\"@\")[-1] if \"@\" in to_addr else\
|
||||
\ \"\"\n if to_domain in forwarding_map:\n account_code\
|
||||
\ = forwarding_map[to_domain]\n print(f\" [転送検出] To:{to_addr}\
|
||||
\ → account: {account_code}\")\n\n print(f\" From: {sender_email_addr},\
|
||||
\ Subject: {subject[:50]}\")\n\n # --- ステップ1: 送信者ルール確認 ---\n rule_result\
|
||||
\ = call_api_get(api_key, api_url, \"/api/mail/sender-rule/\", {\n \
|
||||
\ \"email\": sender_email_addr,\n \"domain\": sender_domain,\n })\n\
|
||||
\n if rule_result.get(\"matched\"):\n rule = rule_result[\"rule\"\
|
||||
]\n\n if rule == \"never_notify\":\n print(f\" → never_notify\
|
||||
\ ルール一致、スキップ\")\n return \"skipped\"\n\n elif rule == \"\
|
||||
always_notify\":\n print(f\" → always_notify ルール一致、即通知\")\n \
|
||||
\ result = post_email(api_key, api_url, {\n \"account\"\
|
||||
: account_code,\n \"message_id\": message_id,\n \
|
||||
\ \"sender_email\": sender_email_addr,\n \"sender_domain\"\
|
||||
: sender_domain,\n \"subject\": subject,\n \"\
|
||||
body_preview\": body_preview,\n \"received_at\": received_at,\n\
|
||||
\ \"llm_verdict\": \"important\",\n })\n \
|
||||
\ if result.get(\"feedback_url\"):\n send_line_notification(line_token,\
|
||||
\ line_to, account_code, sender_email_addr, subject, result[\"feedback_url\"\
|
||||
])\n return \"notified\"\n return \"skipped\"\n\n\
|
||||
\ # --- ステップ2: LLM判定 ---\n context = call_api_get(api_key, api_url,\
|
||||
\ \"/api/mail/sender-context/\", {\n \"email\": sender_email_addr,\n\
|
||||
\ \"domain\": sender_domain,\n })\n verdict = judge_with_llm(gemini_key,\
|
||||
\ sender_email_addr, subject, body_preview, context)\n print(f\" → LLM判定:\
|
||||
\ {verdict}\")\n\n # --- ステップ3: Keinasystemに記録 ---\n result = post_email(api_key,\
|
||||
\ api_url, {\n \"account\": account_code,\n \"message_id\":\
|
||||
\ message_id,\n \"sender_email\": sender_email_addr,\n \"sender_domain\"\
|
||||
: sender_domain,\n \"subject\": subject,\n \"body_preview\"\
|
||||
: body_preview,\n \"received_at\": received_at,\n \"llm_verdict\"\
|
||||
: verdict,\n })\n\n if verdict == \"important\" and result.get(\"feedback_url\"\
|
||||
):\n send_line_notification(line_token, line_to, account_code, sender_email_addr,\
|
||||
\ subject, result[\"feedback_url\"])\n return \"notified\"\n\n return\
|
||||
\ \"not_important\"\n\n\n# ============================================================\n\
|
||||
# メールボックス解決\n# ============================================================\n\
|
||||
\ndef resolve_mailbox(mail, mailbox):\n \"\"\"メールボックスを選択し SELECT する。\n\
|
||||
\ INBOX はそのまま、それ以外は指定名 -> \\\\All 属性でフォールバック。\n \"\"\"\n if mailbox\
|
||||
\ == \"INBOX\":\n typ, data = mail.select(\"INBOX\")\n if typ\
|
||||
\ != 'OK':\n raise Exception(f\"SELECT INBOX failed: {data}\")\n\
|
||||
\ return \"INBOX\"\n\n # まず指定名で試行\n imap_name = '\"' + mailbox\
|
||||
\ + '\"'\n typ, data = mail.select(imap_name)\n if typ == 'OK':\n \
|
||||
\ return imap_name\n\n # 失敗した場合: \\\\All 属性を持つメールボックスを自動検出\n print(f\"\
|
||||
\ [INFO] {mailbox} not found, searching for \\\\\\\\All mailbox...\")\n \
|
||||
\ typ2, mboxes = mail.list()\n if typ2 == 'OK':\n for mb in mboxes:\n\
|
||||
\ if not mb:\n continue\n mb_str = mb.decode()\
|
||||
\ if isinstance(mb, bytes) else mb\n if '\\\\\\\\All' in mb_str\
|
||||
\ or '\\\\All' in mb_str:\n # \"(attrs) \\\".\\\" \\\"name\\\
|
||||
\"\" 形式から名前を抽出\n parts = mb_str.rsplit('\"', 2)\n \
|
||||
\ if len(parts) >= 2 and parts[-2]:\n found = parts[-2]\n\
|
||||
\ else:\n found = mb_str.split()[-1].strip('\"\
|
||||
')\n print(f\" [INFO] Found All Mail mailbox: {found}\")\n\
|
||||
\ imap_found = '\"' + found + '\"'\n typ3, data3\
|
||||
\ = mail.select(imap_found)\n if typ3 == 'OK':\n \
|
||||
\ return imap_found\n raise Exception(f\"Could not select any\
|
||||
\ All Mail mailbox (tried: {mailbox})\")\n\n\n# ============================================================\n\
|
||||
# APIヘルパー\n# ============================================================\n\
|
||||
\ndef _make_ssl_ctx():\n ctx = ssl.create_default_context()\n ctx.check_hostname\
|
||||
\ = False\n ctx.verify_mode = ssl.CERT_NONE\n return ctx\n\n\ndef call_api_get(api_key,\
|
||||
\ api_url, path, params):\n qs = urllib.parse.urlencode(params)\n url\
|
||||
\ = f\"{api_url}{path}?{qs}\"\n req = urllib.request.Request(url, headers={\"\
|
||||
X-API-Key\": api_key})\n with urllib.request.urlopen(req, context=_make_ssl_ctx(),\
|
||||
\ timeout=10) as resp:\n return json.loads(resp.read().decode(\"utf-8\"\
|
||||
))\n\n\ndef post_email(api_key, api_url, data):\n url = f\"{api_url}/api/mail/emails/\"\
|
||||
\n payload = json.dumps(data).encode(\"utf-8\")\n req = urllib.request.Request(\n\
|
||||
\ url,\n data=payload,\n headers={\"X-API-Key\": api_key,\
|
||||
\ \"Content-Type\": \"application/json\"},\n method=\"POST\",\n \
|
||||
\ )\n try:\n with urllib.request.urlopen(req, context=_make_ssl_ctx(),\
|
||||
\ timeout=10) as resp:\n return json.loads(resp.read().decode(\"\
|
||||
utf-8\"))\n except urllib.error.HTTPError as e:\n body = e.read().decode(\"\
|
||||
utf-8\")\n if e.code == 400 and \"message_id\" in body:\n \
|
||||
\ # 重複message_idは正常(再実行時の冦殁)\n print(f\" 重複メール、スキップ\")\n \
|
||||
\ return {}\n raise\n\n\nACCOUNT_LABELS = {\n \"gmail\"\
|
||||
: \"Gmail (メイン)\",\n \"gmail_service\": \"Gmail (サービス用)\",\n \"hotmail\"\
|
||||
: \"Hotmail\",\n \"xserver\": \"Xserver\",\n}\n\ndef send_line_notification(line_token,\
|
||||
\ line_to, account_code, sender_email_addr, subject, feedback_url):\n account_label\
|
||||
\ = ACCOUNT_LABELS.get(account_code, account_code)\n message = (\n \
|
||||
\ f\"\U0001F4E7 重要なメールが届きました\\n\\n\"\n f\"宛先: {account_label}\\\
|
||||
n\"\n f\"差出人: {sender_email_addr}\\n\"\n f\"件名: {subject}\\\
|
||||
n\\n\"\n f\"フィードバック:\\n{feedback_url}\"\n )\n payload = json.dumps({\n\
|
||||
\ \"to\": line_to,\n \"messages\": [{\"type\": \"text\", \"\
|
||||
text\": message}],\n }).encode(\"utf-8\")\n req = urllib.request.Request(\n\
|
||||
\ \"https://api.line.me/v2/bot/message/push\",\n data=payload,\n\
|
||||
\ headers={\n \"Authorization\": f\"Bearer {line_token}\"\
|
||||
,\n \"Content-Type\": \"application/json\",\n },\n \
|
||||
\ method=\"POST\",\n )\n with urllib.request.urlopen(req, timeout=30)\
|
||||
\ as resp:\n resp.read()\n\n\n# ============================================================\n\
|
||||
# LLM判定(Gemini API)\n# ============================================================\n\
|
||||
\ndef judge_with_llm(gemini_key, sender_email_addr, subject, body_preview,\
|
||||
\ context):\n \"\"\"農家にとって重要なメールか判定。'important' または 'not_important' を返す。\"\
|
||||
\"\"\n\n context_text = \"\"\n total = context.get(\"total_notified\"\
|
||||
, 0)\n if total > 0:\n context_text = (\n f\"\\n\\n[この送信者の過去データ]\
|
||||
\ \"\n f\"通知済み{total}件: \"\n f\"重要{context.get('important',\
|
||||
\ 0)}件 / \"\n f\"普通{context.get('not_important', 0)}件 / \"\n \
|
||||
\ f\"通知不要{context.get('never_notify', 0)}件 / \"\n f\"\
|
||||
未評価{context.get('no_feedback', 0)}件\"\n )\n\n user_message = (\n\
|
||||
\ f\"送信者: {sender_email_addr}\\n\"\n f\"件名: {subject}\\n\"\n\
|
||||
\ f\"本文冠頭:\\n{body_preview}\"\n f\"{context_text}\\n\\n\"\n\
|
||||
\ f\"このメールは農家にとって重要ですか?\\n\"\n f\"1: 重要(要確認)\\n\"\n f\"\
|
||||
2: 重要でない(営業・通知等)\\n\"\n f\"数字1文字のみで答えてください。\"\n )\n\n payload\
|
||||
\ = json.dumps({\n \"system_instruction\": {\n \"parts\"\
|
||||
: [{\"text\": \"あなたは農家のメールフィルタリングアシスタントです。メールが重要かどうかを判定してください。\"}]\n \
|
||||
\ },\n \"contents\": [{\n \"role\": \"user\",\n \
|
||||
\ \"parts\": [{\"text\": user_message}]\n }],\n \"generationConfig\"\
|
||||
: {\n \"maxOutputTokens\": 10,\n \"temperature\": 0\n\
|
||||
\ }\n }).encode(\"utf-8\")\n\n url = f\"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={gemini_key}\"\
|
||||
\n req = urllib.request.Request(\n url,\n data=payload,\n\
|
||||
\ headers={\"Content-Type\": \"application/json\"},\n method=\"\
|
||||
POST\",\n )\n with urllib.request.urlopen(req, timeout=30) as resp:\n\
|
||||
\ result = json.loads(resp.read().decode(\"utf-8\"))\n answer\
|
||||
\ = result[\"candidates\"][0][\"content\"][\"parts\"][0][\"text\"].strip()\n\
|
||||
\n return \"important\" if answer.startswith(\"1\") else \"not_important\"\
|
||||
\n\n\n# ============================================================\n# メール解析ヘルパー\n\
|
||||
# ============================================================\n\ndef extract_email_address(raw):\n\
|
||||
\ \"\"\"'Name <email@example.com>' または 'email@example.com' からアドレスを抽出\"\"\
|
||||
\"\n match = re.search(r'<([^>]+)>', raw)\n if match:\n return\
|
||||
\ match.group(1).strip().lower()\n return raw.strip().lower()\n\n\ndef\
|
||||
\ decode_header_value(value):\n \"\"\"MIMEエンコードされたヘッダー値をデコード\"\"\"\n \
|
||||
\ if not value:\n return \"\"\n parts = email.header.decode_header(value)\n\
|
||||
\ decoded = []\n for part, charset in parts:\n if isinstance(part,\
|
||||
\ bytes):\n decoded.append(part.decode(charset or \"utf-8\", errors=\"\
|
||||
replace\"))\n else:\n decoded.append(part)\n return \"\
|
||||
\".join(decoded)\n\n\ndef extract_body_preview(msg, max_chars=500):\n \"\
|
||||
\"\"メール本文の冠頭を抽出(テキスト優先、HTMLフォールバック)\"\"\"\n text_content = \"\"\n html_content\
|
||||
\ = \"\"\n\n if msg.is_multipart():\n for part in msg.walk():\n\
|
||||
\ ctype = part.get_content_type()\n if ctype == \"text/plain\"\
|
||||
\ and not text_content:\n charset = part.get_content_charset()\
|
||||
\ or \"utf-8\"\n try:\n text_content = part.get_payload(decode=True).decode(charset,\
|
||||
\ errors=\"replace\")\n except Exception:\n \
|
||||
\ pass\n elif ctype == \"text/html\" and not html_content:\n\
|
||||
\ charset = part.get_content_charset() or \"utf-8\"\n \
|
||||
\ try:\n html_content = part.get_payload(decode=True).decode(charset,\
|
||||
\ errors=\"replace\")\n except Exception:\n \
|
||||
\ pass\n else:\n charset = msg.get_content_charset() or \"utf-8\"\
|
||||
\n try:\n content = msg.get_payload(decode=True).decode(charset,\
|
||||
\ errors=\"replace\")\n if msg.get_content_type() == \"text/html\"\
|
||||
:\n html_content = content\n else:\n \
|
||||
\ text_content = content\n except Exception:\n pass\n\
|
||||
\n if text_content:\n # フッター・区切り線を除去\n text = re.sub(r'\\\
|
||||
n[-_=]{10,}\\n.*', '', text_content, flags=re.DOTALL)\n text = re.sub(r'\\\
|
||||
s+', ' ', text).strip()\n return text[:max_chars]\n\n if html_content:\n\
|
||||
\ # HTMLタグを除去\n text = re.sub(r'<[^>]+>', ' ', html_content)\n\
|
||||
\ text = re.sub(r'\\s+', ' ', text).strip()\n return text[:max_chars]\n\
|
||||
\n return \"\"\n"
|
||||
language: python3
|
||||
input_transforms: {}
|
||||
summary: メール取得・判定・通知
|
||||
schema:
|
||||
$schema: https://json-schema.org/draft/2020-12/schema
|
||||
type: object
|
||||
order: []
|
||||
properties: {}
|
||||
required: []
|
||||
Reference in New Issue
Block a user