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