Working on multiple recipient (CC) support.
[lambdamail.git] / lambdamail.scm
1 ;; Super-basic bell-and-whistle-free SMTP server.
2 ;;
3 ;; Intended for a single-user system 
4
5 (import tcp6
6         (chicken port)
7         (chicken io)
8         (chicken string)
9         (chicken pathname)
10         (chicken file)
11         (chicken time)
12         (chicken time posix)
13         (chicken process)
14         (chicken process-context)
15         (chicken process-context posix)
16         (chicken condition)
17         (chicken sort)
18         srfi-1 srfi-13 matchable base64)
19
20 (define lambdamail-version "LambdaMail v1.5.0")
21
22 (define-record config host port spool-dir user group)
23 (define-record message to from text user password)
24 (define (make-empty-message) (make-message '() "" "" "" ""))
25
26 (define (time-stamp)
27   (time->string (seconds->local-time) "%d %b %Y %T %z"))
28
29
30 ;;; Server initialization
31 ;;
32
33 (define (drop-privs config)
34   (let ((uid (config-user config))
35         (gid (config-group config)))
36     (if (not (null? gid)) ; Group first, since only root can switch groups.
37         (set! (current-group-id) gid))
38     (if (not (null? uid))
39         (set! (current-user-id) uid))))
40
41 (define (run-server config)
42   (set-buffering-mode! (current-output-port) #:line)
43   (let ((listener (tcp-listen (config-port config) 10 "::")))
44     (print lambdamail-version
45            " listening on port " (config-port config) " ...")
46     (print "(Host name: " (config-host config)
47            ", Spool dir: " (config-spool-dir config) ")")
48     (drop-privs config)
49     (server-loop listener config '())))
50
51
52 ;;; Main server loop
53 ;;
54
55 (define (server-loop listener config undelivered-messages)
56   (let* ((messages (append (receive-messages listener config) undelivered-messages)))
57     (server-loop listener config (deliver-messages config messages))))
58
59
60 ;;; Receiving messages
61 ;;
62
63 (define (receive-messages listener config)
64   (let ((messages '()))
65     (print "*** Waiting for incoming mail")
66     (let-values (((in-port out-port) (tcp-accept listener)))
67       (let-values (((local-ip remote-ip) (tcp-addresses in-port)))
68         (print "Accepted connection from " remote-ip " on " (time-stamp)))
69       (condition-case
70           (set! messages (process-smtp (make-smtp-session in-port out-port config) config))
71         (o (exn)
72            (print-error-message o)))
73       (print "Terminating connection.")
74       (close-input-port in-port)
75       (close-output-port out-port))
76     messages))
77
78 (define (make-smtp-session in-port out-port config)
79   (let ((helo ""))
80     (lambda command
81       (match command
82         (('get-line) (read-line in-port))
83         (('send strings ...) (write-line (conc (apply conc strings) "\r") out-port))
84         (('set-helo! h) (set! helo h))
85         (('helo) helo)))))
86
87 (define (smtp-command? cmd-string input-string)
88   (string-prefix? cmd-string (string-downcase input-string)))
89
90 (define (smtp-command-args cmd-string input-string)
91   (if (> (string-length input-string) (string-length cmd-string))
92       (string-trim (string-drop input-string (string-length cmd-string)))
93       ""))
94
95 (define (process-smtp smtp-session config)
96   (smtp-session 'send "220 " (config-host config) " " lambdamail-version)
97   (let loop ((msg (make-empty-message))
98              (received-messages '()))
99     (let ((line (smtp-session 'get-line)))
100       (print "got " line)
101       (if (not (string? line))
102           '() ; Don't keep anything on unexpected termination.
103           (cond
104            ((smtp-command? "helo" line)
105             (smtp-session 'set-helo! (smtp-command-args "helo" line))
106             (smtp-session 'send "250 ok")
107             (loop msg received-messages))
108            ((smtp-command? "ehlo" line)
109             (smtp-session 'set-helo! (smtp-command-args "helo" line))
110             (smtp-session 'send
111                           "250-" (config-host config)
112                           " Hello " (smtp-command-args "ehlo" line))
113             (smtp-session 'send "250 AUTH PLAIN")
114             ;; (smtp-session 'send "250 STARTTLS")
115             (loop msg received-messages))
116            ((smtp-command? "auth plain" line)
117             (let* ((auth-string (smtp-command-args "auth plain" line))
118                    (auth-decoded (base64-decode auth-string))
119                    (auth-list (string-split auth-decoded "\x00"))
120                    (user (car auth-list))
121                    (password (cadr auth-list)))
122               (message-user-set! msg user)
123               (message-password-set! msg password)
124               (print "Attempted login, user: " user ", password: " password)
125               (smtp-session 'send "235 authentication successful")
126               (loop msg received-messages)))
127            ((smtp-command? "mail from:" line)
128             (message-from-set! msg (smtp-command-args "mail from:" line))
129             (smtp-session 'send "250 ok")
130             (loop msg received-messages))
131            ((smtp-command? "rcpt to:" line)
132             (message-to-set! msg (cons (smtp-command-args "rcpt to:" line)
133                                        (message-to msg)))
134             (if (message-valid? msg config)
135                 (smtp-session 'send "250 ok")
136                 (smtp-session 'send "551 relay forbidden"))
137             (loop msg received-messages))
138            ((smtp-command? "data" line)
139             (smtp-session 'send "354 intermediate")
140             (let text-loop ((text '()))
141               (let ((text-line (smtp-session 'get-line)))
142                 (if (string=? "." text-line)
143                     (message-text-set! msg text)
144                     (text-loop (conc text text-line "\n")))))
145             (smtp-session 'send "250 ok")
146             (loop (make-empty-message) (append (get-single-recipient-messages msg smtp-session)
147                                                received-messages)))
148            ((smtp-command? "quit" line)
149             (smtp-session 'send "221 closing transmission channel")
150             received-messages)
151            ((string=? "" (string-trim line))
152             (loop msg received-messages))
153            (else
154             (smtp-session 'send "502 command not implemented")
155             (loop msg received-messages)))))))
156
157 (define (get-single-recipient-messages smtp-session msg)
158   (map
159    (lambda (to)
160      (make-message to (message-from msg)
161                    (conc "Received: from " (smtp-session 'helo) "\n"
162                          "\tby " (config-host config) "\n"
163                          "\tfor " to ";\n"
164                          "\t" (time-stamp) "\n"
165                          (message-text msg))
166                    (message-user msg)
167                    (message-password msg)))
168    (message-to msg)))
169
170
171 ;;; Message stamping and validation
172 ;;
173
174 (define (get-local-addresses config)
175   (map (lambda (p) (cons
176                     (conc "<" (car p) "@" (config-host config) ">")
177                     (cdr p)))
178        (map (lambda (file)
179               (list (pathname-file file) file
180                     (let ((password-file (conc file ".auth")))
181                       (if (file-exists? password-file)
182                           (with-input-from-file password-file read-line)
183                           #f))))
184             (filter directory-exists?
185                     (glob (conc (config-spool-dir config) "/*"))))))
186
187 (define (message-stamp msg config)
188   (let* ((local-addresses (get-local-addresses config))
189          (local-dest (assoc (message-to msg) local-addresses))
190          (local-src (assoc (message-from msg) local-addresses)))
191     (cond
192      (local-dest
193       (list #t 'local (cadr local-dest)))
194      (local-src
195       (let ((password (caddr local-src)))
196         (if (and (string=? (conc "<" (message-user msg) "@" (config-host config) ">")
197                            (message-from msg))
198                  password
199                  (string=? (message-password msg) password))
200             (list #t 'remote)
201             (begin
202               (print "Provided password " (message-password msg))
203               (print "Host password " password)
204               (list #f 'remote)))))
205      (else
206       (list #f 'relay)))))
207
208 (define (message-valid? msg config)
209   (let ((stamp (message-stamp msg config)))
210     (print "Stamp: " stamp)
211     (car stamp)))
212
213
214 ;;; Sending/Delivering messages
215 ;;
216
217 (define (deliver-messages config messages)
218   (print "*** Attempting delivery of " (length messages) " mail items.")
219   (filter (lambda (msg) (not (deliver-message msg config)))
220           messages))
221
222 (define (deliver-message msg config)
223   (print "From: " (message-from msg))
224   (print "To: " (message-to msg))
225   (condition-case
226     (match (message-stamp msg config)
227       ((#t 'local dest-dir) (deliver-message-local msg dest-dir))
228       ((#t 'remote) (deliver-message-remote msg config))
229       ((#f 'remote)
230        (print "* REMOTE DELIVERY NOT ALLOWED (auth failure)")
231        #t)
232       (else
233        (print "* DELIVERY NOT ALLOWED (relay forbidden)")
234        #t))
235     (o (exn)
236        (print "* DELIVERY FAILED")
237        (print-error-message o)
238        #t)))
239
240 ;; Local delivery
241
242 (define (deliver-message-local msg dest-dir)
243   (with-output-to-file (conc dest-dir "/" (current-seconds))
244     (lambda ()
245       (print (message-text msg))))
246   (print "* MESSAGE DELIVERED (local)")
247   #t)
248
249
250 ;; Remote delivery
251
252 (define (get-domain-from-email email-string)
253   (car (string-split (cadr (string-split email-string "@")) ">")))
254
255 ;; This is a hack - there's no built-in interface to res_query()
256 ;; in chicken, so we have to resort to a system call to dig...
257 (define (get-mail-server-for-domain domain)
258   (let* ((mx-lines (let-values (((in out id) (process (conc "dig " domain " mx +short"))))
259                      (with-input-from-port in read-lines)))
260          (mx-entries (map (lambda (l)
261                             (let ((s (string-split l)))
262                               (list (string->number (car s)) 
263                                     (string-drop-right (cadr s) 1)))) ; remove trailing "."
264                           mx-lines))
265          (sorted-mx-entries (sort mx-entries (lambda (e f) (< (car e) (car f))))))
266     (if (null? sorted-mx-entries)
267         domain ; fall-back to email address domain if no mx entries
268         (cadar sorted-mx-entries)))) ; otherwise pick the highest priority server
269
270 (define (deliver-message-remote msg config)
271   (let* ((domain (get-domain-from-email (message-to msg)))
272          (mail-server (get-mail-server-for-domain domain)))
273     (print "Attempting delivery to " mail-server)
274     (let-values (((tcp-in tcp-out) (tcp-connect mail-server 25)))
275       (let ((smtp-session (make-outgoing-smtp-session tcp-in tcp-out)))
276         (let ((result (and
277                        (smtp-session 'expect "220")
278                        (smtp-session 'send "helo " (config-host config))
279                        (smtp-session 'expect "250")
280                        (smtp-session 'send "mail from:" (message-from msg))
281                        (smtp-session 'expect "250")
282                        (smtp-session 'send "rcpt to:" (message-to msg))
283                        (smtp-session 'expect "250")
284                        (smtp-session 'send "data")
285                        (smtp-session 'expect "354")
286                        (smtp-session 'send (message-text msg))
287                        (smtp-session 'send ".")
288                        (smtp-session 'expect "250" "5") ;Do not try again on rejects.
289                        (smtp-session 'send "quit"))))
290           (close-input-port tcp-in)
291           (close-output-port tcp-out)
292           (print "Connection closed.")
293           (if result
294               (print "* MESSAGE DELIVERED (remote)")
295               (print "* REMOTE DELIVERY FAILED (unexpected server response)"))
296           result)))))
297
298 (define (or-list l)
299   (fold (lambda (a b) (or a b)) #f l))
300
301 (define ((make-outgoing-smtp-session tcp-in tcp-out) . command)
302   (match command
303     (('expect codes ...)
304      (let loop ((result (read-line tcp-in)))
305        (if (and (> (string-length result) 3)
306                 (eq? (string-ref result 3) #\-))
307            (loop (read-line tcp-in)) ;status continues on next line
308            (begin
309              (print "Expecting one of " codes " got " result)
310              (or-list (map (lambda (code)
311                              (string-prefix? code result))
312                            codes))))))
313     (('send strings ...)
314      (print "Sending " (if (> (string-length (car strings)) 30)
315                            (string-take (car strings) 30)
316                            (car strings)))
317      (let ((processed-string
318             (string-translate* (conc (apply conc strings) "\n")
319                                '(("\n" . "\r\n")))))
320        (write-string processed-string #f tcp-out)))))
321
322
323 ;;; Command line argument parsing
324 ;;
325
326 (define (print-usage progname)
327   (print "Usage:\n"
328          progname " -h/--help\n"
329          progname " -v/--version\n"
330          progname " [-u/--user UID] [-g/--group GID] hostname [[port [spooldir]]\n"
331          "\n"
332          "The -u and -g options can be used to set the UID and GID of the process\n"
333          "following the creation of the TCP port listener (which often requires root)."))
334
335 (define (print-version)
336   (print lambdamail-version))
337
338 (define (main)
339   (let ((progname (pathname-file (car (argv))))
340         (config (make-config "" 25 "/var/spool/mail" '() '())))
341     (if (null? (cdr (argv)))
342         (print-usage progname)
343         (let loop ((args (cdr (argv))))
344           (let ((this-arg (car args))
345                 (rest-args (cdr args)))
346             (if (string-prefix? "-" this-arg)
347                 (cond
348                  ((or (equal? this-arg "-u")
349                       (equal? this-arg "--user"))
350                   (config-user-set! config (string->number (car rest-args)))
351                   (loop (cdr rest-args)))
352                  ((or (equal? this-arg "-g")
353                       (equal? this-arg "--group"))
354                   (config-group-set! config (string->number (car rest-args)))
355                   (loop (cdr rest-args)))
356                  ((or (equal? this-arg "-h")
357                       (equal? this-arg "--help"))
358                   (print-usage progname))
359                  ((or (equal? this-arg "-v")
360                       (equal? this-arg "--version"))
361                   (print-version))
362                  (else
363                   (print "Unknown option " this-arg "\n")
364                   (print-usage progname)))
365                 (begin
366                   (config-host-set! config this-arg)
367                   (unless (null? rest-args)
368                     (config-port-set! config (string->number (car rest-args)))
369                     (unless (null? (cdr rest-args))
370                       (config-spool-dir-set! config (cadr rest-args))))
371                   (run-server config))))))))
372
373 (main)
374
375 ;; (define (test)
376 ;;   (run-server (make-config "localhost" 2525 "spool" '() '())))
377
378 ;; (test)