root/trunk/thirdparty/cl-smtp/cl-smtp.lisp

Revision 4342, 18.1 KB (checked in by hans, 12 months ago)

Patch to make sending multipart messages with send-smtp work better,
provided by Russ Tyndall.

Line 
1;;; -*- mode: Lisp -*-
2       
3;;; This file is part of CL-SMTP, the Lisp SMTP Client
4
5;;; Copyright (C) 2004/2005/2006/2007 Jan Idzikowski
6
7;;; This library is free software; you can redistribute it and/or
8;;; modify it under the terms of the Lisp Lesser General Public License
9;;; (http://opensource.franz.com/preamble.html), known as the LLGPL.
10
11;;; This library is distributed in the hope that it will be useful,
12;;; but WITHOUT ANY WARRANTY; without even the implied warranty of
13;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14;;; Lisp Lesser GNU General Public License for more details.
15
16;;; File: cl-smtp.lisp
17;;; Description: main smtp client logic
18
19(in-package :cl-smtp)
20
21(defparameter *content-type* "text/plain; charset=ISO-8859-1")
22
23(defparameter *x-mailer* (format nil "(~A ~A)"
24                                 (lisp-implementation-type)
25                                 (lisp-implementation-version)))
26
27(defun check-arg (arg name)
28  (cond
29   ((or (stringp arg)
30        (pathnamep arg))
31    (list arg))
32   ((listp arg)
33    arg)
34   (t
35    (error "the \"~A\" argument is not a string or cons" name))))
36
37(eval-when (:compile-toplevel :load-toplevel :execute)
38  (defvar *line-with-one-dot* #.(format nil "~C~C.~C~C" #\Return #\NewLine
39                                        #\Return #\NewLine))
40  (defvar *line-with-two-dots* #.(format nil "~C~C..~C~C" #\Return #\NewLine
41                                         #\Return #\NewLine)))
42
43(defun mask-dot (str)
44  "Replace all occurences of \r\n.\r\n in STR with \r\n..\r\n"
45  (let ((resultstr ""))
46    (labels ((mask (tempstr)
47               (let ((n (search *line-with-one-dot* tempstr)))
48                 (cond
49                  (n
50                   (setf resultstr (concatenate 'string resultstr
51                                                (subseq tempstr 0 n)
52                                                *line-with-two-dots*))
53                   (mask (subseq tempstr (+ n #.(length *line-with-one-dot*)))))
54                  (t
55                   (setf resultstr (concatenate 'string resultstr
56                                                tempstr)))))))
57      (mask str))
58    resultstr))
59
60(defun string-to-base64-string (str)
61  (declare (ignorable str))
62  #+allegro (excl:string-to-base64-string str)
63  #-allegro (cl-base64:string-to-base64-string str))
64
65(define-condition smtp-error (error)
66  ())
67
68(define-condition smtp-protocol-error (smtp-error)
69  ((command :initarg :command :reader command)
70   (expected-response-code :initarg :expected-response-code :reader expected-response-code)
71   (response-code :initarg :response-code :reader response-code)
72   (response-message :initarg :response-message :reader response-message))
73  (:report (lambda (condition stream)
74             (print-unreadable-object (condition stream :type t)
75               (format stream "a command failed:~%command: ~S expected: ~A response: ~A"
76                       (command condition)
77                       (expected-response-code condition)
78                       (response-message condition))))))
79
80(define-condition rcpt-failed (smtp-protocol-error)
81  ((recipient :initarg :recipient
82              :reader recipient))
83  (:report (lambda (condition stream)
84             (print-unreadable-object (condition stream :type t)
85               (format stream "while trying to send email through SMTP, the server rejected the recipient ~A: ~A"
86                       (recipient condition)
87                       (response-message condition))))))
88
89(defun smtp-command (stream command expected-response-code
90                     &key (condition-class 'smtp-protocol-error)
91                     condition-arguments)
92  (when command
93    (write-to-smtp stream command))
94  (multiple-value-bind (code msgstr lines)
95      (read-from-smtp stream)
96    (when (/= code expected-response-code)
97      (apply #'error
98             condition-class
99             (append condition-arguments
100                     (list :command command
101                           :expected-response-code expected-response-code
102                           :response-code code
103                           :response-message msgstr))))
104    lines))
105
106(defun do-with-smtp-mail (host from to thunk &key port authentication ssl local-hostname)
107  (usocket:with-client-socket (socket stream host port)
108    (let ((stream (smtp-handshake stream
109                                  :authentication authentication
110                                  :ssl ssl
111                                  :local-hostname local-hostname)))
112      (initiate-smtp-mail stream from to)
113      (funcall thunk  (make-instance 'smtp-output-stream :encapsulated-stream stream))
114      (finish-smtp-mail stream))))
115
116(defmacro with-smtp-mail ((stream-var host from to &key ssl (port (if (eq :tls ssl) 465 25)) authentication local-hostname)
117                          &body body)
118  "Encapsulate a SMTP MAIl conversation.  A connection to the SMTP
119   server on HOST and PORT is established and a MAIL command is
120   initiated with FROM being the mail sender and TO being the list of
121   recipients.  BODY is evaluated with STREAM-VAR being the stream
122   connected to the remote SMTP server.  BODY is expected to write the
123   RFC2821 message (headers and body) to STREAM-VAR."
124  `(do-with-smtp-mail ,host ,from ,to
125                      (lambda (,stream-var) ,@body)
126                      :port ,port
127                      :authentication ,authentication
128                      :ssl ,ssl
129                      :local-hostname ,local-hostname))
130
131(defun send-email (host from to subject message
132                   &key ssl (port (if (eq :tls ssl) 465 25)) cc bcc reply-to extra-headers
133                   html-message display-name authentication
134                   attachments (buffer-size 256))
135  (send-smtp host from (check-arg to "to") subject (mask-dot message)
136             :port port :cc (check-arg cc "cc") :bcc (check-arg bcc "bcc")
137             :reply-to reply-to
138             :extra-headers extra-headers
139             :html-message html-message
140             :display-name display-name
141             :authentication authentication
142             :attachments (check-arg attachments "attachments")
143             :buffer-size (if (numberp buffer-size)
144                              buffer-size
145                              256)
146             :ssl ssl))
147
148(defun send-smtp (host from to subject message
149                  &key ssl (port (if (eq :tls ssl) 465 25)) cc bcc
150                  reply-to extra-headers html-message display-name
151                  authentication attachments buffer-size
152                  (local-hostname (usocket::get-host-name)))
153  (with-smtp-mail (stream host from (append to cc bcc)
154                          :port port
155                          :authentication authentication
156                          :ssl ssl
157                          :local-hostname local-hostname)
158    (let* ((boundary (make-random-boundary))
159           (html-boundary (if (and attachments html-message)
160                              (make-random-boundary)
161                              boundary)))
162      (send-mail-headers stream
163                         :from from
164                         :to to
165                         :cc cc
166                         :reply-to reply-to
167                         :display-name display-name
168                         :extra-headers extra-headers :subject subject)
169      (when (or attachments html-message)
170        (send-multipart-headers stream
171                                :attachment-boundary (when attachments boundary)
172                                :html-boundary html-boundary))
173      ;;----------- Send  the body Message ---------------------------
174      ;;--- Send the proper headers depending on plain-text,
175      ;;--- multi-part or html email
176      (cond ((and attachments html-message)
177             ;; if both present, start attachment section,
178             ;; then define alternative section,
179             ;; then write alternative header
180             (progn
181               (generate-message-header
182                stream :boundary boundary :include-blank-line? nil)
183               (generate-multipart-header stream html-boundary
184                                          :multipart-type "alternative")
185               (write-blank-line stream)
186               (generate-message-header
187                stream :boundary html-boundary :content-type *content-type*
188                :content-disposition "inline" :include-blank-line? nil)))
189            (attachments
190             (generate-message-header
191              stream :boundary boundary
192              :content-type *content-type* :content-disposition "inline"
193              :include-blank-line? nil))
194            (html-message
195             (generate-message-header
196              stream :boundary html-boundary :content-type *content-type*
197              :content-disposition "inline"))
198            (t
199             (generate-message-header stream :content-type *content-type*
200                                      :include-blank-line? nil)))
201      (write-blank-line stream)
202      (write-to-smtp stream message)
203      (write-blank-line stream)
204      ;;---------- Send  Html text if needed -------------------------
205      (when html-message
206        (generate-message-header
207         stream :boundary html-boundary
208         :content-type "text/html; charset=ISO-8859-1"
209         :content-disposition "inline")
210        (write-to-smtp stream html-message)
211        (send-end-marker stream html-boundary))
212      ;;---------- Send Attachments -----------------------------------
213      (when attachments
214        (dolist (attachment attachments)
215          (send-attachment stream attachment boundary buffer-size))
216        (send-end-marker stream boundary)))))
217
218(define-condition no-supported-authentication-method (smtp-error)
219  ((features :initarg :features :reader features))
220  (:report (lambda (condition stream)
221             (print-unreadable-object (condition stream :type t)
222               (format stream "SMTP authentication has been requested, but the SMTP server did not advertise any ~
223                               supported authentication scheme.  Features announced: ~{~S~^, ~}"
224                       (features condition))))))
225
226(defun smtp-authenticate (stream authentication features)
227  "Authenticate to the SMTP server connected on STREAM.
228   AUTHENTICATION is a list of two or three elements.  If the first
229   element is a keyword, it specifies the desired authentication
230   method (:PLAIN or :LOGIN), which is currently ignored.  The actual
231   method used is determined by looking at the advertised features of
232   the SMTP server.  The (other) two elements of the AUTHENTICATION
233   list are the login username and password.  FEATURES is the list of
234   features announced by the SMTP server.
235
236   If the server does not announce any compatible authentication scheme,
237   the NO-SUPPORTED-AUTHENTICATION-METHOD error is signalled."
238  (when (keywordp (car authentication))
239    (pop authentication))
240  (let ((server-authentication (loop for i in features
241                                  for e = (search "AUTH " i :test #'equal)
242                                  when (and e (= e 0))
243                                  return i)))
244    (destructuring-bind (username password) authentication
245      (cond
246        ((search " PLAIN" server-authentication :test #'equal)
247         (smtp-command stream (format nil "AUTH PLAIN ~A"
248                                      (string-to-base64-string
249                                       (format nil "~A~C~A~C~A"
250                                               username
251                                               #\null username
252                                               #\null password)))
253                       235))
254        ((search " LOGIN" server-authentication :test #'equal)
255         (smtp-command stream "AUTH LOGIN"
256                       334)
257         (smtp-command stream (string-to-base64-string username)
258                       334)
259         (smtp-command stream (string-to-base64-string password)
260                       235))
261        (t
262         (error 'no-supported-authentication-method :features features))))))
263
264(defun smtp-handshake (stream &key authentication ssl local-hostname)
265  "Perform the initial SMTP handshake on STREAM.  Returns the stream
266   to use further down in the conversation, which may be different from
267   the original stream if we switched to SSL."
268
269  ;; Read the initial greeting from the SMTP server
270  (smtp-command stream nil
271                220)
272
273  (unless (or ssl authentication)
274    ;; Unless we want ESMTP features, perform classic SMTP handshake and return
275    (smtp-command stream (format nil "HELO ~A"
276                                   (usocket::get-host-name))
277                  250)
278    (return-from smtp-handshake stream))
279
280  ;; When SSL or authentication requested, perform ESMTP EHLO
281  (let (features)
282    (labels
283        ((do-ehlo ()
284           (setf features (rest (smtp-command stream (format nil "EHLO ~A" local-hostname)
285                                              250))))
286         (convert-connection-to-ssl ()
287           (setf stream
288                 #+allegro (socket:make-ssl-client-stream stream)
289                 #-allegro
290                 (let ((s stream))
291                   (cl+ssl:make-ssl-client-stream
292                    (cl+ssl:stream-fd stream)
293                    :close-callback (lambda () (close s)))))
294           #-allegro
295           (setf stream (flexi-streams:make-flexi-stream
296                         stream
297                         :external-format
298                         (flexi-streams:make-external-format
299                          :latin-1 :eol-style :lf)))))
300      (ecase ssl
301        ((or t :starttls)
302         (do-ehlo)
303         (unless (find "STARTTLS" features :test #'equal)
304           (error "this server does not supports TLS"))
305         (print-debug "this server supports TLS")
306         (smtp-command stream "STARTTLS"
307                       220)
308         (convert-connection-to-ssl)
309         ;; After STARTTLS, the connection is "like new".  Re-do the
310         ;; EHLO command to switch the server to ESMTP mode and read
311         ;; the list of announced features again.
312         (do-ehlo))
313        (:tls
314         ;; Plain SSL connection
315         (convert-connection-to-ssl)
316         (do-ehlo))
317        ((nil)
318         (do-ehlo))))
319    (when authentication
320      (smtp-authenticate stream authentication features)))
321  stream)
322 
323(defun initiate-smtp-mail (stream from to)
324  "Initiate an SMTP MAIL command, sending a MAIL FROM command for the
325   email address in FROM and RCPT commands for all receipients in TO,
326   which is expected to be a list.
327
328   If any of the TO addresses is not accepted, a RCPT-FAILED condition
329   is signalled.  This condition may be handled by the caller in order
330   to send the email anyway."
331  (smtp-command stream
332                (format nil "MAIL FROM:<~A>" from)
333                250)
334  (dolist (address to)
335    (restart-case
336        (smtp-command stream (format nil "RCPT TO:<~A>" address)
337                      250
338                      :condition-class 'rcpt-failed
339                      :condition-arguments (list :recipient address))
340      (ignore-recipient ())))
341  (smtp-command stream "DATA"
342                354))
343
344(defun finish-smtp-mail (stream)
345  "Finish sending an email to the SMTP server connected to on STREAM.
346   The server is expected to be inside of the DATA SMTP command.  The
347   connection is then terminated by sending a QUIT command."
348  (fresh-line stream)
349  (smtp-command stream "." 250)
350  (ignore-errors
351    ;; For now, we ignore errors that are signalled after the mail has
352    ;; successfully been sent.  Some servers close the connection
353    ;; after having received the QUIT command without properly saying
354    ;; good bye.
355    (smtp-command stream "QUIT" 221)))
356
357(defun send-mail-headers (stream
358                          &key from to cc reply-to
359                          extra-headers display-name subject)
360  "Send email headers according to the given arguments to the SMTP
361   server connected to on STREAM.  The server is expected to have
362   previously accepted the DATA SMTP command."
363  (write-to-smtp stream (format nil "Date: ~A" (get-email-date-string)))
364  (write-to-smtp stream (format nil "From: ~@[~A <~]~A~@[>~]"
365                                display-name from display-name))
366  (write-to-smtp stream (format nil "To: ~{ ~a~^,~}" to))
367  (when cc
368    (write-to-smtp stream (format nil "Cc: ~{ ~a~^,~}" cc)))
369  (write-to-smtp stream (format nil "Subject: ~A" subject))
370  (write-to-smtp stream (format nil "X-Mailer: cl-smtp ~A"
371                                *x-mailer*))
372  (when reply-to
373    (write-to-smtp stream (format nil "Reply-To: ~A" reply-to)))
374  (when (and extra-headers
375             (listp extra-headers))
376    (dolist (l extra-headers)
377      (write-to-smtp stream
378                     (format nil "~A: ~{~a~^,~}" (car l) (rest l)))))
379  (write-to-smtp stream "Mime-Version: 1.0"))
380
381(defun send-multipart-headers (stream &key attachment-boundary html-boundary)
382  (cond (attachment-boundary
383         (generate-multipart-header stream attachment-boundary
384                                    :multipart-type "mixed"))
385        (html-boundary (generate-multipart-header
386                        stream html-boundary
387                        :multipart-type "alternative"))
388        (t nil))
389  (write-blank-line stream))
390
391(defun write-to-smtp (stream command)
392  (print-debug (format nil "to server: ~A" command))
393  (write-string command stream)
394  (write-char #\Return stream)
395  (write-char #\NewLine stream)
396  (force-output stream))
397
398(defun write-blank-line (stream)
399  (write-char #\Return stream)
400  (write-char #\NewLine stream)
401  (force-output stream))
402
403(defun read-from-smtp (stream &optional lines)
404  (let* ((line (read-line stream))
405         (response (string-trim '(#\Return #\NewLine) (subseq line 4)))
406         (response-code (parse-integer line :start 0 :junk-allowed t)))
407    (print-debug (format nil "from server: ~A" line))
408    (if (= (char-code (elt line 3)) (char-code #\-))
409        (read-from-smtp stream (append lines (list response)))
410        (values response-code response lines))))
411
412(defun get-email-date-string ()
413  (multiple-value-bind (sec min h d m y wd) (get-decoded-time)
414    (let* ((month (elt '("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec") (- m 1)))
415           (weekday (elt '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun") wd))
416           (timezone (get-timezone-from-integer
417                      (- (encode-universal-time sec min h d m y 0)
418                         (get-universal-time)))))
419      (format nil "~A, ~2,'0d ~A ~d ~2,'0d:~2,'0d:~2,'0d ~D"
420              weekday d month y h min sec timezone))))
421
422(defun get-timezone-from-integer (x)
423  (let ((min (/ x 60))
424        (hour (/ x 3600)))
425    (if (integerp hour)
426        (cond
427          ((>= hour 0)
428           (format nil "+~2,'0d00" hour))
429          ((< hour 0)
430          (format nil "-~2,'0d00" (* -1 hour))))
431        (multiple-value-bind (h m) (truncate min 60)
432          (cond
433          ((>= hour 0)
434           (format nil "+~2,'0d~2,'0d" h (truncate m)))
435          ((< hour 0)
436           (format nil "-~2,'0d~2,'0d" (* -1 h) (* -1 (truncate m)))))))))
Note: See TracBrowser for help on using the browser.