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