git.fiddlerwoaroof.com
Raw Blame History
;; mail sending package
;;
;; smtp -  rfc821
;;
(defpackage :net.post-office
  (:use #:lisp #:excl)
  (:export 
   #:send-letter
   #:send-smtp))

(in-package :net.post-office)

;; the exported functions:

;; (send-letter "mail-server" "from" "to" "message" &key subject reply-to)
;;  
;;    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
;;    This builds a header and inserts the optional 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 string to be concatenated together
;;    and sent as one message
;;
;;











(defmacro response-case ((ftp-stream) &rest case-clauses)
  (let ((response-class (gensym)))
    `(multiple-value-bind (,response-class ftp-response)
	 (progn (force-output ,ftp-stream)
		(wait-for-response ,ftp-stream))
       (declare (ignorable ftp-response))
       (case ,response-class
	 ,@case-clauses))))

(defvar *smtp-debug* nil)



(defun send-letter (server from to message
		    &key subject
			 reply-to)
  (let ((header (make-string-output-stream)))
    (format header "From: ~a~c~cTo: "
	    from
	    #\return
	    #\linefeed)
    (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))))
      (format header "~{ ~a~^,~}~c~c" tos #\return #\linefeed))
    
    (if* subject
       then (format header "Subject: ~a~c~c" subject #\return #\linefeed))
    
    (if* reply-to
       then (format header "Reply-To: ~a~c~c" reply-to #\return #\linefeed))
    
    
    (format header "~c~c" #\return #\linefeed)
    
    (send-smtp server from to (get-output-stream-string header) message)
  
  
  
    ))
    
    
	  
		    

(defun send-smtp (server from to &rest messages)
  ;; send the effective concatenation of the messages via
  ;; smtp to the mail server
  ;; Each message should be a string
  ;;
  ;; '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 (socket:make-socket :remote-host server
				  :remote-port 25  ; smtp
				  )))
    (unwind-protect
	(progn
	  (response-case (sock)
			 (2 ;; to the initial connect
			  nil)
			 (t (error "initial connect failed")))
	  
	  ;; now that we're connected we can compute our hostname
	  (let ((hostname (socket:ipaddr-to-hostname
			   (socket:local-host sock))))
	    (if* (null hostname)
	       then (format nil "[~a]" (socket:ipaddr-to-dotted
					(socket:local-host sock))))
	    (smtp-command sock "HELO ~a" hostname)
	    (response-case (sock)
			   (2 ;; ok
			    nil)
			   (t (error "hello greeting failed"))))
	    
	  (smtp-command sock "MAIL from:<~a>" from)
	  (response-case (sock)
			 (2 ;; cool
			  nil
			  )
			 (t (error "Mail from command failed")))

	  (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-command sock "RCPT to:<~a>" to)
	      (response-case (sock)
			     (2 ;; cool
			      nil
			      )
			     (t (error "rcpt to command failed")))))
	
	  (smtp-command sock "DATA")
	  (response-case (sock)
			 (3 ;; cool
			  nil)
			 (t (error "Data command failed")))
	  ;(format t "sending message~%") (force-output t)	
	  
	  
	  (let ((at-bol t))
	    (dolist (message messages)
	      (dotimes (i (length message))
		(let ((ch (aref message i)))
		  (if* (and at-bol (eq ch #\.))
		     then ; to prevent . from being interpreted as eol
			  (write-char #\. sock))
		  (if* (eq ch #\newline)
		     then (setq at-bol t)
			  (write-char #\return sock)
		     else (setq at-bol nil))
		  (write-char ch sock)))))
	
	  (write-char #\return sock) (write-char #\linefeed sock)
	  (write-char #\. sock)
	  (write-char #\return sock) (write-char #\linefeed sock)
	
	  (response-case (sock)
			 (2 nil ; (format t "Message sent to ~a~%" to)
			    )
			 
			 (t (error "message not sent")))

	  (force-output t)
	  
	  (smtp-command sock "QUIT")
	  (response-case (sock)
			 (2 ;; cool
			  nil)
			 (t (error "quit failed"))))
      (close sock))))







	
      
(defun wait-for-response (stream)
  ;; read the response of the ftp 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)))))      

(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))))