8138b7f0615bf32b3df03bcdb9cc8027d0eb2737
[elisp/gnus.git-] / lisp / spam.el
1 ;;; spam.el --- Identifying spam
2 ;; Copyright (C) 2002 Free Software Foundation, Inc.
3
4 ;; Author: Lars Magne Ingebrigtsen <larsi@gnus.org>
5 ;; Keywords: network
6
7 ;; This file is part of GNU Emacs.
8
9 ;; GNU Emacs is free software; you can redistribute it and/or modify
10 ;; it under the terms of the GNU General Public License as published by
11 ;; the Free Software Foundation; either version 2, or (at your option)
12 ;; any later version.
13
14 ;; GNU Emacs is distributed in the hope that it will be useful,
15 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
16 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 ;; GNU General Public License for more details.
18
19 ;; You should have received a copy of the GNU General Public License
20 ;; along with GNU Emacs; see the file COPYING.  If not, write to the
21 ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
22 ;; Boston, MA 02111-1307, USA.
23
24 ;;; Commentary:
25
26 ;;; This module addresses a few aspects of spam control under Gnus.  Page
27 ;;; breaks are used for grouping declarations and documentation relating to
28 ;;; each particular aspect.
29
30 ;;; The integration with Gnus is not yet complete.  See various `FIXME'
31 ;;; comments, below, for supplementary explanations or discussions.
32
33 ;;; Code:
34
35 (require 'gnus-sum)
36
37 ;; FIXME!  We should not require `dns' nor `message' until we actually
38 ;; need them.  Best would be to declare needed functions as auto-loadable.
39 (require 'dns)
40 (require 'message)
41
42 (autoload 'bbdb-records "bbdb-com")
43
44 ;; Attempt to load BBDB macros
45 (eval-when-compile
46   (condition-case nil
47       (require 'bbdb-com)
48     (file-error (defalias 'bbdb-search 'ignore))
49     (error)))
50
51 ;; autoload executable-find
52 (autoload 'executable-find "executable")
53
54 ;;; Main parameters.
55
56 (defvar spam-use-blacklist t
57   "True if the blacklist should be used.")
58
59 (defvar spam-use-whitelist t
60   "True if the whitelist should be used.")
61
62 (defvar spam-use-blackholes nil
63   ;; FIXME!  Turned off for now.  The DNS routines are said to be flaky.
64   "True if blackholes should be used.")
65
66 (defvar spam-use-bogofilter t
67   "True if bogofilter should be used.")
68
69 (defvar spam-split-group "spam"
70   "Usual group name where spam should be split.")
71
72 (defvar spam-junk-mailgroups
73   ;; FIXME!  The mailgroup list evidently depends on other choices made by the
74   ;; user, so the built-in default below is not likely to be appropriate.
75   (cons spam-split-group '("mail.junk" "poste.pourriel"))
76   "Mailgroups which are dedicated by splitting to receive various junk.
77 All unmarked article in such group receive the spam mark on group entry.")
78
79 ;; FIXME!  For `spam-ham-marks' and `spam-spam-marks', I wonder if it would
80 ;; not be easier for the user to just accept a string of mark letters, instead
81 ;; of a list of Gnus variable names.  In such case, the stunt of deferred
82 ;; evaluation would not be useful anymore.  Lars?? :-)
83
84 ;; FIXME!  It is rather questionable to see `K', `X' and `Y' as indicating
85 ;; positive ham.  It much depends on how and why people use kill files, score
86 ;; files, and the kill command.  Maybe it would be better, by default, to not
87 ;; process a message neither as ham nor spam, that is, just ignore it for
88 ;; learning purposes, when we are not sure of how the user sees it.
89 ;; But `r' and `R' should undoubtedly be seen as ham.
90
91 ;; FIXME!  Some might consider overkill to define a list of spam marks.  On
92 ;; the other hand, who knows, some users might for example like that
93 ;; explicitly `E'xpired articles be processed as positive spam.
94
95 (defvar spam-ham-marks
96   (list gnus-del-mark gnus-read-mark gnus-killed-mark
97          gnus-kill-file-mark gnus-low-score-mark)
98   "Marks considered as being ham (positively not spam).
99 Such articles will be transmitted to `bogofilter -n' on group exit.")
100
101 (defvar spam-spam-marks
102   (list gnus-spam-mark)
103   "Marks considered as being spam (positively spam).
104 Such articles will be transmitted to `bogofilter -s' on group exit.")
105
106 ;; FIXME!  Ideally, the remainder of this page should be fully integrated
107 ;; within `gnus-sum.el'.
108
109 ;;; Key bindings for spam control.
110
111 ;; FIXME!  The justification for `M-d' is that this is what Paul Graham
112 ;; suggests in his original article, and what Eric Raymond's patch for Mutt
113 ;; uses.  But more importantly, that binding was still free in Summary mode!
114
115 ;; FIXME!  Lars has not blessed the following key bindings yet.  It looks
116 ;; convenient that the score analysis command uses a sequence ending with the
117 ;; letter `t', so it nicely parallels `B t' or `V t'.  `M-d' is a kind of
118 ;; "alternate" `d', it is also the sequence suggested in Paul Graham article,
119 ;; and also in Eric Raymond's patch for Mutt.  `S x' might be the more
120 ;; official key binding for `M-d'.
121
122 (gnus-define-keys gnus-summary-mode-map
123   "St" spam-bogofilter-score
124   "Sx" gnus-summary-mark-as-spam
125   "\M-d" gnus-summary-mark-as-spam)
126
127 ;;; How to highlight a spam summary line.
128
129 ;; FIXME!  Of course, `gnus-splash-face' has another purpose.  Maybe a
130 ;; special face should be created, named and used instead, for spam lines.
131
132 (push '((eq mark gnus-spam-mark) . gnus-splash-face)
133       gnus-summary-highlight)
134
135 ;;; Hooks dispatching.  A bit raw for now.
136
137 (defun spam-summary-prepare ()
138   (spam-mark-junk-as-spam-routine))
139
140 (defun spam-summary-prepare-exit ()
141   (spam-bogofilter-register-routine))
142
143 (add-hook 'gnus-summary-prepare-hook 'spam-summary-prepare)
144 (add-hook 'gnus-summary-prepare-exit-hook 'spam-summary-prepare-exit)
145
146 (defun spam-mark-junk-as-spam-routine ()
147   (when (member gnus-newsgroup-name spam-junk-mailgroups)
148     (let ((articles gnus-newsgroup-articles)
149           article)
150       (while articles
151         (setq article (pop articles))
152         (when (eq (gnus-summary-article-mark article) gnus-unread-mark)
153           (gnus-summary-mark-article article gnus-spam-mark))))))
154 \f
155 ;;;; Spam determination.
156
157 ;; The following list contains pairs associating a parameter variable with a
158 ;; spam checking function.  If the parameter variable is true, then the
159 ;; checking function is called, and its value decides what happens.  Each
160 ;; individual check may return `nil', `t', or a mailgroup name.  The value
161 ;; `nil' means that the check does not yield a decision, and so, that further
162 ;; checks are needed.  The value `t' means that the message is definitely not
163 ;; spam, and that further spam checks should be inhibited.  Otherwise, a
164 ;; mailgroup name is returned where the mail should go, and further checks are
165 ;; also inhibited.  The usual mailgroup name is the value of
166 ;; `spam-split-group', meaning that the message is definitely a spam.
167
168 (defvar spam-list-of-checks
169   '((spam-use-blacklist  . spam-check-blacklist)
170     (spam-use-whitelist  . spam-check-whitelist)
171     (spam-use-bbdb       . spam-check-bbdb)
172     (spam-use-blackholes . spam-check-blackholes)
173     (spam-use-bogofilter . spam-check-bogofilter)))
174
175 (defun spam-split ()
176   "Split this message into the `spam' group if it is spam.
177 This function can be used as an entry in `nnmail-split-fancy', for
178 example like this: (: spam-split)
179
180 See the Info node `(gnus)Fancy Mail Splitting' for more details."
181   (interactive)
182
183   (let ((list-of-checks spam-list-of-checks)
184         decision)
185     (while (and list-of-checks (not decision))
186       (let ((pair (pop list-of-checks)))
187         (when (eval (car pair))
188           (setq decision (apply (cdr pair))))))
189     (if (eq decision t)
190         nil
191       decision)))
192 \f
193 ;;;; Blackholes.
194
195 (defvar spam-blackhole-servers '("bl.spamcop.net"
196                                  "relays.ordb.org"
197                                  "dev.null.dk"
198                                  "relays.visi.com"
199                                  "rbl.maps.vix.com")
200   "List of blackhole servers.")
201
202 (defun spam-check-blackholes ()
203   "Check the Receieved headers for blackholed relays."
204   (let ((headers (message-fetch-field "received"))
205         ips matches)
206     (when headers
207       (with-temp-buffer
208         (insert headers)
209         (goto-char (point-min))
210         (while (re-search-forward
211                 "\\[\\([0-9]+.[0-9]+.[0-9]+.[0-9]+\\)\\]" nil t)
212           (message "Blackhole search found host IP %s." (match-string 1))
213           (push (mapconcat 'identity
214                            (nreverse (split-string (match-string 1) "\\."))
215                            ".")
216                 ips)))
217       (dolist (server spam-blackhole-servers)
218         (dolist (ip ips)
219           (when (query-dns (concat ip "." server))
220             (push (list ip server (query-dns (concat ip "." server) 'TXT))
221                   matches)))))
222     (when matches
223       spam-split-group)))
224 \f
225 ;;;; Blacklists and whitelists.
226
227 (defvar spam-directory "~/News/spam/"
228   "When spam files are kept.")
229
230 (defvar spam-whitelist (expand-file-name "whitelist" spam-directory)
231   "The location of the whitelist.
232 The file format is one regular expression per line.
233 The regular expression is matched against the address.")
234
235 (defvar spam-blacklist (expand-file-name "blacklist" spam-directory)
236   "The location of the blacklist.
237 The file format is one regular expression per line.
238 The regular expression is matched against the address.")
239
240 (defvar spam-whitelist-cache nil)
241 (defvar spam-blacklist-cache nil)
242
243 (defun spam-enter-whitelist (address)
244   "Enter ADDRESS into the whitelist."
245   (interactive "sAddress: ")
246   (spam-enter-list address spam-whitelist)
247   (setq spam-whitelist-cache nil))
248
249 (defun spam-enter-blacklist (address)
250   "Enter ADDRESS into the blacklist."
251   (interactive "sAddress: ")
252   (spam-enter-list address spam-blacklist)
253   (setq spam-blacklist-cache nil))
254
255 (defun spam-enter-list (address file)
256   "Enter ADDRESS into the given FILE, either the whitelist or the blacklist."
257   (unless (file-exists-p (file-name-directory file))
258     (make-directory (file-name-directory file) t))
259   (save-excursion
260     (set-buffer
261      (find-file-noselect file))
262     (goto-char (point-max))
263     (unless (bobp)
264       (insert "\n"))
265     (insert address "\n")
266     (save-buffer)))
267
268 ;;; returns nil if the sender is in the whitelist, spam-split-group otherwise
269 (defun spam-check-whitelist ()
270   ;; FIXME!  Should it detect when file timestamps change?
271   (unless spam-whitelist-cache
272     (setq spam-whitelist-cache (spam-parse-list spam-whitelist)))
273   (if (spam-from-listed-p spam-whitelist-cache) nil spam-split-group))
274
275 ;;; copied from code by Alexander Kotelnikov <sacha@giotto.sj.ru>
276 (defun spam-check-bbdb ()
277   "We want messages from people who are in the BBDB not to be split to spam"
278   (let ((who (message-fetch-field "from")))
279     (when who
280       (setq who (regexp-quote (cadr (gnus-extract-address-components who))))
281       (if (bbdb-search (bbdb-records) nil nil who) nil spam-split-group))))
282
283 ;; let spam-check-bbdb be nil if the BBDB can't be loaded
284 (condition-case nil
285     (require 'bbdb)
286   (file-error (defalias 'spam-check-bbdb 'ignore)))
287
288 (defun spam-check-blacklist ()
289   ;; FIXME!  Should it detect when file timestamps change?
290   (unless spam-blacklist-cache
291     (setq spam-blacklist-cache (spam-parse-list spam-blacklist)))
292   (and (spam-from-listed-p spam-blacklist-cache) spam-split-group))
293
294 (eval-and-compile
295   (defalias 'spam-point-at-eol (if (fboundp 'point-at-eol)
296                                    'point-at-eol
297                                  'line-end-position)))
298
299 (defun spam-parse-list (file)
300   (when (file-readable-p file)
301     (let (contents address)
302       (with-temp-buffer
303         (insert-file-contents file)
304         (while (not (eobp))
305           (setq address (buffer-substring (point) (spam-point-at-eol)))
306           (forward-line 1)
307           (unless (zerop (length address))
308             (setq address (regexp-quote address))
309             (while (string-match "\\\\\\*" address)
310               (setq address (replace-match ".*" t t address)))
311             (push address contents))))
312       (nreverse contents))))
313
314 (defun spam-from-listed-p (cache)
315   (let ((from (message-fetch-field "from"))
316         found)
317     (while cache
318       (when (string-match (pop cache) from)
319         (setq found t
320               cache nil)))
321     found))
322 \f
323 ;;;; Training via Bogofilter.   Last updated 2002-09-02.
324
325 ;;; See Paul Graham article, at `http://www.paulgraham.com/spam.html'.
326
327 ;;; This page is for those wanting to control spam with the help of Eric
328 ;;; Raymond's speedy Bogofilter, see http://www.tuxedo.org/~esr/bogofilter.
329 ;;; This has been tested with a locally patched copy of version 0.4.
330
331 ;;; Make sure Bogofilter is installed.  Bogofilter internally uses Judy fast
332 ;;; associative arrays, so you need to install Judy first, and Bogofilter
333 ;;; next.  Fetch both distributions by visiting the following links and
334 ;;; downloading the latest version of each:
335 ;;;
336 ;;;     http://sourceforge.net/projects/judy/
337 ;;;     http://www.tuxedo.org/~esr/bogofilter/
338 ;;;
339 ;;; Unpack the Judy distribution and enter its main directory.  Then do:
340 ;;;
341 ;;;     ./configure
342 ;;;     make
343 ;;;     make install
344 ;;;
345 ;;; You will likely need to become super-user for the last step.  Then, unpack
346 ;;; the Bogofilter distribution and enter its main directory:
347 ;;;
348 ;;;     make
349 ;;;     make install
350 ;;;
351 ;;; Here as well, you need to become super-user for the last step.  Now,
352 ;;; initialises your word lists by doing, under your own identity:
353 ;;;
354 ;;;     mkdir ~/.bogofilter
355 ;;;     touch ~/.bogofilter/badlist
356 ;;;     touch ~/.bogofilter/goodlist
357 ;;;
358 ;;; These two files are text files you may edit, but you normally don't!
359
360 ;;; The `M-d' command gets added to Gnus summary mode, marking current article
361 ;;; as spam, showing it with the `H' mark.  Whenever you see a spam article,
362 ;;; make sure to mark its summary line with `M-d' before leaving the group.
363 ;;; Some groups, as per variable `spam-junk-mailgroups' below, receive articles
364 ;;; from Gnus splitting on clues added by spam recognisers, so for these
365 ;;; groups, we tack an `H' mark at group entry for all summary lines which
366 ;;; would otherwise have no other mark.  Make sure to _remove_ `H' marks for
367 ;;; any article which is _not_ genuine spam, before leaving such groups: you
368 ;;; may use `M-u' to "unread" the article, or `d' for declaring it read the
369 ;;; non-spam way.  When you leave a group, all `H' marked articles, saved or
370 ;;; unsaved, are sent to Bogofilter which will study them as spam samples.
371
372 ;;; Messages may also be deleted in various other ways, and unless
373 ;;; `spam-ham-marks-form' gets overridden below, marks `R' and `r' for default
374 ;;; read or explicit delete, marks `X' and 'K' for automatic or explicit
375 ;;; kills, as well as mark `Y' for low scores, are all considered to be
376 ;;; associated with articles which are not spam.  This assumption might be
377 ;;; false, in particular if you use kill files or score files as means for
378 ;;; detecting genuine spam, you should then adjust `spam-ham-marks-form'.  When
379 ;;; you leave a group, all _unsaved_ articles bearing any the above marks are
380 ;;; sent to Bogofilter which will study these as not-spam samples.  If you
381 ;;; explicit kill a lot, you might sometimes end up with articles marked `K'
382 ;;; which you never saw, and which might accidentally contain spam.  Best is
383 ;;; to make sure that real spam is marked with `H', and nothing else.
384
385 ;;; All other marks do not contribute to Bogofilter pre-conditioning.  In
386 ;;; particular, ticked, dormant or souped articles are likely to contribute
387 ;;; later, when they will get deleted for real, so there is no need to use
388 ;;; them prematurely.  Explicitly expired articles do not contribute, command
389 ;;; `E' is a way to get rid of an article without Bogofilter ever seeing it.
390
391 ;;; In a word, with a minimum of care for associating the `H' mark for spam
392 ;;; articles only, Bogofilter training all gets fairly automatic.  You should
393 ;;; do this until you get a few hundreds of articles in each category, spam
394 ;;; or not.  The shell command `head -1 ~/.bogofilter/*' shows both article
395 ;;; counts.  The command `S S' in summary mode, either for debugging or for
396 ;;; curiosity, triggers Bogofilter into displaying in another buffer the
397 ;;; "spamicity" score of the current article (between 0.0 and 1.0), together
398 ;;; with the article words which most significantly contribute to the score.
399
400 ;;; The real way for using Bogofilter, however, is to have some use tool like
401 ;;; `procmail' for invoking it on message reception, then adding some
402 ;;; recognisable header in case of detected spam.  Gnus splitting rules might
403 ;;; later trip on these added headers and react by sorting such articles into
404 ;;; specific junk folders as per `spam-junk-mailgroups'.  Here is a possible
405 ;;; `.procmailrc' contents (still untested -- please tell me how it goes):
406 ;;;
407 ;;; :0HBf:
408 ;;; * ? bogofilter
409 ;;; | formail -bfI "X-Spam-Status: Yes"
410
411 (defvar spam-output-buffer-name "*Bogofilter Output*"
412   "Name of buffer when displaying `bogofilter -v' output.")
413
414 (defvar spam-spaminfo-header-regexp
415   ;; FIXME!  In the following regexp, we should explain which tool produces
416   ;; which kind of header.  I do not even remember them all by now.  X-Junk
417   ;; (and previously X-NoSpam) are produced by the `NoSpam' tool, which has
418   ;; never been published, so it might not be reasonable leaving it in the
419   ;; list.
420   "^X-\\(jf\\|Junk\\|NoSpam\\|Spam\\|SB\\)[^:]*:"
421   "Regexp for spam markups in headers.
422 Markup from spam recognisers, as well as `Xref', are to be removed from
423 articles before they get registered by Bogofilter.")
424
425 (defvar spam-bogofilter-path (executable-find "bogofilter")
426   "File path of the Bogofilter executable program.
427 Force this variable to nil if you want to inhibit the functionality.")
428
429 (defun spam-check-bogofilter ()
430   ;; Dynamic spam check.  I do not know how to check the exit status,
431   ;; so instead, read `bogofilter -v' output.
432   (when (and spam-use-bogofilter spam-bogofilter-path)
433     (spam-bogofilter-articles nil "-v" (list (gnus-summary-article-number)))
434     (when (save-excursion
435             (set-buffer spam-output-buffer-name)
436             (goto-char (point-min))
437             (re-search-forward "Spamicity: \\(0\\.9\\|1\\.0\\)" nil t))
438       spam-split-group)))
439
440 (defun spam-bogofilter-score ()
441   "Use `bogofilter -v' on the current article.
442 This yields the 15 most discriminant words for this article and the
443 spamicity coefficient of each, and the overall article spamicity."
444   (interactive)
445   (when (and spam-use-bogofilter spam-bogofilter-path)
446     (spam-bogofilter-articles nil "-v" (list (gnus-summary-article-number)))
447     (save-excursion
448       (set-buffer spam-output-buffer-name)
449       (unless (= (point-min) (point-max))
450         (display-message-or-buffer (current-buffer)
451                                    spam-output-buffer-name)))))
452
453 (defun spam-bogofilter-register-routine ()
454   (when (and spam-use-bogofilter spam-bogofilter-path)
455     (let ((articles gnus-newsgroup-articles)
456           article mark ham-articles spam-articles)
457       (while articles
458         (setq article (pop articles)
459               mark (gnus-summary-article-mark article))
460         (cond ((memq mark spam-spam-marks) (push article spam-articles))
461               ((memq article gnus-newsgroup-saved))
462               ((memq mark spam-ham-marks) (push article ham-articles))))
463       (when ham-articles
464         (spam-bogofilter-articles "ham" "-n" ham-articles))
465       (when spam-articles
466         (spam-bogofilter-articles "SPAM" "-s" spam-articles)))))
467
468 (defvar spam-bogofilter-initial-timeout 40
469   "Timeout in seconds for the initial reply from the `bogofilter' program.")
470
471 (defvar spam-bogofilter-subsequent-timeout 15
472   "Timeout in seconds for any subsequent reply from the `bogofilter' program.")
473
474 (defun spam-bogofilter-articles (type option articles)
475   (let ((output-buffer (get-buffer-create spam-output-buffer-name))
476         (article-copy (get-buffer-create " *Bogofilter Article Copy*"))
477         (remove-regexp (concat spam-spaminfo-header-regexp "\\|Xref:"))
478         (counter 0)
479         prefix process article)
480     (when type
481       (setq prefix (format "Studying %d articles as %s..." (length articles)
482                            type))
483       (message "%s" prefix))
484     (save-excursion (set-buffer output-buffer) (erase-buffer))
485     (setq process (start-process "bogofilter" output-buffer
486                                  spam-bogofilter-path "-F" option))
487     (process-kill-without-query process t)
488     (unwind-protect
489         (save-window-excursion
490           (while articles
491             (setq counter (1+ counter))
492             (when prefix
493               (message "%s %d" prefix counter))
494             (setq article (pop articles))
495             (gnus-summary-goto-subject article)
496             (gnus-summary-select-article)
497             (gnus-eval-in-buffer-window article-copy
498               (insert-buffer-substring gnus-original-article-buffer)
499               ;; Remove spam classification redundant headers: they may induce
500               ;; unwanted biases in later analysis.
501               (goto-char (point-min))
502               (while (not (or (eobp) (= (following-char) ?\n)))
503                 (if (looking-at remove-regexp)
504                     (delete-region (point)
505                                    (save-excursion (forward-line 1) (point)))
506                   (forward-line 1)))
507               (goto-char (point-min))
508               ;; Bogofilter really wants From envelopes for counting articles.
509               ;; Fake one at the beginning, make sure there will be no other.
510               (if (looking-at "From ")
511                   (forward-line 1)
512                 (insert "From nobody " (current-time-string) "\n"))
513               (let (case-fold-search)
514                 (while (re-search-forward "^From " nil t)
515                   (beginning-of-line)
516                   (insert ">")))
517               (process-send-region process (point-min) (point-max))
518               (erase-buffer))))
519       ;; Sending the EOF is unwind-protected.  This is to prevent lost copies
520       ;; of `bogofilter', hung on reading their standard input, in case the
521       ;; whole registering process gets interrupted by the user.
522       (process-send-eof process))
523     (kill-buffer article-copy)
524     ;; Receive process output.  It sadly seems that we still have to protect
525     ;; ourselves against hung `bogofilter' processes.
526     (let ((status (process-status process))
527           (timeout (* 1000 spam-bogofilter-initial-timeout))
528           (quanta 200))                 ; also counted in milliseconds
529       (while (and (not (eq status 'exit)) (> timeout 0))
530         ;; `accept-process-output' timeout is counted in microseconds.
531         (setq timeout (if (accept-process-output process 0 (* 1000 quanta))
532                           (* 1000 spam-bogofilter-subsequent-timeout)
533                         (- timeout quanta))
534               status (process-status process)))
535       (if (eq status 'exit)
536           (when prefix
537             (message "%s done!" prefix))
538         ;; Sigh!  The process did time out...  Become brutal!
539         (interrupt-process process)
540         (message "%s %d INTERRUPTED!  (Article %d, status %s)"
541                  (or prefix "Bogofilter process...")
542                  counter article status)
543         ;; Give some time for user to read.  Sitting redisplays but gives up
544         ;; if input is pending.  Sleeping does not give up, but it does not
545         ;; redisplay either.  Mix both: let's redisplay and not give up.
546         (sit-for 1)
547         (sleep-for 3)))))
548
549 (provide 'spam)
550
551 ;;; spam.el ends here.