1 """Sending Roundup-specific mail over SMTP.
2 """
3 __docformat__ = 'restructuredtext'
5 import time, quopri, os, socket, smtplib, re, sys, traceback, email
7 from cStringIO import StringIO
9 from roundup import __version__
10 from roundup.date import get_timezone
12 from email.Utils import formatdate, formataddr, specialsre, escapesre
13 from email.Message import Message
14 from email.Header import Header
15 from email.MIMEText import MIMEText
16 from email.MIMEMultipart import MIMEMultipart
18 class MessageSendError(RuntimeError):
19 pass
21 def encode_quopri(msg):
22 orig = msg.get_payload()
23 encdata = quopri.encodestring(orig)
24 msg.set_payload(encdata)
25 del msg['Content-Transfer-Encoding']
26 msg['Content-Transfer-Encoding'] = 'quoted-printable'
28 def nice_sender_header(name, address, charset):
29 # construct an address header so it's as human-readable as possible
30 # even in the presence of a non-ASCII name part
31 if not name:
32 return address
33 try:
34 encname = name.encode('ASCII')
35 except UnicodeEncodeError:
36 # use Header to encode correctly.
37 encname = Header(name, charset=charset).encode()
39 # the important bits of formataddr()
40 if specialsre.search(encname):
41 encname = '"%s"'%escapesre.sub(r'\\\g<0>', encname)
43 # now use Header again to wrap the line if necessary
44 return '%s <%s>'%(encname, address)
46 class Mailer:
47 """Roundup-specific mail sending."""
48 def __init__(self, config):
49 self.config = config
51 # set to indicate to roundup not to actually _send_ email
52 # this var must contain a file to write the mail to
53 self.debug = os.environ.get('SENDMAILDEBUG', '') \
54 or config["MAIL_DEBUG"]
56 # set timezone so that things like formatdate(localtime=True)
57 # use the configured timezone
58 # apparently tzset doesn't exist in python under Windows, my bad.
59 # my pathetic attempts at googling a Windows-solution failed
60 # so if you're on Windows your mail won't use your configured
61 # timezone.
62 if hasattr(time, 'tzset'):
63 os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
64 time.tzset()
66 def get_standard_message(self, to, subject, author=None, multipart=False):
67 '''Form a standard email message from Roundup.
69 "to" - recipients list
70 "subject" - Subject
71 "author" - (name, address) tuple or None for admin email
73 Subject and author are encoded using the EMAIL_CHARSET from the
74 config (default UTF-8).
76 Returns a Message object.
77 '''
78 # encode header values if they need to be
79 charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
80 tracker_name = unicode(self.config.TRACKER_NAME, 'utf-8')
81 if not author:
82 author = (tracker_name, self.config.ADMIN_EMAIL)
83 name = author[0]
84 else:
85 name = unicode(author[0], 'utf-8')
86 author = nice_sender_header(name, author[1], charset)
88 if multipart:
89 message = MIMEMultipart()
90 else:
91 message = MIMEText("")
92 message.set_charset(charset)
94 try:
95 message['Subject'] = subject.encode('ascii')
96 except UnicodeError:
97 message['Subject'] = Header(subject, charset)
98 message['To'] = ', '.join(to)
99 message['From'] = author
100 message['Date'] = formatdate(localtime=True)
102 # add a Precedence header so autoresponders ignore us
103 message['Precedence'] = 'bulk'
105 # Add a unique Roundup header to help filtering
106 try:
107 message['X-Roundup-Name'] = tracker_name.encode('ascii')
108 except UnicodeError:
109 message['X-Roundup-Name'] = Header(tracker_name, charset)
111 # and another one to avoid loops
112 message['X-Roundup-Loop'] = 'hello'
113 # finally, an aid to debugging problems
114 message['X-Roundup-Version'] = __version__
116 return message
118 def standard_message(self, to, subject, content, author=None):
119 """Send a standard message.
121 Arguments:
122 - to: a list of addresses usable by rfc822.parseaddr().
123 - subject: the subject as a string.
124 - content: the body of the message as a string.
125 - author: the sender as a (name, address) tuple
127 All strings are assumed to be UTF-8 encoded.
128 """
129 message = self.get_standard_message(to, subject, author)
130 message.set_payload(content)
131 encode_quopri(message)
132 self.smtp_send(to, message.as_string())
134 def bounce_message(self, bounced_message, to, error,
135 subject='Failed issue tracker submission'):
136 """Bounce a message, attaching the failed submission.
138 Arguments:
139 - bounced_message: an RFC822 Message object.
140 - to: a list of addresses usable by rfc822.parseaddr(). Might be
141 extended or overridden according to the config
142 ERROR_MESSAGES_TO setting.
143 - error: the reason of failure as a string.
144 - subject: the subject as a string.
146 """
147 # see whether we should send to the dispatcher or not
148 dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
149 getattr(self.config, "ADMIN_EMAIL"))
150 error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
151 if error_messages_to == "dispatcher":
152 to = [dispatcher_email]
153 elif error_messages_to == "both":
154 to.append(dispatcher_email)
156 message = self.get_standard_message(to, subject, multipart=True)
158 # add the error text
159 part = MIMEText('\n'.join(error))
160 message.attach(part)
162 # attach the original message to the returned message
163 body = []
164 for header in bounced_message.headers:
165 body.append(header)
166 try:
167 bounced_message.rewindbody()
168 except IOError, errmessage:
169 body.append("*** couldn't include message body: %s ***" %
170 errmessage)
171 else:
172 body.append('\n')
173 body.append(bounced_message.fp.read())
174 part = MIMEText(''.join(body))
175 message.attach(part)
177 # send
178 try:
179 self.smtp_send(to, message.as_string())
180 except MessageSendError:
181 # squash mail sending errors when bouncing mail
182 # TODO this *could* be better, as we could notify admin of the
183 # problem (even though the vast majority of bounce errors are
184 # because of spam)
185 pass
187 def exception_message(self):
188 '''Send a message to the admins with information about the latest
189 traceback.
190 '''
191 subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
192 to = [self.config.ADMIN_EMAIL]
193 content = '\n'.join(traceback.format_exception(*sys.exc_info()))
194 self.standard_message(to, subject, content)
196 def smtp_send(self, to, message, sender=None):
197 """Send a message over SMTP, using roundup's config.
199 Arguments:
200 - to: a list of addresses usable by rfc822.parseaddr().
201 - message: a StringIO instance with a full message.
202 - sender: if not 'None', the email address to use as the
203 envelope sender. If 'None', the admin email is used.
204 """
206 if not sender:
207 sender = self.config.ADMIN_EMAIL
208 if self.debug:
209 # don't send - just write to a file
210 open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
211 (sender,
212 ', '.join(to), message))
213 else:
214 # now try to send the message
215 try:
216 # send the message as admin so bounces are sent there
217 # instead of to roundup
218 smtp = SMTPConnection(self.config)
219 smtp.sendmail(sender, to, message)
220 except socket.error, value:
221 raise MessageSendError("Error: couldn't send email: "
222 "mailhost %s"%value)
223 except smtplib.SMTPException, msg:
224 raise MessageSendError("Error: couldn't send email: %s"%msg)
226 class SMTPConnection(smtplib.SMTP):
227 ''' Open an SMTP connection to the mailhost specified in the config
228 '''
229 def __init__(self, config):
230 smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
231 local_hostname=config['MAIL_LOCAL_HOSTNAME'])
233 # start the TLS if requested
234 if config["MAIL_TLS"]:
235 self.ehlo()
236 self.starttls(config["MAIL_TLS_KEYFILE"],
237 config["MAIL_TLS_CERTFILE"])
239 # ok, now do we also need to log in?
240 mailuser = config["MAIL_USERNAME"]
241 if mailuser:
242 self.login(mailuser, config["MAIL_PASSWORD"])
244 # vim: set et sts=4 sw=4 :