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