はじめに
少し古いマシンとGPUが余っているのでLLMを使って何かできないかなと考えていたらメールフィルタができそうだなと思ったので作ってみることにしました。
仕様
なんとなく以下の要件を想定
- メールサーバーからメールをIMAPで取得
- LLMにスパムか否か、返信が必要か否か、内容の種類、判定の理由を教えてもらう
自動返信とかスパムの削除とかも考えたけど、とりあえず今のところはこんな感じで。
動作環境
- CPU : Intel(R) Core(TM) i5-7400 CPU @ 3.00GHz
- RAM : 32GB
- GPU : NVIDIA GeForce RTX 3050 (6GB)
- OS : Ubuntu 22.04.5 LTS
- 開発言語 : Ruby 3.0.2p107
LLM設定
Ollamaインストール
以下のコマンドを実行
$ curl -fsSL https://ollama.com/install.sh | sh
以下のコマンドを実行してモデルをダウンロードしてテスト
ファイルサイズと回答をいくつか試して良さそうなモデルを選定。
$ ollama run llama31-swallow-8b:latest
スクリプト
環境設定ファイル
config.yaml
imap_host: [imap.xxxx.com]
imap_user: [your_mail_account]
imap_pass: [your_mail_password]
model: llama31-swallow-8b:latest
実行スクリプト
check_emails.rb
#!/bin/env ruby
require 'net/imap'
require 'mail'
require 'json'
require 'shellwords'
require 'yaml'
require 'sqlite3'
# 設定読み込み
config = YAML.load_file('config.yaml')
# Ollama問合せ
def classify_email_with_ollama(email, subject, body, model)
prompt = <<~PROMPT
# 依頼内容
以下のメールがスパムかどうか、また返信が必要かどうかを判定してください。
# 参考判定条件
以下の判定条件を追加で考慮して下さい。
- 送信元が「.cn」で終わるアドレスは無条件でスパムです
- 送信元と本文の内容が乖離しているものはスパムです
- Apple社以外のドメインから「Appleアカウント」についてのメールはスパムです
- visacardの利用についての問い合わせはスパムです
- ヤマト運輸ドメイン(kuronekoyamato.co.jp)以外からヤマト運輸の再配達通知がきているのはスパムです
# 送信元
#{email}
# 件名
#{subject}
# 本文
#{body}
# 結果出力
- 出力形式は以下のJSON形式のみとして下さい。
- 判断できなかった場合は「簡単な理由」を「判定不能」として下さい。
- 「メールの種類」に案内・通知・報告・依頼などわかりやすい言葉で分類して下さい。
- また判定結果について「簡単な理由」を必ず添えて下さい。
# 出力形式
{
"spam": true/false,
"needs_reply": true/false,
"type": "メールの種類",
"reason": "簡単な理由"
}
PROMPT
command = "echo #{prompt.shellescape} | ollama run #{model}"
response = `#{command}`
match = response.match(/\{.*\}/m)
unless match
puts ""
puts "[ERROR] JSONが見つかりませんでした。[Ollama出力]"
puts response
return { "spam" => false, "needs_reply" => false, "type"=>"エラー", "reason" => "LLM出力が無効" }
end
json_str = match[0]
begin
JSON.parse(json_str)
rescue
puts "[ERROR] JSON形式が正しくありません。[Ollama出力]"
return { "spam" => false, "needs_reply" => false, "type"=>"エラー", "reason" => "LLM出力が無効" }
end
end
puts "MODEL : #{config['model']}"
puts ""
# メールサーバー接続
imap = Net::IMAP.new(config['imap_host'], ssl: true)
imap.login(config['imap_user'], config['imap_pass'])
imap.select('INBOX')
uids = imap.search(["ALL"]).last(3)
uids.each do |uid|
raw_source = imap.fetch(uid, "RFC822")[0].attr["RFC822"]
mail = Mail.read_from_string(raw_source)
email = mail.from
subject = mail.subject || ""
body_raw = if mail.multipart?
mail.parts.map { |p| p.body.decoded if p.mime_type == 'text/plain' }.compact.join("\n")
else
mail.body.decoded
end
# 文字化け防止処理
body = body_raw.dup.force_encoding("UTF-8").encode("UTF-8", invalid: :replace, undef: :replace)
puts "UID : #{uid}"
puts "送信元 : #{email}"
puts "件 名 : #{subject}"
flg = true
# 送信元アドレスから国を確認
match = email.to_s.match(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.cn\b/)
if match then
result = { "spam" => true, "needs_reply" => false, "type"=>"スパム", "reason" => "国籍不明のドメイン" }
flg = false
end
# LLM問合せ
if flg then
result = classify_email_with_ollama(email, subject, body, config['model'])
end
puts ""
puts "***** 結果 *****"
puts "スパム:#{result["spam"]}"
puts "要返信:#{result["needs_reply"]}"
puts "種 類:#{result["type"]}"
puts "理 由:#{result["reason"]}"
puts "***** 終了 *****"
puts ""
end
imap.logout
imap.disconnect
実行
以下のコマンドで実行
$ ./check_emails.rb
実行結果...
MODEL : llama31-swallow-8b:latest
UID : 294
送信元 : ["Aeon.*****@*****.net"]
件 名 : イオンカード:ログイン状況の安全確認のお知らせ
***** 結果 *****
スパム:true
要返信:false
種 類:案内
理 由:送信元が.cnで終わるアドレスであり、本文の内容と乖離しているためスパムと判断しました。
***** 終了 *****
UID : 295
送信元 : ["aaaaa@*****.jp"]
件 名 : 【必須】Amazonアカウントのセキュリティ更新
***** 結果 *****
スパム:true
要返信:false
種 類:案内
理 由:送信元が.cnドメインで、Amazonアカウントのセキュリティ更新を促す内容はスパムの可能性が高いです。
***** 終了 *****
UID : 296
送信元 : ["*****@*****.co.jp"]
件 名 : 《本日開催!》【2025年セキュリティリスクを予測!】新たなサイバー攻撃に対する対策とは【デジタルアーツセミナー】
***** 結果 *****
スパム:false
要返信:false
種 類:案内・通知
理 由:イベントの告知や登録情報に関する内容であるため。
***** 終了 *****
なんか間違っているような気のする回答もあるけど、判定はきちんとできてるみたい!
とりあえずできた!!
さて、次は何をしようかな...