diff --git a/flows/mail_filter.flow.json b/flows/mail_filter.flow.json index ef0eadf..c1ee0d0 100644 --- a/flows/mail_filter.flow.json +++ b/flows/mail_filter.flow.json @@ -9,7 +9,7 @@ "value": { "lock": "# py: 3.12\nanyio==4.12.1\ncertifi==2026.1.4\nh11==0.16.0\nhttpcore==1.0.9\nhttpx==0.28.1\nidna==3.11\ntyping-extensions==4.15.0\nwmill==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\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n# ============================================================\n# アカウント設定\n# 新しいアカウントを追加する際は enabled: True にする\n# ============================================================\nACCOUNTS = [\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\": \"xserver1\",\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\": \"xserver2\",\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\": \"xserver3\",\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\": \"xserver4\",\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\": \"xserver5\",\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\": \"xserver6\",\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 \"xserver1\": \"Xserver (akira@keinafarm.com)\",\n \"xserver2\": \"Xserver (service@keinafarm.com)\",\n \"xserver3\": \"Xserver (midori@keinafarm.com)\",\n \"xserver4\": \"Xserver (kouseiren@keinafarm.com)\",\n \"xserver5\": \"Xserver (post@keinafarm.com)\",\n \"xserver6\": \"Xserver (sales@keinafarm.com)\",\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\"📧 重要なメールが届きました\\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' からアドレスを抽出\"\"\"\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", + "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\nimport wmill\n\nJST = timezone(timedelta(hours=9))\n\n# ============================================================\n# アカウント設定\n# 新しいアカウントを追加する際は enabled: True にする\n# ============================================================\nACCOUNTS = [\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\": \"xserver1\",\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\": \"xserver2\",\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\": \"xserver3\",\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\": \"xserver4\",\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\": \"xserver5\",\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\": \"xserver6\",\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 recipient_map = {\n \"akira@keinafarm.com\": \"xserver1\",\n \"service@keinafarm.com\": \"xserver2\",\n \"midori@keinafarm.com\": \"xserver3\",\n \"kouseiren@keinafarm.com\": \"xserver4\",\n \"post@keinafarm.com\": \"xserver5\",\n \"sales@keinafarm.com\": \"xserver6\",\n }\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:ヘッダーから account_code を補正(転送/重複受信時の誤判定防止)\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 mapped = forwarding_map.get(to_addr) or forwarding_map.get(to_domain) or recipient_map.get(to_addr)\n if mapped:\n account_code = mapped\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 \"xserver1\": \"Xserver (akira@keinafarm.com)\",\n \"xserver2\": \"Xserver (service@keinafarm.com)\",\n \"xserver3\": \"Xserver (midori@keinafarm.com)\",\n \"xserver4\": \"Xserver (kouseiren@keinafarm.com)\",\n \"xserver5\": \"Xserver (post@keinafarm.com)\",\n \"xserver6\": \"Xserver (sales@keinafarm.com)\",\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\"📧 重要なメールが届きました\\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' からアドレスを抽出\"\"\"\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": {} }, @@ -26,3 +26,4 @@ } } +