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