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