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

Revision 2855, 17.8 kB (checked in by hans, 5 months ago)

update cl-smtp from trunk

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   (smtp-command stream "QUIT" 221))
351
352 (defun send-mail-headers (stream
353                           &key from to cc reply-to
354                           extra-headers display-name subject)
355   "Send email headers according to the given arguments to the SMTP
356    server connected to on STREAM.  The server is expected to have
357    previously accepted the DATA SMTP command."
358   (write-to-smtp stream (format nil "Date: ~A" (get-email-date-string)))
359   (write-to-smtp stream (format nil "From: ~@[~A <~]~A~@[>~]"
360                                 display-name from display-name))
361   (write-to-smtp stream (format nil "To: ~{ ~a~^,~}" to))
362   (when cc
363     (write-to-smtp stream (format nil "Cc: ~{ ~a~^,~}" cc)))
364   (write-to-smtp stream (format nil "Subject: ~A" subject))
365   (write-to-smtp stream (format nil "X-Mailer: cl-smtp ~A"
366                                 *x-mailer*))
367   (when reply-to
368     (write-to-smtp stream (format nil "Reply-To: ~A" reply-to)))
369   (when (and extra-headers
370              (listp extra-headers))
371     (dolist (l extra-headers)
372       (write-to-smtp stream
373                      (format nil "~A: ~{~a~^,~}" (car l) (rest l)))))
374   (write-to-smtp stream "Mime-Version: 1.0"))
375
376 (defun send-multipart-headers (stream &key attachment-boundary html-boundary)
377   (cond (attachment-boundary
378          (generate-multipart-header stream attachment-boundary
379                                     :multipart-type "mixed"))
380         (html-boundary (generate-multipart-header
381                         stream html-boundary
382                         :multipart-type "alternative"))
383         (t nil)))
384
385 (defun write-to-smtp (stream command)
386   (print-debug (format nil "to server: ~A" command))
387   (write-string command stream)
388   (write-char #\Return stream)
389   (write-char #\NewLine stream)
390   (force-output stream))
391
392 (defun write-blank-line (stream)
393   (write-char #\Return stream)
394   (write-char #\NewLine stream)
395   (force-output stream))
396
397 (defun read-from-smtp (stream &optional lines)
398   (let* ((line (read-line stream))
399          (response (string-trim '(#\Return #\NewLine) (subseq line 4)))
400          (response-code (parse-integer line :start 0 :junk-allowed t)))
401     (print-debug (format nil "from server: ~A" line))
402     (if (= (char-code (elt line 3)) (char-code #\-))
403         (read-from-smtp stream (append lines (list response)))
404         (values response-code response lines))))
405
406 (defun get-email-date-string ()
407   (multiple-value-bind (sec min h d m y wd) (get-decoded-time)
408     (let* ((month (elt '("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec") (- m 1)))
409            (weekday (elt '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun") wd))
410            (timezone (get-timezone-from-integer
411                       (- (encode-universal-time sec min h d m y 0)
412                          (get-universal-time)))))
413       (format nil "~A, ~2,'0d ~A ~d ~2,'0d:~2,'0d:~2,'0d ~D"
414               weekday d month y h min sec timezone))))
415
416 (defun get-timezone-from-integer (x)
417   (let ((min (/ x 60))
418         (hour (/ x 3600)))
419     (if (integerp hour)
420         (cond
421           ((>= hour 0)
422            (format nil "+~2,'0d00" hour))
423           ((< hour 0)
424           (format nil "-~2,'0d00" (* -1 hour))))
425         (multiple-value-bind (h m) (truncate min 60)
426           (cond
427           ((>= hour 0)
428            (format nil "+~2,'0d~2,'0d" h (truncate m)))
429           ((< hour 0)
430            (format nil "-~2,'0d~2,'0d" (* -1 h) (* -1 (truncate m)))))))))
Note: See TracBrowser for help on using the browser.