Auto-sync: 2026-02-25 07:14:42
This commit is contained in:
1
wmill_config/activeWorkspace
Normal file
1
wmill_config/activeWorkspace
Normal file
@@ -0,0 +1 @@
|
|||||||
|
admins
|
||||||
1
wmill_config/remotes.ndjson
Normal file
1
wmill_config/remotes.ndjson
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"remote":"http://windmill_server:8000/","workspaceId":"admins","name":"admins","token":"CQKYm1bUwszHCT4Ww6TGyQX97XMs8qg8"}
|
||||||
1
wmill_config/windmill/activeWorkspace
Normal file
1
wmill_config/windmill/activeWorkspace
Normal file
@@ -0,0 +1 @@
|
|||||||
|
admins
|
||||||
1
wmill_config/windmill/remotes.ndjson
Normal file
1
wmill_config/windmill/remotes.ndjson
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"remote":"http://windmill_server:8000/","workspaceId":"admins","name":"admins","token":"CQKYm1bUwszHCT4Ww6TGyQX97XMs8qg8"}
|
||||||
@@ -1,289 +1,18 @@
|
|||||||
summary: メールフィルタリング
|
summary: メールフィルタリング
|
||||||
description: IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。
|
description: >-
|
||||||
|
IMAPで新着メールを受信し、送信者ルール確認→LLM判定→LINE通知を行う。Keinasystemと連携。Gmail→Hotmail→Xserverの順で段階的に有効化する。
|
||||||
value:
|
value:
|
||||||
modules:
|
modules:
|
||||||
- id: a
|
- id: a
|
||||||
value:
|
summary: メール取得・判定・通知
|
||||||
lock: '# py: 3.12
|
value:
|
||||||
|
type: rawscript
|
||||||
anyio==4.12.1
|
content: '!inline メール取得・判定・通知.py'
|
||||||
|
input_transforms: {}
|
||||||
certifi==2026.1.4
|
lock: '!inline メール取得・判定・通知.lock'
|
||||||
|
language: python3
|
||||||
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:
|
||||||
$schema: https://json-schema.org/draft/2020-12/schema
|
$schema: 'https://json-schema.org/draft/2020-12/schema'
|
||||||
type: object
|
type: object
|
||||||
order: []
|
order: []
|
||||||
properties: {}
|
properties: {}
|
||||||
|
|||||||
9
workflows/f/mail/mail_filter__flow/メール取得・判定・通知.lock
Normal file
9
workflows/f/mail/mail_filter__flow/メール取得・判定・通知.lock
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
561
workflows/f/mail/mail_filter__flow/メール取得・判定・通知.py
Normal file
561
workflows/f/mail/mail_filter__flow/メール取得・判定・通知.py
Normal file
@@ -0,0 +1,561 @@
|
|||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
import email.header
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
import wmill
|
||||||
|
|
||||||
|
JST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# アカウント設定
|
||||||
|
# 新しいアカウントを追加する際は enabled: True にする
|
||||||
|
# ============================================================
|
||||||
|
ACCOUNTS = [
|
||||||
|
{
|
||||||
|
"name": "gmail",
|
||||||
|
"account_code": "gmail",
|
||||||
|
"host": "imap.gmail.com",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/GMAIL_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/GMAIL_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_GMAIL_LAST_UID",
|
||||||
|
"mailbox": "[Gmail]/All Mail",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gmail_service",
|
||||||
|
"account_code": "gmail_service",
|
||||||
|
"host": "imap.gmail.com",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/GMAIL2_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/GMAIL2_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_GMAIL2_LAST_UID",
|
||||||
|
"mailbox": "[Gmail]/All Mail",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
# Hotmail テスト後に有効化
|
||||||
|
# {
|
||||||
|
# "name": "hotmail",
|
||||||
|
# "account_code": "hotmail",
|
||||||
|
# "host": "outlook.office365.com",
|
||||||
|
# "port": 993,
|
||||||
|
# "user_var": "u/admin/HOTMAIL_IMAP_USER",
|
||||||
|
# "pass_var": "u/admin/HOTMAIL_IMAP_PASSWORD",
|
||||||
|
# "last_uid_var": "u/admin/MAIL_FILTER_HOTMAIL_LAST_UID",
|
||||||
|
# "enabled": False,
|
||||||
|
# },
|
||||||
|
# Xserver (keinafarm.com) 6アカウント
|
||||||
|
{
|
||||||
|
"name": "xserver_akiracraftwork",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER1_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER1_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER1_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xserver_service",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER2_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER2_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER2_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xserver_midori",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER3_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER3_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER3_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xserver_kouseiren",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER4_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER4_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER4_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xserver_post",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER5_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER5_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER5_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "xserver_sales",
|
||||||
|
"account_code": "xserver",
|
||||||
|
"host": "sv579.xserver.jp",
|
||||||
|
"port": 993,
|
||||||
|
"user_var": "u/admin/XSERVER6_IMAP_USER",
|
||||||
|
"pass_var": "u/admin/XSERVER6_IMAP_PASSWORD",
|
||||||
|
"last_uid_var": "u/admin/MAIL_FILTER_XSERVER6_LAST_UID",
|
||||||
|
"enabled": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 共通変数取得
|
||||||
|
api_key = wmill.get_variable("u/admin/KEINASYSTEM_API_KEY")
|
||||||
|
api_url = wmill.get_variable("u/admin/KEINASYSTEM_API_URL").rstrip("/")
|
||||||
|
gemini_key = wmill.get_variable("u/admin/GEMINI_API_KEY")
|
||||||
|
line_token = wmill.get_variable("u/admin/LINE_CHANNEL_ACCESS_TOKEN")
|
||||||
|
line_to = wmill.get_variable("u/admin/LINE_TO")
|
||||||
|
|
||||||
|
total_processed = 0
|
||||||
|
total_notified = 0
|
||||||
|
|
||||||
|
for account in ACCOUNTS:
|
||||||
|
if not account["enabled"]:
|
||||||
|
continue
|
||||||
|
print(f"[{account['name']}] 処理開始")
|
||||||
|
try:
|
||||||
|
processed, notified = process_account(
|
||||||
|
account, api_key, api_url, gemini_key, line_token, line_to
|
||||||
|
)
|
||||||
|
total_processed += processed
|
||||||
|
total_notified += notified
|
||||||
|
print(f"[{account['name']}] 処理完了: {processed}件処理, {notified}件通知")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{account['name']}] エラー: {e}")
|
||||||
|
# 1アカウントが失敗しても他のアカウントは継続
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_processed": total_processed,
|
||||||
|
"total_notified": total_notified,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def process_account(account, api_key, api_url, gemini_key, line_token, line_to):
|
||||||
|
user = wmill.get_variable(account["user_var"])
|
||||||
|
password = wmill.get_variable(account["pass_var"])
|
||||||
|
|
||||||
|
# 前回の最終UID取得
|
||||||
|
try:
|
||||||
|
last_uid_str = wmill.get_variable(account["last_uid_var"])
|
||||||
|
last_uid = int(last_uid_str) if last_uid_str and last_uid_str != "0" else None
|
||||||
|
except Exception:
|
||||||
|
last_uid = None
|
||||||
|
|
||||||
|
# IMAP接続
|
||||||
|
ssl_ctx = ssl.create_default_context()
|
||||||
|
mail = imaplib.IMAP4_SSL(account["host"], account["port"], ssl_context=ssl_ctx)
|
||||||
|
mail.login(user, password)
|
||||||
|
mailbox = account.get("mailbox", "INBOX")
|
||||||
|
imap_mailbox = resolve_mailbox(mail, mailbox)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if last_uid is None:
|
||||||
|
# 初回実行: 現在の最大UIDを記録して終了(既存メールは処理しない)
|
||||||
|
_, data = mail.uid("SEARCH", None, "ALL")
|
||||||
|
all_uids = data[0].split() if data[0] else []
|
||||||
|
max_uid = int(all_uids[-1]) if all_uids else 0
|
||||||
|
wmill.set_variable(account["last_uid_var"], str(max_uid))
|
||||||
|
print(f"[{account['name']}] 初回実行: 最大UID={max_uid} を記録、既存メールはスキップ")
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
# last_uid より大きい UID を検索
|
||||||
|
search_criterion = f"UID {last_uid + 1}:*"
|
||||||
|
_, data = mail.uid("SEARCH", None, search_criterion)
|
||||||
|
raw_uids = data[0].split() if data[0] else []
|
||||||
|
new_uids = [u for u in raw_uids if int(u) > last_uid]
|
||||||
|
|
||||||
|
if not new_uids:
|
||||||
|
print(f"[{account['name']}] 新着メールなし")
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
print(f"[{account['name']}] 新着{len(new_uids)}件")
|
||||||
|
|
||||||
|
processed = 0
|
||||||
|
notified = 0
|
||||||
|
max_processed_uid = last_uid
|
||||||
|
|
||||||
|
for uid_bytes in new_uids:
|
||||||
|
uid = int(uid_bytes)
|
||||||
|
try:
|
||||||
|
result = process_message(
|
||||||
|
mail, uid, account,
|
||||||
|
api_key, api_url, gemini_key, line_token, line_to
|
||||||
|
)
|
||||||
|
processed += 1
|
||||||
|
if result == "notified":
|
||||||
|
notified += 1
|
||||||
|
max_processed_uid = max(max_processed_uid, uid)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{account['name']}] UID={uid} 処理エラー: {e}")
|
||||||
|
# 個別メッセージのエラーは継続、UIDは進めない
|
||||||
|
|
||||||
|
# 処理済み最大UIDを保存(正常完了時のみ)
|
||||||
|
if max_processed_uid > last_uid:
|
||||||
|
wmill.set_variable(account["last_uid_var"], str(max_processed_uid))
|
||||||
|
|
||||||
|
return processed, notified
|
||||||
|
finally:
|
||||||
|
mail.logout()
|
||||||
|
|
||||||
|
|
||||||
|
def process_message(mail, uid, account, api_key, api_url, gemini_key, line_token, line_to):
|
||||||
|
"""メッセージを1通処理。戻り値: 'skipped' / 'not_important' / 'notified'"""
|
||||||
|
account_code = account["account_code"]
|
||||||
|
forwarding_map = account.get("forwarding_map", {})
|
||||||
|
|
||||||
|
# メール取得
|
||||||
|
_, data = mail.uid("FETCH", str(uid), "(RFC822)")
|
||||||
|
if not data or not data[0]:
|
||||||
|
return "skipped"
|
||||||
|
|
||||||
|
raw_email = data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
|
||||||
|
# ヘッダー解析
|
||||||
|
message_id = msg.get("Message-ID", "").strip()
|
||||||
|
if not message_id:
|
||||||
|
message_id = f"{account_code}-uid-{uid}"
|
||||||
|
|
||||||
|
sender_raw = msg.get("From", "")
|
||||||
|
sender_email_addr = extract_email_address(sender_raw)
|
||||||
|
sender_domain = sender_email_addr.split("@")[-1] if "@" in sender_email_addr else ""
|
||||||
|
|
||||||
|
subject = decode_header_value(msg.get("Subject", "(件名なし)"))
|
||||||
|
|
||||||
|
date_str = msg.get("Date", "")
|
||||||
|
try:
|
||||||
|
received_at = parsedate_to_datetime(date_str).isoformat()
|
||||||
|
except Exception:
|
||||||
|
received_at = datetime.now(JST).isoformat()
|
||||||
|
|
||||||
|
body_preview = extract_body_preview(msg, max_chars=500)
|
||||||
|
|
||||||
|
# 転送検出: To:ヘッダーのドメインが forwarding_map に存在する場合は account_code を上書き
|
||||||
|
if forwarding_map:
|
||||||
|
to_raw = msg.get("To", "")
|
||||||
|
if to_raw:
|
||||||
|
to_addr = extract_email_address(to_raw)
|
||||||
|
to_domain = to_addr.split("@")[-1] if "@" in to_addr else ""
|
||||||
|
if to_domain in forwarding_map:
|
||||||
|
account_code = forwarding_map[to_domain]
|
||||||
|
print(f" [転送検出] To:{to_addr} → account: {account_code}")
|
||||||
|
|
||||||
|
print(f" From: {sender_email_addr}, Subject: {subject[:50]}")
|
||||||
|
|
||||||
|
# --- ステップ1: 送信者ルール確認 ---
|
||||||
|
rule_result = call_api_get(api_key, api_url, "/api/mail/sender-rule/", {
|
||||||
|
"email": sender_email_addr,
|
||||||
|
"domain": sender_domain,
|
||||||
|
})
|
||||||
|
|
||||||
|
if rule_result.get("matched"):
|
||||||
|
rule = rule_result["rule"]
|
||||||
|
|
||||||
|
if rule == "never_notify":
|
||||||
|
print(f" → never_notify ルール一致、スキップ")
|
||||||
|
return "skipped"
|
||||||
|
|
||||||
|
elif rule == "always_notify":
|
||||||
|
print(f" → always_notify ルール一致、即通知")
|
||||||
|
result = post_email(api_key, api_url, {
|
||||||
|
"account": account_code,
|
||||||
|
"message_id": message_id,
|
||||||
|
"sender_email": sender_email_addr,
|
||||||
|
"sender_domain": sender_domain,
|
||||||
|
"subject": subject,
|
||||||
|
"body_preview": body_preview,
|
||||||
|
"received_at": received_at,
|
||||||
|
"llm_verdict": "important",
|
||||||
|
})
|
||||||
|
if result.get("feedback_url"):
|
||||||
|
send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, result["feedback_url"])
|
||||||
|
return "notified"
|
||||||
|
return "skipped"
|
||||||
|
|
||||||
|
# --- ステップ2: LLM判定 ---
|
||||||
|
context = call_api_get(api_key, api_url, "/api/mail/sender-context/", {
|
||||||
|
"email": sender_email_addr,
|
||||||
|
"domain": sender_domain,
|
||||||
|
})
|
||||||
|
verdict = judge_with_llm(gemini_key, sender_email_addr, subject, body_preview, context)
|
||||||
|
print(f" → LLM判定: {verdict}")
|
||||||
|
|
||||||
|
# --- ステップ3: Keinasystemに記録 ---
|
||||||
|
result = post_email(api_key, api_url, {
|
||||||
|
"account": account_code,
|
||||||
|
"message_id": message_id,
|
||||||
|
"sender_email": sender_email_addr,
|
||||||
|
"sender_domain": sender_domain,
|
||||||
|
"subject": subject,
|
||||||
|
"body_preview": body_preview,
|
||||||
|
"received_at": received_at,
|
||||||
|
"llm_verdict": verdict,
|
||||||
|
})
|
||||||
|
|
||||||
|
if verdict == "important" and result.get("feedback_url"):
|
||||||
|
send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, result["feedback_url"])
|
||||||
|
return "notified"
|
||||||
|
|
||||||
|
return "not_important"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# メールボックス解決
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def resolve_mailbox(mail, mailbox):
|
||||||
|
"""メールボックスを選択し SELECT する。
|
||||||
|
INBOX はそのまま、それ以外は指定名 -> \\All 属性でフォールバック。
|
||||||
|
"""
|
||||||
|
if mailbox == "INBOX":
|
||||||
|
typ, data = mail.select("INBOX")
|
||||||
|
if typ != 'OK':
|
||||||
|
raise Exception(f"SELECT INBOX failed: {data}")
|
||||||
|
return "INBOX"
|
||||||
|
|
||||||
|
# まず指定名で試行
|
||||||
|
imap_name = '"' + mailbox + '"'
|
||||||
|
typ, data = mail.select(imap_name)
|
||||||
|
if typ == 'OK':
|
||||||
|
return imap_name
|
||||||
|
|
||||||
|
# 失敗した場合: \\All 属性を持つメールボックスを自動検出
|
||||||
|
print(f" [INFO] {mailbox} not found, searching for \\\\All mailbox...")
|
||||||
|
typ2, mboxes = mail.list()
|
||||||
|
if typ2 == 'OK':
|
||||||
|
for mb in mboxes:
|
||||||
|
if not mb:
|
||||||
|
continue
|
||||||
|
mb_str = mb.decode() if isinstance(mb, bytes) else mb
|
||||||
|
if '\\\\All' in mb_str or '\\All' in mb_str:
|
||||||
|
# "(attrs) \".\" \"name\"" 形式から名前を抽出
|
||||||
|
parts = mb_str.rsplit('"', 2)
|
||||||
|
if len(parts) >= 2 and parts[-2]:
|
||||||
|
found = parts[-2]
|
||||||
|
else:
|
||||||
|
found = mb_str.split()[-1].strip('"')
|
||||||
|
print(f" [INFO] Found All Mail mailbox: {found}")
|
||||||
|
imap_found = '"' + found + '"'
|
||||||
|
typ3, data3 = mail.select(imap_found)
|
||||||
|
if typ3 == 'OK':
|
||||||
|
return imap_found
|
||||||
|
raise Exception(f"Could not select any All Mail mailbox (tried: {mailbox})")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# APIヘルパー
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _make_ssl_ctx():
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def call_api_get(api_key, api_url, path, params):
|
||||||
|
qs = urllib.parse.urlencode(params)
|
||||||
|
url = f"{api_url}{path}?{qs}"
|
||||||
|
req = urllib.request.Request(url, headers={"X-API-Key": api_key})
|
||||||
|
with urllib.request.urlopen(req, context=_make_ssl_ctx(), timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def post_email(api_key, api_url, data):
|
||||||
|
url = f"{api_url}/api/mail/emails/"
|
||||||
|
payload = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=_make_ssl_ctx(), timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8")
|
||||||
|
if e.code == 400 and "message_id" in body:
|
||||||
|
# 重複message_idは正常(再実行時の冦殁)
|
||||||
|
print(f" 重複メール、スキップ")
|
||||||
|
return {}
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
ACCOUNT_LABELS = {
|
||||||
|
"gmail": "Gmail (メイン)",
|
||||||
|
"gmail_service": "Gmail (サービス用)",
|
||||||
|
"hotmail": "Hotmail",
|
||||||
|
"xserver": "Xserver",
|
||||||
|
}
|
||||||
|
|
||||||
|
def send_line_notification(line_token, line_to, account_code, sender_email_addr, subject, feedback_url):
|
||||||
|
account_label = ACCOUNT_LABELS.get(account_code, account_code)
|
||||||
|
message = (
|
||||||
|
f"📧 重要なメールが届きました\n\n"
|
||||||
|
f"宛先: {account_label}\n"
|
||||||
|
f"差出人: {sender_email_addr}\n"
|
||||||
|
f"件名: {subject}\n\n"
|
||||||
|
f"フィードバック:\n{feedback_url}"
|
||||||
|
)
|
||||||
|
payload = json.dumps({
|
||||||
|
"to": line_to,
|
||||||
|
"messages": [{"type": "text", "text": message}],
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.line.me/v2/bot/message/push",
|
||||||
|
data=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {line_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# LLM判定(Gemini API)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def judge_with_llm(gemini_key, sender_email_addr, subject, body_preview, context):
|
||||||
|
"""農家にとって重要なメールか判定。'important' または 'not_important' を返す。"""
|
||||||
|
|
||||||
|
context_text = ""
|
||||||
|
total = context.get("total_notified", 0)
|
||||||
|
if total > 0:
|
||||||
|
context_text = (
|
||||||
|
f"\n\n[この送信者の過去データ] "
|
||||||
|
f"通知済み{total}件: "
|
||||||
|
f"重要{context.get('important', 0)}件 / "
|
||||||
|
f"普通{context.get('not_important', 0)}件 / "
|
||||||
|
f"通知不要{context.get('never_notify', 0)}件 / "
|
||||||
|
f"未評価{context.get('no_feedback', 0)}件"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_message = (
|
||||||
|
f"送信者: {sender_email_addr}\n"
|
||||||
|
f"件名: {subject}\n"
|
||||||
|
f"本文冠頭:\n{body_preview}"
|
||||||
|
f"{context_text}\n\n"
|
||||||
|
f"このメールは農家にとって重要ですか?\n"
|
||||||
|
f"1: 重要(要確認)\n"
|
||||||
|
f"2: 重要でない(営業・通知等)\n"
|
||||||
|
f"数字1文字のみで答えてください。"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"system_instruction": {
|
||||||
|
"parts": [{"text": "あなたは農家のメールフィルタリングアシスタントです。メールが重要かどうかを判定してください。"}]
|
||||||
|
},
|
||||||
|
"contents": [{
|
||||||
|
"role": "user",
|
||||||
|
"parts": [{"text": user_message}]
|
||||||
|
}],
|
||||||
|
"generationConfig": {
|
||||||
|
"maxOutputTokens": 10,
|
||||||
|
"temperature": 0
|
||||||
|
}
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={gemini_key}"
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
answer = result["candidates"][0]["content"]["parts"][0]["text"].strip()
|
||||||
|
|
||||||
|
return "important" if answer.startswith("1") else "not_important"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# メール解析ヘルパー
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def extract_email_address(raw):
|
||||||
|
"""'Name <email@example.com>' または 'email@example.com' からアドレスを抽出"""
|
||||||
|
match = re.search(r'<([^>]+)>', raw)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip().lower()
|
||||||
|
return raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_header_value(value):
|
||||||
|
"""MIMEエンコードされたヘッダー値をデコード"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
parts = email.header.decode_header(value)
|
||||||
|
decoded = []
|
||||||
|
for part, charset in parts:
|
||||||
|
if isinstance(part, bytes):
|
||||||
|
decoded.append(part.decode(charset or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded.append(part)
|
||||||
|
return "".join(decoded)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_body_preview(msg, max_chars=500):
|
||||||
|
"""メール本文の冠頭を抽出(テキスト優先、HTMLフォールバック)"""
|
||||||
|
text_content = ""
|
||||||
|
html_content = ""
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
ctype = part.get_content_type()
|
||||||
|
if ctype == "text/plain" and not text_content:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
text_content = part.get_payload(decode=True).decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif ctype == "text/html" and not html_content:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
html_content = part.get_payload(decode=True).decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
charset = msg.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
content = msg.get_payload(decode=True).decode(charset, errors="replace")
|
||||||
|
if msg.get_content_type() == "text/html":
|
||||||
|
html_content = content
|
||||||
|
else:
|
||||||
|
text_content = content
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if text_content:
|
||||||
|
# フッター・区切り線を除去
|
||||||
|
text = re.sub(r'\n[-_=]{10,}\n.*', '', text_content, flags=re.DOTALL)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
return text[:max_chars]
|
||||||
|
|
||||||
|
if html_content:
|
||||||
|
# HTMLタグを除去
|
||||||
|
text = re.sub(r'<[^>]+>', ' ', html_content)
|
||||||
|
text = re.sub(r'\s+', ' ', text).strip()
|
||||||
|
return text[:max_chars]
|
||||||
|
|
||||||
|
return ""
|
||||||
10
workflows/f/mail/mail_filter_schedule.schedule.yaml
Normal file
10
workflows/f/mail/mail_filter_schedule.schedule.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
args: {}
|
||||||
|
cron_version: v2
|
||||||
|
email: akiracraftwork@gmail.com
|
||||||
|
enabled: true
|
||||||
|
is_flow: true
|
||||||
|
no_flow_overlap: false
|
||||||
|
schedule: 0 */10 * * * *
|
||||||
|
script_path: f/mail/mail_filter
|
||||||
|
timezone: Asia/Tokyo
|
||||||
|
ws_error_handler_muted: false
|
||||||
@@ -1,69 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
export PATH=/usr/bin:/usr/local/bin:/usr/sbin:/sbin:/bin:$PATH
|
||||||
|
|
||||||
# 色付き出力
|
GREEN="[0;32m"
|
||||||
GREEN='\033[0;32m'
|
YELLOW="[1;33m"
|
||||||
YELLOW='\033[1;33m'
|
RED="[0;31m"
|
||||||
RED='\033[0;31m'
|
NC="[0m"
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
echo -e "${GREEN}=== Windmill Workflow Git Sync ===${NC}"
|
echo -e "${GREEN}=== Windmill Workflow Git Sync ===${NC}"
|
||||||
|
|
||||||
# リポジトリルート(コンテナ内: docker-compose.ymlの .:/workspace マウント)
|
|
||||||
REPO_ROOT="/workspace"
|
REPO_ROOT="/workspace"
|
||||||
# wmill.yamlがあるディレクトリ(Windmill CLIはここで実行する)
|
|
||||||
WMILL_DIR="${REPO_ROOT}/workflows"
|
WMILL_DIR="${REPO_ROOT}/workflows"
|
||||||
|
|
||||||
# Windmill CLIのセットアップ
|
|
||||||
if ! command -v wmill &> /dev/null; then
|
if ! command -v wmill &> /dev/null; then
|
||||||
echo -e "${YELLOW}Installing windmill-cli...${NC}"
|
echo -e "${YELLOW}Installing windmill-cli...${NC}"
|
||||||
npm install -g windmill-cli
|
npm install -g windmill-cli
|
||||||
|
export PATH=$(npm prefix -g)/bin:$PATH
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 環境変数チェック
|
|
||||||
if [ -z "$WM_TOKEN" ]; then
|
|
||||||
echo -e "${RED}Error: WM_TOKEN is not set.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# WM_BASE_URLはWindmill内で自動設定される場合があるが、念のため
|
|
||||||
: "${WM_BASE_URL:=http://windmill_server:8000}"
|
|
||||||
# Workspaceは環境変数または引数で
|
|
||||||
: "${WM_WORKSPACE:=admins}"
|
|
||||||
|
|
||||||
# Git設定(コンテナ内での一時設定)
|
|
||||||
git config --global --add safe.directory "$REPO_ROOT"
|
git config --global --add safe.directory "$REPO_ROOT"
|
||||||
git config --global user.email "bot@keinafarm.net"
|
git config --global user.email "bot@keinafarm.net"
|
||||||
git config --global user.name "Windmill Bot"
|
git config --global user.name "Windmill Bot"
|
||||||
|
|
||||||
# 1. Windmill(DB) -> Local Disk(wmill.yamlがあるディレクトリで実行)
|
|
||||||
echo -e "${YELLOW}Pulling from Windmill...${NC}"
|
echo -e "${YELLOW}Pulling from Windmill...${NC}"
|
||||||
cd "$WMILL_DIR"
|
cd "$WMILL_DIR"
|
||||||
wmill sync pull --token "$WM_TOKEN" --base-url "$WM_BASE_URL" --workspace "$WM_WORKSPACE" --skip-variables --skip-secrets --skip-resources --yes || exit 1
|
wmill sync pull --config-dir /workspace/wmill_config --skip-variables --skip-secrets --skip-resources --yes || exit 1
|
||||||
|
|
||||||
# 2. Local Disk -> Git Remote(Gitリポジトリルートに戻ってgit操作)
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
if [[ -n $(git status --porcelain) ]]; then
|
if [[ -n $(git status --porcelain) ]]; then
|
||||||
echo -e "${YELLOW}Changes detected, committing to Git...${NC}"
|
echo -e "${YELLOW}Changes detected, committing to Git...${NC}"
|
||||||
|
|
||||||
git add -A
|
git add -A
|
||||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
||||||
git commit -m "Auto-sync: ${TIMESTAMP}"
|
git commit -m "Auto-sync: ${TIMESTAMP}"
|
||||||
|
|
||||||
echo -e "${YELLOW}Pushing to Gitea...${NC}"
|
echo -e "${YELLOW}Pushing to Gitea...${NC}"
|
||||||
# リモートURLにトークンが含まれていない場合、プッシュに失敗する可能性がある
|
|
||||||
# ここでは既存のoriginを使用
|
|
||||||
git pull --rebase origin main || {
|
git pull --rebase origin main || {
|
||||||
echo -e "${RED}Failed to pull from remote. Trying push anyway...${NC}"
|
echo -e "${RED}Failed to pull from remote. Trying push anyway...${NC}"
|
||||||
}
|
}
|
||||||
git push origin main || {
|
git push origin main || {
|
||||||
echo -e "${RED}Failed to push. Need credentials in git remote url or credential helper.${NC}"
|
echo -e "${RED}Failed to push.${NC}"
|
||||||
echo -e "${YELLOW}Hint: git remote set-url origin https://<token>@gitea.keinafarm.net/...${NC}"
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
echo -e "${GREEN}Changes pushed to Gitea${NC}"
|
||||||
echo -e "${GREEN}✓ Changes pushed to Gitea${NC}"
|
|
||||||
else
|
else
|
||||||
echo -e "${GREEN}✓ No changes detected${NC}"
|
echo -e "${GREEN}No changes detected${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${GREEN}=== Sync Complete ===${NC}"
|
echo -e "${GREEN}=== Sync Complete ===${NC}"
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ locks:
|
|||||||
'f/app_custom/system_heartbeat__flow+step2:_データ検証.py': d7f4e6e04ed116ba3836cb32793a0187a69359a3f2a807b533030b01d42bed39
|
'f/app_custom/system_heartbeat__flow+step2:_データ検証.py': d7f4e6e04ed116ba3836cb32793a0187a69359a3f2a807b533030b01d42bed39
|
||||||
'f/app_custom/system_heartbeat__flow+step3:_httpヘルスチェック.py': 5d3bce0ddb4f521444bf01bc80670e7321933ad09f935044f4d6123c658ca7a8
|
'f/app_custom/system_heartbeat__flow+step3:_httpヘルスチェック.py': 5d3bce0ddb4f521444bf01bc80670e7321933ad09f935044f4d6123c658ca7a8
|
||||||
'f/app_custom/system_heartbeat__flow+step4:_年度判定_&_最終レポート.py': 6889bfac9a629fa42cf0505cbc945ba3782c59e1697b8493ce6101ef5ffa8b32
|
'f/app_custom/system_heartbeat__flow+step4:_年度判定_&_最終レポート.py': 6889bfac9a629fa42cf0505cbc945ba3782c59e1697b8493ce6101ef5ffa8b32
|
||||||
|
f/mail/mail_filter__flow+__flow_hash: 08d8ca9e024f743def5c1e8a90e424da3d9884628074f02c37dbb4c00599e9e9
|
||||||
|
f/mail/mail_filter__flow+メール取得・判定・通知.py: 0b9cc3ff72d6f3445d46005a657903ae8f195104d1623b47079d13691811c602
|
||||||
f/shiraou/shiraou_notification__flow+__flow_hash: 94825ff4362b6e4b6d165f8e17a51ebf8e5ef4da3e0ec1407a94b614ecab19dd
|
f/shiraou/shiraou_notification__flow+__flow_hash: 94825ff4362b6e4b6d165f8e17a51ebf8e5ef4da3e0ec1407a94b614ecab19dd
|
||||||
f/shiraou/shiraou_notification__flow+変更確認・line通知.py: ac80896991cce8132cfbf34d5dae20d3c09de5bc74a55c500e4c8705dd6a9d88
|
f/shiraou/shiraou_notification__flow+変更確認・line通知.py: ac80896991cce8132cfbf34d5dae20d3c09de5bc74a55c500e4c8705dd6a9d88
|
||||||
g/all/setup_app__app+__app_hash: d71add32e14e552d1a4c861c972a50d9598b07c0af201bbadec5b59bbd99d7e3
|
g/all/setup_app__app+__app_hash: d71add32e14e552d1a4c861c972a50d9598b07c0af201bbadec5b59bbd99d7e3
|
||||||
g/all/setup_app__app+change_account.deno.ts: 3c592cac27e9cdab0de6ae19270bcb08c7fa54355ad05253a12de2351894346b
|
g/all/setup_app__app+change_account.deno.ts: 3c592cac27e9cdab0de6ae19270bcb08c7fa54355ad05253a12de2351894346b
|
||||||
u/admin/hub_sync: aaf9fd803fa229f3029d1bb02bbe3cc422fce680cad39c4eec8dd1da115de102
|
u/admin/hub_sync: aaf9fd803fa229f3029d1bb02bbe3cc422fce680cad39c4eec8dd1da115de102
|
||||||
u/antigravity/git_sync__flow+__flow_hash: 4dd9119107cf74b0b98a4907f897f1b0158b76665353552d5e0aa9ba9a408d78
|
u/antigravity/git_sync__flow+__flow_hash: 66cdf1feb6136bb87f65a050266840e7b074a136f4b752bd01dbe524eb8f05d7
|
||||||
u/antigravity/git_sync__flow+a.sh: a683bcb9ec70d52a573999b0564172f0600eaf71c5dbf610ad0b3b611580cf62
|
u/antigravity/git_sync__flow+a.sh: 3094bf5aed54e3232c6e0260fa0b3f3849f7fc19930ec2a8395fcfe437cdbe8f
|
||||||
u/antigravity/hello_world_demo__flow+__flow_hash: 0adc341960f8196454876684f85fe14ef087ba470322d2aabc99b37bf61edac9
|
u/antigravity/hello_world_demo__flow+__flow_hash: 0adc341960f8196454876684f85fe14ef087ba470322d2aabc99b37bf61edac9
|
||||||
u/antigravity/hello_world_demo__flow+a.ts: 53669a285c16d4ba322888755a33424521f769e9ebf64fc1f0cb21f9952b5958
|
u/antigravity/hello_world_demo__flow+a.ts: 53669a285c16d4ba322888755a33424521f769e9ebf64fc1f0cb21f9952b5958
|
||||||
u/antigravity/test_git_sync: 3aa9e66ad8c87f1c2718d41d78ce3b773ce20743e4a1011396edbe2e7f88ac51
|
u/antigravity/test_git_sync: 3aa9e66ad8c87f1c2718d41d78ce3b773ce20743e4a1011396edbe2e7f88ac51
|
||||||
|
|||||||
Reference in New Issue
Block a user