Auto-sync: 2026-02-25 07:14:42

This commit is contained in:
Windmill Bot
2026-02-25 07:14:42 +00:00
parent f49ee2ab95
commit 7b31410ef4
10 changed files with 610 additions and 316 deletions

View File

@@ -0,0 +1 @@
admins

View File

@@ -0,0 +1 @@
{"remote":"http://windmill_server:8000/","workspaceId":"admins","name":"admins","token":"CQKYm1bUwszHCT4Ww6TGyQX97XMs8qg8"}

View File

@@ -0,0 +1 @@
admins

View File

@@ -0,0 +1 @@
{"remote":"http://windmill_server:8000/","workspaceId":"admins","name":"admins","token":"CQKYm1bUwszHCT4Ww6TGyQX97XMs8qg8"}

View File

@@ -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:
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: メール取得・判定・通知 summary: メール取得・判定・通知
value:
type: rawscript
content: '!inline メール取得・判定・通知.py'
input_transforms: {}
lock: '!inline メール取得・判定・通知.lock'
language: python3
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: {}

View 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

View 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 ""

View 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

View File

@@ -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=""
GREEN='\033[0;32m' YELLOW=""
YELLOW='\033[1;33m' RED=""
RED='\033[0;31m' NC=""
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 Diskwmill.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 RemoteGitリポジトリルートに戻って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}"

View File

@@ -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