AI にメールを自動分類させてみた(fetchmail + llama.cpp + procmail)

ozy's labo.


AI にメールを自動分類させてみた(fetchmail + llama.cpp + procmail)

概要

ローカルLLMを使ってメールを自動分類する仕組みを作りました.
構成は非常にシンプルで,昔ながらのUnixメールパイプラインに
LLMフィルタを1段挟むだけです.

最終構成は以下です.

fetchmail(cron実行)
   ↓
トリガースクリプト
   ↓
LLM分類フィルタ
   ↓
procmail
   ↓
Maildir
   ↓
dovecot
   ↓
thnderbird

元々この環境は Gentoo 時代に使用していた Courier + procmail + postfix 環境から引き継いでおり,
現在は fetchmail → procmail → Maildir → dovecot という構成になっています.

そこに LLM を追加して,メールの自動分類を行います.

後で説明しますが,Postfix 環境では /user/bin/procmail が setuid されている事があり,この場合は少し注意が必要です.

やろうと思った経緯

迷惑メールフィルタは既存のものでも十分動きますが,
最近はメールの種類自体が増えてきました.

例えば

  • 友人
  • ML
  • サービス通知
  • ECサイト
  • spam

などです.

従来の procmail ルールだけでも分類できますが,
条件が増えると .procmailrc がどんどん複雑で,管理自体が面倒になります(数年放置…).

そこで

「LLMに最初の分類だけやらせて,procmailで振り分ければよい」

と考えました.

設計

メールの Subject と本文の一部だけを LLM に渡し,
分類結果を1単語で返させます.

例えば

friends
ml
spam
misc

のような形です.

その結果を procmail に渡して振り分けます.

fetchmail 設定

.fetchmailrc からトリガースクリプトを呼びます.
例えば,

poll pop.gmail.com
  protocol pop3
  port 995
  user 'yourname@gmail.com'
  password 'XXXXXXX'
  ssl
  mda '/home/yourname/bin/procLLmail.sh'

fetchmail は取得したメールをそのままこのトリガースクリプトの stdin に渡します.

トリガースクリプト

fetchmail から受け取ったメールを一旦テンポラリに保存し,
LLM フィルタを呼び出します.

#!/bin/bash

PROCMAIL=/usr/bin/procmail
PROCMAILRC="/home/yourname/.procmailrc"
LLMFILTER="/home/yourname/bin/llmfilter-maildir.sh"

TMP=$(mktemp)

# メール保存(stdin → tmp)
cat > "$TMP"

# LLMへ渡す入力作成(From, Subject + 本文先頭100行)
LLM_INPUT=$TMP
LLM_INPUT=$(
{
    formail -X From: -X Subject: < "$TMP"
    echo
    sed -n '1,100p' "$TMP"
}
)

CATEGORY=""

# LLM呼び出し(失敗しても処理継続)
CATEGORY=$(
    printf "%s\n" "$LLM_INPUT" | "$LLMFILTER" 2>/dev/null
)

# 改行削除
CATEGORY=$(printf "%s" "$CATEGORY" | tr -d '\r\n')

# 無反応なら fallback
if [ -z "$CATEGORY" ]; then
    CATEGORY="misc"
fi

# procmailへ渡して配送させる

$PROCMAIL CATEGORY="$CATEGORY" "$PROCMAILRC" < "$TMP"


rm -f "$TMP"

ここで重要なのは

procmail CATEGORY=value

という **引数形式で変数を渡す**ことです.

LLMフィルタ

メールヘッダと本文の一部を取り出して
LLM に渡します.
このフィルタ自体は標準入出力で動作するので,プロンプトを変更するだけで何にでもなります.
要約フィルタ,ファイル形式変換機,翻訳機,デバッガーなどとして使えます.

#!/usr/bin/env bash

## LLM をフィルタとして使う.

source '/home/yourname/bin/llmfilter-core.sh'

SYSTEM_PROMPT=$(cat <<EOF
メールを分類して下さい.入力された文章を解析し,当てはまるものを次のどれか一語でのみ答えて下さい:
$(grep '^* CATEGORY' /home/yourname/.procmailrc | sed -E 's/^\* CATEGORY \?\? \^(.*)\$$/\1/')

EOF
)

USER_PROMPT=$(cat <<EOF

EOF
)

llmfilter-core

コア部分.

## LLM をフィルタとして使う.

# 設定

DEFAULT_SERVER='127.0.0.1:8080'
DEFAULT_MODEL='default'

#TEMPERATURE=0.3
TEMPERATURE=0

