1 """Sending Roundup-specific mail over SMTP.
2 """
3 __docformat__ = 'restructuredtext'
4 # $Id: mailer.py,v 1.22 2008-07-21 01:44:58 richard Exp $
6 import time, quopri, os, socket, smtplib, re, sys, traceback
8 from cStringIO import StringIO
9 from MimeWriter import MimeWriter
11 from roundup.rfc2822 import encode_header
12 from roundup import __version__
13 from roundup.date import get_timezone
15 try:
16 from email.Utils import formatdate
17 except ImportError:
18 def formatdate():
19 return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime())
21 class MessageSendError(RuntimeError):
22 pass
24 class Mailer:
25 """Roundup-specific mail sending."""
26 def __init__(self, config):
27 self.config = config
29 # set to indicate to roundup not to actually _send_ email
30 # this var must contain a file to write the mail to
31 self.debug = os.environ.get('SENDMAILDEBUG', '') \
32 or config["MAIL_DEBUG"]
34 # set timezone so that things like formatdate(localtime=True)
35 # use the configured timezone
36 # apparently tzset doesn't exist in python under Windows, my bad.
37 # my pathetic attempts at googling a Windows-solution failed
38 # so if you're on Windows your mail won't use your configured
39 # timezone.
40 if hasattr(time, 'tzset'):
41 os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
42 time.tzset()
44 def get_standard_message(self, to, subject, author=None):
45 '''Form a standard email message from Roundup.
47 "to" - recipients list
48 "subject" - Subject
49 "author" - (name, address) tuple or None for admin email
51 Subject and author are encoded using the EMAIL_CHARSET from the
52 config (default UTF-8).
54 Returns a Message object and body part writer.
55 '''
56 # encode header values if they need to be
57 charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
58 tracker_name = self.config.TRACKER_NAME
59 if charset != 'utf-8':
60 tracker = unicode(tracker_name, 'utf-8').encode(charset)
61 if not author:
62 author = straddr((tracker_name, self.config.ADMIN_EMAIL))
63 else:
64 name = author[0]
65 if charset != 'utf-8':
66 name = unicode(name, 'utf-8').encode(charset)
67 author = straddr((encode_header(name, charset), author[1]))
69 message = StringIO()
70 writer = MimeWriter(message)
71 writer.addheader('Subject', encode_header(subject, charset))
72 writer.addheader('To', ', '.join(to))
73 writer.addheader('From', author)
74 writer.addheader('Date', formatdate(localtime=True))
76 # add a Precedence header so autoresponders ignore us
77 writer.addheader('Precedence', 'bulk')
79 # Add a unique Roundup header to help filtering
80 writer.addheader('X-Roundup-Name', encode_header(tracker_name,
81 charset))
82 # and another one to avoid loops
83 writer.addheader('X-Roundup-Loop', 'hello')
84 # finally, an aid to debugging problems
85 writer.addheader('X-Roundup-Version', __version__)
87 writer.addheader('MIME-Version', '1.0')
89 return message, writer
91 def standard_message(self, to, subject, content, author=None):
92 """Send a standard message.
94 Arguments:
95 - to: a list of addresses usable by rfc822.parseaddr().
96 - subject: the subject as a string.
97 - content: the body of the message as a string.
98 - author: the sender as a (name, address) tuple
99 """
100 message, writer = self.get_standard_message(to, subject, author)
102 writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
103 body = writer.startbody('text/plain; charset=utf-8')
104 content = StringIO(content)
105 quopri.encode(content, body, 0)
107 self.smtp_send(to, message)
109 def bounce_message(self, bounced_message, to, error,
110 subject='Failed issue tracker submission'):
111 """Bounce a message, attaching the failed submission.
113 Arguments:
114 - bounced_message: an RFC822 Message object.
115 - to: a list of addresses usable by rfc822.parseaddr(). Might be
116 extended or overridden according to the config
117 ERROR_MESSAGES_TO setting.
118 - error: the reason of failure as a string.
119 - subject: the subject as a string.
121 """
122 # see whether we should send to the dispatcher or not
123 dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
124 getattr(self.config, "ADMIN_EMAIL"))
125 error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
126 if error_messages_to == "dispatcher":
127 to = [dispatcher_email]
128 elif error_messages_to == "both":
129 to.append(dispatcher_email)
131 message, writer = self.get_standard_message(to, subject)
133 part = writer.startmultipartbody('mixed')
134 part = writer.nextpart()
135 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
136 body = part.startbody('text/plain; charset=utf-8')
137 body.write(quopri.encodestring ('\n'.join(error)))
139 # attach the original message to the returned message
140 part = writer.nextpart()
141 part.addheader('Content-Disposition', 'attachment')
142 part.addheader('Content-Description', 'Message you sent')
143 body = part.startbody('text/plain')
145 for header in bounced_message.headers:
146 body.write(header)
147 body.write('\n')
148 try:
149 bounced_message.rewindbody()
150 except IOError, message:
151 body.write("*** couldn't include message body: %s ***"
152 % bounced_message)
153 else:
154 body.write(bounced_message.fp.read())
156 writer.lastpart()
158 try:
159 self.smtp_send(to, message)
160 except MessageSendError:
161 # squash mail sending errors when bouncing mail
162 # TODO this *could* be better, as we could notify admin of the
163 # problem (even though the vast majority of bounce errors are
164 # because of spam)
165 pass
167 def exception_message(self):
168 '''Send a message to the admins with information about the latest
169 traceback.
170 '''
171 subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
172 to = [self.config.ADMIN_EMAIL]
173 content = '\n'.join(traceback.format_exception(*sys.exc_info()))
174 self.standard_message(to, subject, content)
176 def smtp_send(self, to, message):
177 """Send a message over SMTP, using roundup's config.
179 Arguments:
180 - to: a list of addresses usable by rfc822.parseaddr().
181 - message: a StringIO instance with a full message.
182 """
183 if self.debug:
184 # don't send - just write to a file
185 open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
186 (self.config.ADMIN_EMAIL,
187 ', '.join(to),
188 message.getvalue()))
189 else:
190 # now try to send the message
191 try:
192 # send the message as admin so bounces are sent there
193 # instead of to roundup
194 smtp = SMTPConnection(self.config)
195 smtp.sendmail(self.config.ADMIN_EMAIL, to,
196 message.getvalue())
197 except socket.error, value:
198 raise MessageSendError("Error: couldn't send email: "
199 "mailhost %s"%value)
200 except smtplib.SMTPException, msg:
201 raise MessageSendError("Error: couldn't send email: %s"%msg)
203 class SMTPConnection(smtplib.SMTP):
204 ''' Open an SMTP connection to the mailhost specified in the config
205 '''
206 def __init__(self, config):
207 smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
208 local_hostname=config['MAIL_LOCAL_HOSTNAME'])
210 # start the TLS if requested
211 if config["MAIL_TLS"]:
212 self.starttls(config["MAIL_TLS_KEYFILE"],
213 config["MAIL_TLS_CERTFILE"])
215 # ok, now do we also need to log in?
216 mailuser = config["MAIL_USERNAME"]
217 if mailuser:
218 self.login(mailuser, config["MAIL_PASSWORD"])
220 # use the 'email' module, either imported, or our copied version
221 try :
222 from email.Utils import formataddr as straddr
223 except ImportError :
224 # code taken from the email package 2.4.3
225 def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
226 escapesre = re.compile(r'[][\()"]')):
227 name, address = pair
228 if name:
229 quotes = ''
230 if specialsre.search(name):
231 quotes = '"'
232 name = escapesre.sub(r'\\\g<0>', name)
233 return '%s%s%s <%s>' % (quotes, name, quotes, address)
234 return address
236 # vim: set et sts=4 sw=4 :