ローカルLLMを使ってRubyでメール判定スクリプトを作ってみた話

# はじめに

少し古いマシンと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インストール

https://ollama.com/download/linux

以下のコマンドを実行

```sh:
$ curl -fsSL https://ollama.com/install.sh | sh
```

以下のコマンドを実行してモデルをダウンロードしてテスト
ファイルサイズと回答をいくつか試して良さそうなモデルを選定。

```sh:
$ ollama run hf.co/mmnga/tokyotech-llm-Llama-3.1-Swallow-8B-Instruct-v0.3-gguf
```

# スクリプト

## 環境設定ファイル

```ruby:config.yaml
imap_host: [imap.xxxx.com]
imap_user: [your_mail_account]
imap_pass: [your_mail_password]
model: "hf.co/mmnga/tokyotech-llm-Llama-3.1-Swallow-8B-Instruct-v0.3-gguf"
```

# 実行スクリプト

```ruby: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
```

# 実行

以下のコマンドで実行

```sh:
$ ./check_emails.rb 
```

実行結果...

```:
MODEL : hf.co/mmnga/tokyotech-llm-Llama-3.1-Swallow-8B-Instruct-v0.3-gguf

UID   : 294
送信元 : ["Aeon.*****@*****.net"]
件　名 : イオンカード：ログイン状況の安全確認のお知らせ

***** 結果 *****
スパム：true
要返信：false
種　類：案内
理　由：送信元が.cnで終わるアドレスであり、本文の内容と乖離しているためスパムと判断しました。
***** 終了 *****

UID   : 295
送信元 : ["aaaaa@*****.jp"]
件　名 : 【必須】Amazonアカウントのセキュリティ更新

***** 結果 *****
スパム：true
要返信：false
種　類：案内
理　由：送信元が.cnドメインで、Amazonアカウントのセキュリティ更新を促す内容はスパムの可能性が高いです。
***** 終了 *****

UID   : 296
送信元 : ["*****@*****.co.jp"]
件　名 : 《本日開催！》【2025年セキュリティリスクを予測！】新たなサイバー攻撃に対する対策とは【デジタルアーツセミナー】

***** 結果 *****
スパム：false
要返信：false
種　類：案内・通知
理　由：イベントの告知や登録情報に関する内容であるため。
***** 終了 *****
```

なんか間違っているような気のする回答もあるけど、判定はきちんとできてるみたい！

とりあえずできた！！

さて、次は何をしようかな...