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