# 共通処理

llmfilter-core(){
    SERVER="${1:-$DEFAULT_SERVER}"
    MODEL="${2:-$DEFAULT_MODEL}"

    #if [ -z "$USER_PROMPT" ]; then
    #  echo "No input."
    #  exit 1
    #fi

    set -euo pipefail

    # 一時ファイル作成
    TMP=$(mktemp)
    cat > "$TMP"

    # LLM問い合わせ
    jq -nc --rawfile user "$TMP" \
       --arg model "$MODEL" \
       --arg system "$SYSTEM_PROMPT" \
       --argjson temp "$TEMPERATURE" '
       {
	 model: $model,
	   messages: [
	       {role:"system",content:$system},
	       {role:"user",content:$user}
	       ],
	       temperature: $temp
       }
       '| curl -s "http://$SERVER/v1/chat/completions" \
	       -H "Content-Type: application/json" \
	       -d @- \
	| jq -r '
	  if .choices then
	      .choices[0].message.content // .choices[0].text
	  elif .error then
	       .error.message
	  else
		.
	  end
      '

    rm -f "$TMP"

}

返り値は .procmailrc のカテゴリ名を切り出したリストの中から返します.

friends
ml
spam
misc

など,いずれかを返します.

procmail 設定

.procmailrc では渡された CATEGORY を使って振り分けます.

MAILDIR=$HOME/.maildir
DEFAULT=$MAILDIR
LOGFILE=/home/yourname/procmail.log
LOG="CATEGORY=$CATEGORY MAILDIR=$MAILDIR DEFAULT=$DEFAULT\n"

## LLMフィルタによる分類.
# カテゴリー初期値.全ルールのあと,.procmailrc 最後でマッチ.
:0
\* ! CATEGORY ?? .
{
    CATEGORY=misc
}

# ---
# このへんに普通の procmail ルールを記述可能
# ---

### LLMによる判定カテゴリー.フィルターの方で指定.

# spam
:0
\* CATEGORY ?? ^spam$
._spam/

# friends
:0
\* CATEGORY ?? ^friends$
.friends/

# mailservice
:0
\* CATEGORY ?? ^mailservice$
.mailservice/

# !!!!! fallback (misc) !!!!! 重要なのはここ
# .procmailrc 一番最後に記述.最後じゃないと他のルールが適用されない.
# 全くの指定なしだと,ファイルとして保存される(Maildir形式にならない)
:0
./

注意点(setuid)

今回かなりハマったポイントです.

私の様な古い環境(特にPostfixを使用していた場合)では

/usr/bin/procmail


setuid root

になっていることがあります.

この場合 procmail はセキュリティのため
外部環境変数を破棄します.

つまり

env CATEGORY=spam procmail

のように呼んでも .procmailrc では

CATEGORY=

になります.

長時間これでハマりました.

さらにハマったポイント

setuid を解除しても

env CATEGORY=spam procmail

では値が渡りませんでした.

原因は procmail の変数処理です.

環境変数ではなく

procmail CATEGORY=spam

という 引数形式で渡す必要があります.

これを使うと .procmailrc から

$CATEGORY

で参照できます.正直もっと早く知りたかった仕様です.

まとめ

今回の構成は

fetchmail
 ↓
LLM分類
 ↓
procmail
 ↓
Maildir
 ↓
dovecot

というシンプルなパイプラインです.

Unix 的な

「小さいツールをパイプで繋ぐ」

という設計そのままで,
LLM をメールフィルタとして組み込むことが出来ました.

昔の spamassassin + procmail 構成を LLM に置き換えたような感じです.

このフィルタにより,非常にざっくりとした指定で procmail を運用出来る様になります.
ルールを追加するには,IMAPクライアントからディレクトリを作ったあと,対応する一単語を .procmailrc に付け加えるだけです.
単語は色々考えられます.例えば,

yahoo
rakuten
alibaba
mailservice
music_plugin
kuroneko
auction
jimoty
twitter

メール内容を見て,これらに一致する内容だとLLMがそう判断してくれます.
配送先は深いディレクトリ構造になっていてもよく,

  • .mailservice/.yahoo/./auction/

みたいな感じにも出来ます.
(最後は / で終わっていないと,Maildir 形式と見なされないので注意)

今後,使っていきながら,より便利なルールを追加していこうと思います.


Date: 2026-03-12

Author: ozyukiwo

Created: 2026-03-12 木 22:03

Emacs 26.3 (Org mode 9.1.9)

Validate