1 ;; Super-basic bell-and-whistle-free SMTP server.
3 ;; Intended for a single-user system
14 (chicken process-context)
15 (chicken process-context posix)
18 srfi-1 srfi-13 matchable base64)
20 (define lambdamail-version "LambdaMail v1.5.0")
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 "" "" "" "" ""))
27 (time->string (seconds->local-time) "%d %b %Y %T %z"))
30 ;;; Server initialization
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))
39 (set! (current-user-id) uid))))
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) ")")
49 (server-loop listener config '())))
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))))
60 ;;; Receiving messages
63 (define (receive-messages listener config)
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)))
70 (set! messages (process-smtp (make-smtp-session in-port out-port config) config))
72 (print-error-message o)))
73 (print "Terminating connection.")
74 (close-input-port in-port)
75 (close-output-port out-port))
78 (define (make-smtp-session in-port out-port config)
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))
87 (define (smtp-command? cmd-string input-string)
88 (string-prefix? cmd-string (string-downcase input-string)))
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)))
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)))
101 (if (not (string? line))
102 '() ; Don't keep anything on unexpected termination.
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))
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 (smtp-command-args "rcpt to:" line))
133 (if (message-valid? msg config)
134 (smtp-session 'send "250 ok")
135 (smtp-session 'send "551 relay forbidden"))
136 (loop msg received-messages))
137 ((smtp-command? "data" line)
138 (smtp-session 'send "354 intermediate")
139 (let text-loop ((text (conc "Received: from " (smtp-session 'helo) "\n"
140 "\tby " (config-host config) "\n"
141 "\tfor " (message-to msg) ";\n"
142 "\t" (time-stamp) "\n")))
143 (let ((text-line (smtp-session 'get-line)))
144 (if (string=? "." text-line)
145 (message-text-set! msg text)
146 (text-loop (conc text text-line "\n")))))
147 (smtp-session 'send "250 ok")
148 (loop (make-empty-message) (cons msg received-messages)))
149 ((smtp-command? "quit" line)
150 (smtp-session 'send "221 closing transmission channel")
152 ((string=? "" (string-trim line))
153 (loop msg received-messages))
155 (smtp-session 'send "502 command not implemented")
156 (loop msg received-messages)))))))
159 ;;; Message stamping and validation
162 (define (get-local-addresses config)
163 (map (lambda (p) (cons
164 (conc "<" (car p) "@" (config-host config) ">")
167 (list (pathname-file file) file
168 (let ((password-file (conc file ".auth")))
169 (if (file-exists? password-file)
170 (with-input-from-file password-file read-line)
172 (filter directory-exists?
173 (glob (conc (config-spool-dir config) "/*"))))))
175 (define (message-stamp msg config)
176 (let* ((local-addresses (get-local-addresses config))
177 (local-dest (assoc (message-to msg) local-addresses))
178 (local-src (assoc (message-from msg) local-addresses)))
181 (list #t 'local (cadr local-dest)))
183 (let ((password (caddr local-src)))
184 (if (and (string=? (conc "<" (message-user msg) "@" (config-host config) ">")
187 (string=? (message-password msg) password))
190 (print "Provided password " (message-password msg))
191 (print "Host password " password)
192 (list #f 'remote)))))
196 (define (message-valid? msg config)
197 (let ((stamp (message-stamp msg config)))
198 (print "Stamp: " stamp)
202 ;;; Sending/Delivering messages
205 (define (deliver-messages config messages)
206 (print "*** Attempting delivery of " (length messages) " mail items.")
207 (filter (lambda (msg) (not (deliver-message msg config)))
210 (define (deliver-message msg config)
211 (print "From: " (message-from msg))
212 (print "To: " (message-to msg))
214 (match (message-stamp msg config)
215 ((#t 'local dest-dir) (deliver-message-local msg dest-dir))
216 ((#t 'remote) (deliver-message-remote msg config))
218 (print "* REMOTE DELIVERY NOT ALLOWED (auth failure)")
221 (print "* DELIVERY NOT ALLOWED (relay forbidden)")
224 (print "* DELIVERY FAILED")
225 (print-error-message o)
230 (define (deliver-message-local msg dest-dir)
231 (with-output-to-file (conc dest-dir "/" (current-seconds))
233 (print (message-text msg))))
234 (print "* MESSAGE DELIVERED (local)")
240 (define (get-domain-from-email email-string)
241 (car (string-split (cadr (string-split email-string "@")) ">")))
243 ;; This is a hack - there's no built-in interface to res_query()
244 ;; in chicken, so we have to resort to a system call to dig...
245 (define (get-mail-server-for-domain domain)
246 (let* ((mx-lines (let-values (((in out id) (process (conc "dig " domain " mx +short"))))
247 (with-input-from-port in read-lines)))
248 (mx-entries (map (lambda (l)
249 (let ((s (string-split l)))
250 (list (string->number (car s))
251 (string-drop-right (cadr s) 1)))) ; remove trailing "."
253 (sorted-mx-entries (sort mx-entries (lambda (e f) (< (car e) (car f))))))
254 (if (null? sorted-mx-entries)
255 domain ; fall-back to email address domain if no mx entries
256 (cadar sorted-mx-entries)))) ; otherwise pick the highest priority server
258 (define (deliver-message-remote msg config)
259 (let* ((domain (get-domain-from-email (message-to msg)))
260 (mail-server (get-mail-server-for-domain domain)))
261 (print "Attempting delivery to " mail-server)
262 (let-values (((tcp-in tcp-out) (tcp-connect mail-server 25)))
263 (let ((smtp-session (make-outgoing-smtp-session tcp-in tcp-out)))
265 (smtp-session 'expect "220")
266 (smtp-session 'send "helo " (config-host config))
267 (smtp-session 'expect "250")
268 (smtp-session 'send "mail from:" (message-from msg))
269 (smtp-session 'expect "250")
270 (smtp-session 'send "rcpt to:" (message-to msg))
271 (smtp-session 'expect "250")
272 (smtp-session 'send "data")
273 (smtp-session 'expect "354")
274 (smtp-session 'send (message-text msg))
275 (smtp-session 'send ".")
276 (smtp-session 'expect "250" "5") ;Do not try again on rejects.
277 (smtp-session 'send "quit"))))
278 (close-input-port tcp-in)
279 (close-output-port tcp-out)
280 (print "Connection closed.")
282 (print "* MESSAGE DELIVERED (remote)")
283 (print "* REMOTE DELIVERY FAILED (unexpected server response)"))
287 (fold (lambda (a b) (or a b)) #f l))
289 (define ((make-outgoing-smtp-session tcp-in tcp-out) . command)
292 (let loop ((result (read-line tcp-in)))
293 (if (and (> (string-length result) 3)
294 (eq? (string-ref result 3) #\-))
295 (loop (read-line tcp-in)) ;status continues on next line
297 (print "Expecting one of " codes " got " result)
298 (or-list (map (lambda (code)
299 (string-prefix? code result))
302 (print "Sending " (if (> (string-length (car strings)) 30)
303 (string-take (car strings) 30)
305 (let ((processed-string
306 (string-translate* (conc (apply conc strings) "\n")
307 '(("\n" . "\r\n")))))
308 (write-string processed-string #f tcp-out)))))
311 ;;; Command line argument parsing
314 (define (print-usage progname)
316 progname " -h/--help\n"
317 progname " -v/--version\n"
318 progname " [-u/--user UID] [-g/--group GID] hostname [[port [spooldir]]\n"
320 "The -u and -g options can be used to set the UID and GID of the process\n"
321 "following the creation of the TCP port listener (which often requires root)."))
323 (define (print-version)
324 (print lambdamail-version))
327 (let ((progname (pathname-file (car (argv))))
328 (config (make-config "" 25 "/var/spool/mail" '() '())))
329 (if (null? (cdr (argv)))
330 (print-usage progname)
331 (let loop ((args (cdr (argv))))
332 (let ((this-arg (car args))
333 (rest-args (cdr args)))
334 (if (string-prefix? "-" this-arg)
336 ((or (equal? this-arg "-u")
337 (equal? this-arg "--user"))
338 (config-user-set! config (string->number (car rest-args)))
339 (loop (cdr rest-args)))
340 ((or (equal? this-arg "-g")
341 (equal? this-arg "--group"))
342 (config-group-set! config (string->number (car rest-args)))
343 (loop (cdr rest-args)))
344 ((or (equal? this-arg "-h")
345 (equal? this-arg "--help"))
346 (print-usage progname))
347 ((or (equal? this-arg "-v")
348 (equal? this-arg "--version"))
351 (print "Unknown option " this-arg "\n")
352 (print-usage progname)))
354 (config-host-set! config this-arg)
355 (unless (null? rest-args)
356 (config-port-set! config (string->number (car rest-args)))
357 (unless (null? (cdr rest-args))
358 (config-spool-dir-set! config (cadr rest-args))))
359 (run-server config))))))))
364 ;; (run-server (make-config "localhost" 2525 "spool" '() '())))