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