;; -*- mode: common-lisp; package: net.post-office -*- ;; ;; smtp.cl ;; ;; copyright (c) 1986-2002 Franz Inc, Berkeley, CA - All rights reserved. ;; copyright (c) 2002-2012 Franz Inc, Oakland, CA - All rights reserved. ;; ;; This code is free software; you can redistribute it and/or ;; modify it under the terms of the version 2.1 of ;; the GNU Lesser General Public License as published by ;; the Free Software Foundation, as clarified by the AllegroServe ;; prequel found in license-allegroserve.txt. ;; ;; This code is distributed in the hope that it will be useful, ;; but without any warranty; without even the implied warranty of ;; merchantability or fitness for a particular purpose. See the GNU ;; Lesser General Public License for more details. ;; ;; Version 2.1 of the GNU Lesser General Public License is in the file ;; license-lgpl.txt that was distributed with this file. ;; If it is not present, you can access it from ;; http://www.gnu.org/copyleft/lesser.txt (until superseded by a newer ;; version) or write to the Free Software Foundation, Inc., 59 Temple Place, ;; Suite 330, Boston, MA 02111-1307 USA ;; ;; ;; $Id: smtp.cl,v 1.24 2008/09/16 23:22:14 layer Exp $ ;; Description: ;; send mail to an smtp server. See rfc821 for the spec. ;;- This code in this file obeys the Lisp Coding Standard found in ;;- http://www.franz.com/~jkf/coding_standards.html ;;- (defpackage :net.post-office (:use #:cl #:excl) (:export #:send-letter #:send-smtp #:send-smtp-auth #:test-email-address)) (in-package :net.post-office) (eval-when (compile load eval) (require :streamp) (require :sasl) (require :mime)) ;; the exported functions: ;; (send-letter "mail-server" "from" "to" "message" ;; &key cc bcc subject reply-to headers) ;; ;; ;; sends a message to the mail server (which may be a relay server ;; or the final destination). "from" is the address to be given ;; as the sender. "to" can be a string or a list of strings naming ;; recipients. ;; "message" is the message to be sent. It can be a string or a stream. ;; cc and bcc can be either be a string or a list of strings ;; naming recipients. All cc's and bcc's are sent the message ;; but the bcc's aren't included in the header created. ;; reply-to's value is a string and in cases a Reply-To header ;; to be created. ;; headers is a string or list of stings. These are raw header lines ;; added to the header build to send out. ;; ;; This builds a header and inserts the optional cc, bcc, ;; subject and reply-to lines. ;; ;; (send-smtp "mail-server" "from" "to" &rest messages) ;; this is like send-letter except that it doesn't build a header. ;; the messages should contain a header (and if not then sendmail ;; notices this and builds one -- other MTAs may not be that smart). ;; The messages ia list of strings or streams to be concatenated together ;; and sent as one message ;; ;; ;; (test-email-address "user@machine.com") ;; return t is this could be a valid email address on the machine ;; named. Do this by contacting the mail server and using the VRFY ;; command from smtp. Since some mail servers don't implement VRFY ;; we return t if VRFY doesn't work. ;; nil means that this address is bad (or we can't make contact with ;; the mail server, which could of course be a transient problem). ;; (defmacro response-case ((smtp-stream &optional smtp-response response-code) &rest case-clauses) ;; get a response from the smtp server and dispatch in a 'case' like ;; fashion to a clause based on the first digit of the return ;; code of the response. ;; smtp-response, if given, will be bound to string that is ;; the actual response ;; (let ((response-class (gensym))) `(multiple-value-bind (,response-class ,@(if* smtp-response then (list smtp-response)) ,@(if* response-code then (list response-code))) (progn (force-output ,smtp-stream) (wait-for-response ,smtp-stream)) ;;(declare (ignorable smtp-response)) (case ,response-class ,@case-clauses)))) (defmacro smtp-send-recv ((smtp-stream cmd smtp-response &optional response-code) &rest case-clauses) (let ((stream (gensym)) (sent (gensym))) `(let ((,stream ,smtp-stream) (,sent ,cmd)) (if* *smtp-debug* then (format *smtp-debug* "to smtp command: ~s~%" ,sent) (force-output *smtp-debug*)) (write-string ,sent ,stream) (write-char #\return ,stream) (write-char #\newline ,stream) (force-output ,stream) (macrolet ((smtp-transaction-error () (list 'error "SMTP transaction failed. We said: ~s, and the server replied: ~s" (quote ,sent) (quote ,smtp-response)))) (response-case (,stream ,smtp-response ,response-code) ,@case-clauses))))) (defvar *smtp-debug* nil) (defun send-letter (server from to message &key cc bcc subject reply-to headers login password attachments) ;; ;; see documentation at the head of this file ;; (if* (mime-part-constructed-p message) then (if* (and (not (multipart-mixed-p message)) attachments) then (error "~ attachments are not allowed for non-multipart/mixed messages.")) else (let ((part (if* (streamp message) then (make-mime-part :file message) elseif (stringp message) then (make-mime-part :text message) else (error "~ message must be a string, stream, or mime-part-constructed, not ~s" message)))) (setf message (if* attachments then (make-mime-part :subparts (list part)) else part)))) (let ((hdrs nil) (user-headers "") (tos (if* (stringp to) then (list to) elseif (consp to) then to else (error "to should be a string or list, not ~s" to))) (ccs (if* (null cc) then nil elseif (stringp cc) then (list cc) elseif (consp cc) then cc else (error "cc should be a string or list, not ~s" cc))) (bccs (if* (null bcc) then nil elseif (stringp bcc) then (list bcc) elseif (consp bcc) then bcc else (error "bcc should be a string or list, not ~s" bcc)))) (setf hdrs (with-output-to-string (hdrs) (macrolet ((already-have (name) `(mime-get-header ,name message))) ;; Give priority to headers already provided in a mime-part. (if* (not (already-have "From")) then (format hdrs "From: ~a~%" from)) (if* (not (already-have "To")) then (format hdrs "To: ~a~%" (list-to-delimited-string tos ", "))) (if* (and ccs (not (already-have "Cc"))) then (format hdrs "Cc: ~a~%" (list-to-delimited-string ccs ", "))) (if* (and subject (not (already-have "Subject"))) then (format hdrs "Subject: ~a~%" subject)) (if* (and reply-to (not (already-have "Reply-To"))) then (format hdrs "Reply-To: ~a~%" reply-to))))) (if* headers then (if* (stringp headers) then (setq headers (list headers)) elseif (consp headers) thenret else (error "Unknown headers format: ~s." headers)) (setf user-headers (with-output-to-string (header) (dolist (h headers) (format header "~a~%" h))))) ;; Temporarily modifies 'message', which may be user-provided. (let ((parts-save (mime-part-parts message))) (if* attachments then (if (not (consp attachments)) (setf attachments (list attachments))) (let (res) (dolist (attachment attachments) (if* (mime-part-constructed-p attachment) thenret elseif (or (streamp attachment) (stringp attachment) (pathnamep attachment)) then (setf attachment (make-mime-part :file attachment)) else (error "~ Attachments must be filenames, streams, or mime-part-constructed, not ~s" attachment)) (push attachment res)) (setf (mime-part-parts message) (append parts-save res)))) (with-mime-part-constructed-stream (s message) (send-smtp-auth server from (append tos ccs bccs) login password hdrs user-headers s)) (setf (mime-part-parts message) parts-save) t))) (defun send-smtp (server from to &rest messages) (send-smtp-1 server from to nil nil messages)) (defun send-smtp-auth (server from to login password &rest messages) (send-smtp-1 server from to login password messages)) (defun send-smtp-1 (server from to login password messages &key (external-format :default)) ;; send the effective concatenation of the messages via ;; smtp to the mail server ;; Each message should be a string or a stream. ;; ;; 'to' can be a single string or a list of strings. ;; each string should be in the official rfc822 format "foo@bar.com" ;; (let ((sock (connect-to-mail-server server login password))) (unwind-protect (progn (smtp-send-recv (sock (format nil "MAIL from:<~a>" from) msg) (2 ;; cool nil ) (t (smtp-transaction-error))) (let ((tos (if* (stringp to) then (list to) elseif (consp to) then to else (error "to should be a string or list, not ~s" to)))) (dolist (to tos) (smtp-send-recv (sock (format nil "RCPT to:<~a>" to) msg) (2 ;; cool nil ) (t (smtp-transaction-error))))) (smtp-send-recv (sock "DATA" msg) (3 ;; cool nil) (t (smtp-transaction-error))) (let ((at-bol t) (prev-ch nil) ch stream) (dolist (message messages) (when message (setf stream (if* (streamp message) then message else (make-buffer-input-stream (string-to-octets message :null-terminate nil :external-format external-format)))) (while (setf ch (read-byte stream nil)) (if* (and at-bol (eq ch #.(char-code #\.))) then ;; to prevent . from being interpreted as eol (write-char #\. sock)) (if* (eq ch #.(char-code #\newline)) then (setq at-bol t) (if* (not (eq prev-ch #.(char-code #\return))) then (write-char #\return sock)) else (setq at-bol nil)) (write-byte ch sock) (setq prev-ch ch))))) (write-char #\return sock) (write-char #\linefeed sock) (write-char #\. sock) (write-char #\return sock) (write-char #\linefeed sock) (response-case (sock msg) (2 nil ; (format t "Message sent to ~a~%" to) ) (t (error "message not sent: ~s" msg))) (smtp-send-recv (sock "QUIT" msg) (2 ;; cool nil) (t (smtp-transaction-error)))) ;; Cleanup (close sock)))) (defun connect-to-mail-server (server login password) ;; make that initial connection to the mail server ;; returning a socket connected to it and ;; signaling an error if it can't be made. (let ((use-port 25) ;; standard SMTP port ssl-args ssl starttls) (if* (consp server) then (if* (consp (cdr server)) then ;; long form (setq ssl-args (cdr server)) (setf server (car server)) (setq ssl (getf ssl-args :ssl)) (remf ssl-args :ssl) (setq use-port (or (getf ssl-args :port) (if ssl 465 use-port))) (remf ssl-args :port) (setq starttls (getf ssl-args :starttls)) (remf ssl-args :starttls) else ;; short form (setf use-port (cdr server)) (setf server (car server))) elseif (stringp server) then (multiple-value-bind (match whole m1 m2) (match-re "^([^:]+):([0-9]+)$" server) (declare (ignore whole)) (if* match then (setf server m1) (setf use-port (parse-integer m2))))) (let ((ipaddr (determine-mail-server server)) (sock) (ok)) (if* (null ipaddr) then (error "Can't determine ip address for mail server ~s" server)) (setq sock (socket:make-socket :remote-host ipaddr :remote-port use-port )) (when ssl (setq sock (apply #'acl-socket:make-ssl-client-stream sock ssl-args))) (unwind-protect (tagbody (response-case (sock msg) (2 ;; to the initial connect nil) (t (error "initial connect failed: ~s" msg))) ehlo ;; now that we're connected we can compute our hostname (let ((hostname (socket:ipaddr-to-hostname (socket:local-host sock)))) (if* (null hostname) then (setq hostname (format nil "[~a]" (socket:ipaddr-to-dotted (socket:local-host sock))))) (let ((mechs (smtp-ehlo sock hostname)) auth-mechs) (if* (and mechs starttls (member "STARTTLS" mechs :test #'string=)) then (smtp-send-recv (sock (format nil "STARTTLS") msg) (2 ;; ok (setq sock (acl-socket:make-ssl-client-stream sock :method :tlsv1))) (t (smtp-transaction-error))) (go ehlo) elseif (and mechs login password (setq auth-mechs (car (member "LOGIN" mechs :test #'(lambda (x y) (search x y)))))) then (setf sock (smtp-authenticate sock server auth-mechs login password))))) ;; all is good (setq ok t)) ;; cleanup: (if* (null ok) then (close sock :abort t) (setq sock nil))) ;; return: sock ))) ;; Returns string with mechanisms, or nil if none. ;; This may need to be expanded in the future as we support ;; more of the features that EHLO responds with. (defun smtp-ehlo (sock our-name) (smtp-send-recv (sock (format nil "EHLO ~A" our-name) msg) (2 ;; ok ;; Collect the auth mechanisms. (let (mechs) (multiple-value-bind (found whole mech) (match-re "250[- ]AUTH (.*)" msg) (declare (ignore whole)) (if found (push mech mechs))) (multiple-value-bind (found whole mech) (match-re "250[- ](STARTTLS)" msg) (declare (ignore whole)) (if found (push mech mechs))) mechs)) (t (smtp-send-recv (sock (format nil "HELO ~A" our-name) msg) (2 ;; ok nil) (t (smtp-transaction-error)))))) (defun smtp-authenticate (sock server mechs login password) (let ((ctx (net.sasl:sasl-client-new "smtp" server :user login :pass password))) (multiple-value-bind (res selected-mech response) (net.sasl:sasl-client-start ctx mechs) (if (not (eq res :continue)) (error "sasl-client-start unexpectedly returned: ~s" res)) (smtp-command sock "AUTH ~a" selected-mech) (loop (response-case (sock msg) (3 ;; need more interaction (multiple-value-setq (res response) (net.sasl:sasl-step ctx (base64-string-to-usb8-array (subseq msg 4)))) (smtp-command sock "~a" (usb8-array-to-base64-string response nil))) (2 ;; server is satisfied. ;; Make sure the auth process really completed (if (not (net.sasl:sasl-conn-auth-complete-p ctx)) (error "SMTP server indicated authentication complete before mechanisms was satisfied")) ;; It's all good. (return)) ;; break from loop (t (error "SMTP authentication failed: ~a" msg))))) ;; Reach here if authentication completed. ;; If a security layer was negotiated, return an encapsulated sock, ;; otherwise just return the original sock. (if (net.sasl:sasl-conn-security-layer-p ctx) (net.sasl:sasl-make-stream ctx sock :close-base t) sock))) (defun test-email-address (address) ;; test to see if we can determine if the address is valid ;; return nil if the address is bogus ;; return t if the address may or may not be bogus (if* (or (not (stringp address)) (zerop (length address))) then (error "mail address should be a non-empty string: ~s" address)) ; split on the @ sign (let (name hostname) (let ((pos (position #\@ address))) (if* (null pos) then (setq name address hostname "localhost") elseif (or (eql pos 0) (eql pos (1- (length address)))) then ; @ at beginning or end, bogus since we don't do route addrs (return-from test-email-address nil) else (setq name (subseq address 0 pos) hostname (subseq address (1+ pos))))) (let ((sock (ignore-errors (connect-to-mail-server hostname nil nil)))) (if* (null sock) then (return-from test-email-address nil)) (unwind-protect (progn (smtp-send-recv (sock (format nil "VRFY ~a" name) msg code) (5 (if* (eq code 550) then ; no such user msg ; to remove unused warning nil else ;; otherwise we don't know (return-from test-email-address t))) (t (return-from test-email-address t))) (smtp-send-recv (sock (format nil "VRFY ~a" address) msg code) (5 (if* (eq code 550) then ; no such user msg ; to remove unused warning nil else t)) (t t))) (close sock :abort t))))) (defun wait-for-response (stream) ;; read the response of the smtp server. ;; collect it all in a string. ;; Return two values: ;; response class ;; whole string ;; The string should begin with a decimal digit, and that is converted ;; into a number which is returned as the response class. ;; If the string doesn't begin with a decimal digit then the ;; response class is -1. ;; (flet ((match-chars (string pos1 pos2 count) ;; like strncmp (dotimes (i count t) (if* (not (eq (aref string (+ pos1 i)) (aref string (+ pos2 i)))) then (return nil))))) (let ((res (make-array 20 :element-type 'character :adjustable t :fill-pointer 0))) (if* (null (read-a-line stream res)) then ; eof encountered before end of line (return-from wait-for-response (values -1 res))) ;; a multi-line response begins with line containing ;; a hyphen in the 4th column: ;; xyz- some text ;; ;; and ends with a line containing the same reply code but no ;; hyphen. ;; xyz some text ;; (if* (and (>= (length res) 4) (eq #\- (aref res 3))) then ;; multi line response (let ((old-length (length res)) (new-length nil)) (loop (if* (null (read-a-line stream res)) then ; eof encountered before end of line (return-from wait-for-response (values -1 res))) (setq new-length (length res)) ;; see if this is the last line (if* (and (>= (- new-length old-length) 4) (eq (aref res (+ old-length 3)) #\space) (match-chars res 0 old-length 3)) then (return)) (setq old-length new-length)))) ;; complete response is in res ;; compute class and return the whole thing (let ((class (or (and (> (length res) 0) (digit-char-p (aref res 0))) -1))) (values class res (if* (>= (length res) 3) then ; compute the whole response value (+ (* (or (digit-char-p (aref res 0)) 0) 100) (* (or (digit-char-p (aref res 1)) 0) 10) (or (digit-char-p (aref res 2)) 0)))))))) (defun smtp-command (stream &rest format-args) ;; send a command to the smtp server (let ((command (apply #'format nil format-args))) (if* *smtp-debug* then (format *smtp-debug* "to smtp command: ~s~%" command) (force-output *smtp-debug*)) (write-string command stream) (write-char #\return stream) (write-char #\newline stream) (force-output stream))) (defun read-a-line (stream res) ;; read from stream and put the result in the adjust able array res ;; if line ends in cr-lf, only put a newline in res. ;; If we get an eof before the line finishes, return nil, ;; else return t if all is ok (let (ch last-ch) (loop (setq ch (read-char stream nil nil)) (if* (null ch) then ; premature eof (return nil)) (if* *smtp-debug* then (format *smtp-debug* "~c" ch) (force-output *smtp-debug*) ) (if* (eq last-ch #\return) then (if* (eq ch #\linefeed) then (vector-push-extend #\newline res) (return t) else (vector-push-extend last-ch res)) elseif (eq ch #\linefeed) then ; line ends with just lf, not cr-lf (vector-push-extend #\newline res) (return t) elseif (not (eq ch #\return)) then (vector-push-extend ch res)) (setq last-ch ch)))) (eval-when (compile eval) (defmacro ipaddrp (obj) ;; #+(version>= 8 0) `(socket:ipaddrp ,obj) ;; #-(version>= 8 0) `(and (integerp ,obj) (<= 0 ,obj #.(1- (expt 2 32))))) ) (defun determine-mail-server (name) ;; return the ipaddress to be used to connect to the ;; the mail server. ;; name is any method for naming a machine: ;; ip address (binary) ;; string with dotted ip address ;; string naming a domain ;; we can only do the mx lookup for the third case, the rest ;; we just return the ipaddress for what we were given ;; (let (ipaddr) (if* (ipaddrp name) then name elseif (ipaddrp (setq ipaddr (socket:dotted-to-ipaddr name :errorp nil))) then ipaddr else ; do mx lookup if acldns is being used (if* (or (eq socket:*dns-mode* :acldns) (and (consp socket:*dns-mode*) (member :acldns socket:*dns-mode* :test #'eq))) then (let ((res (socket:dns-query name :type :mx))) (if* (and (consp res) (cadr res)) then (cadr res) ; the ip address else (dolist (suffix socket::*domain-search-list* (socket:dns-lookup-hostname name)) (declare (special socket:*domain-search-list*)) (let ((name (concatenate 'string name "." suffix))) (setq res (socket:dns-query name :type :mx)) (if* (and res (cadr res)) then (return (cadr res))))))) else ; just do a hostname lookup (ignore-errors (socket:lookup-hostname name)))))) (provide :smtp)