;; Super-basic bell-and-whistle-free SMTP server. ;; ;; Intended for a single-user system (import tcp6 (chicken port) (chicken io) (chicken string) (chicken pathname) (chicken file) (chicken time) (chicken time posix) (chicken process) (chicken process-context) (chicken process-context posix) (chicken condition) srfi-1 srfi-13 matchable) (define lambdamail-version "0.0.1") (define-record config host port spool-dir user group) (define-record message to from text helo) (define (make-empty-message) (make-message "" "" "" "")) ;;; SMTP transactions ;; (define ((make-smtp in-port out-port config) type) (if (eq? type 'get-line) (read-line in-port) (write-line (conc (case type ((greeting) (conc "220 " (config-host config) " LambdaMail v" lambdamail-version)) ((ok) "250 ok") ((intermediate) "354 intermediate") ((close) "221 closing transmission channel") ((not-implemented) "502 command not implemented")) "\r") out-port))) ;;; Server initialization ;; (define (drop-privs config) (let ((uid (config-user config)) (gid (config-group config))) (if (not (null? gid)) ; Group first, since only root can switch groups. (set! (current-group-id) gid)) (if (not (null? uid)) (set! (current-user-id) uid)))) (define (run-server config) (set-buffering-mode! (current-output-port) #:line) (let ((listener (tcp-listen (config-port config) 10 "::"))) (print "LambdaMail v" lambdamail-version " listening on port " (config-port config) " ...") (print "(Host name: " (config-host config) ", Spool dir: " (config-spool-dir config) ")") (drop-privs config) (server-loop listener config))) ;;; Main server loop ;; (define (server-loop listener config) (let-values (((in-port out-port) (tcp-accept listener))) (let-values (((local-ip remote-ip) (tcp-addresses in-port))) (print "Accepted connection from " remote-ip " on " (seconds->string))) (condition-case (let ((smtp (make-smtp in-port out-port config))) (smtp 'greeting) (process-smtp smtp config)) (o (exn) (print-error-message o))) (print "Terminating connection.") (close-input-port in-port) (close-output-port out-port) (server-loop listener config))) ;;; SMTP processing loop ;; (define (process-smtp smtp config) (let loop ((msg (make-empty-message)) (line-orig (smtp 'get-line))) (if (string? line-orig) (let ((line (string-downcase line-orig))) (print "got " line) (cond ((string-prefix? "helo" line) (message-helo-set! msg (string-drop line (string-length "helo"))) (smtp 'ok) (loop msg (smtp 'get-line))) ((string-prefix? "mail from:" line) (message-from-set! msg (string-drop line (string-length "mail from:"))) (smtp 'ok) (loop msg (smtp 'get-line))) ((string-prefix? "rcpt to:" line) (message-to-set! msg (string-drop line (string-length "rcpt to:"))) (smtp 'ok) (loop msg (smtp 'get-line))) ((string-prefix? "data" line) (smtp 'intermediate) (let text-loop ((text-line (smtp 'get-line)) (text "")) (if (string=? "." text-line) (message-text-set! msg text) (text-loop (smtp 'get-line) (conc text text-line "\n")))) (process-message msg config) (smtp 'ok) (loop (make-empty-message) (smtp 'get-line))) ((string-prefix? "quit" line) (smtp 'close) 'done) ((string=? "" line) (loop msg (smtp 'get-line))) (else (smtp 'not-implemented) (loop msg (smtp 'get-line))))) 'done))) ;;; Message delivery ;; (define (get-to-addresses config) (map (lambda (p) (cons (conc "<" (car p) "@" (config-host config) ">") (cdr p))) (map (lambda (file) (cons (pathname-file file) file)) (glob (conc (config-spool-dir config) "/*"))))) (define (remove-angle-brackets addr) (let ((left-idx (substring-index "<" addr)) (right-idx (substring-index ">" addr))) (substring addr (+ left-idx 1) right-idx))) (define (deliver-message-mbox msg dest-file) (print "Delivering to mbox " dest-file) (with-output-to-file dest-file (lambda () (print "\nFrom " (remove-angle-brackets (message-from msg))) (print (message-text msg))) #:append)) (define (deliver-message-maildir msg dest-dir) (print "Delivering to maildir " dest-dir) (with-output-to-file (conc dest-dir "/" (current-seconds)) (lambda () (print (message-text msg))))) (define (process-message msg config) (let ((dest (assoc (message-to msg) (get-to-addresses config)))) (if dest (let ((dest-file (cdr dest))) (if (directory-exists? dest-file) (deliver-message-maildir msg dest-file) (deliver-message-mbox msg dest-file)) (print "Message DELIVERED:")) (print "Message REJECTED:")) (print " * From: " (message-from msg)) (print " * To: " (message-to msg)))) ;;; Command line argument parsing ;; (define (print-usage progname) (print "Usage:\n" progname " -h/--help\n" progname " [-u/--user UID] [-g/--group GID] hostname [[port [spooldir]]\n" "\n" "The -u and -g options can be used to set the UID and GID of the process\n" "following the creation of the TCP port listener (which often requires root).")) (define (main) (let ((progname (pathname-file (car (argv)))) (config (make-config "" 25 "/var/spool/mail" '() '()))) (if (null? (cdr (argv))) (print-usage progname) (let loop ((args (cdr (argv)))) (let ((this-arg (car args)) (rest-args (cdr args))) (if (string-prefix? "-" this-arg) (cond ((or (equal? this-arg "-u") (equal? this-arg "--user")) (config-user-set! config (string->number (car rest-args))) (loop (cdr rest-args))) ((or (equal? this-arg "-g") (equal? this-arg "--group")) (config-group-set! config (string->number (car rest-args))) (loop (cdr rest-args))) ((or (equal? this-arg "-h") (equal? this-arg "--help")) (print-usage progname)) (else (print "Unknown option " this-arg "\n") (print-usage progname))) (begin (config-host-set! config this-arg) (unless (null? rest-args) (config-port-set! config (string->number (car rest-args))) (unless (null? (cdr rest-args)) (config-spool-dir-set! (cadr rest-args)))) (run-server config)))))))) (main) ;; (run-server (make-config "thelambdalab.xyz" 2525 "/var/spool/mail"))