Commit | Line | Data |
---|---|---|
c638d827 CR |
1 | """Sending Roundup-specific mail over SMTP. |
2 | """ | |
3 | __docformat__ = 'restructuredtext' | |
4 | ||
5 | import time, quopri, os, socket, smtplib, re, sys, traceback, email | |
6 | ||
7 | from cStringIO import StringIO | |
8 | ||
9 | from roundup import __version__ | |
10 | from roundup.date import get_timezone | |
11 | ||
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 | |
17 | ||
18 | class MessageSendError(RuntimeError): | |
19 | pass | |
20 | ||
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' | |
27 | ||
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() | |
38 | ||
39 | # the important bits of formataddr() | |
40 | if specialsre.search(encname): | |
41 | encname = '"%s"'%escapesre.sub(r'\\\g<0>', encname) | |
42 | ||
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) | |
46 | ||
47 | class Mailer: | |
48 | """Roundup-specific mail sending.""" | |
49 | def __init__(self, config): | |
50 | self.config = config | |
51 | ||
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"] | |
56 | ||
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() | |
66 | ||
67 | def get_standard_message(self, to, subject, author=None, multipart=False): | |
68 | '''Form a standard email message from Roundup. | |
69 | ||
70 | "to" - recipients list | |
71 | "subject" - Subject | |
72 | "author" - (name, address) tuple or None for admin email | |
73 | ||
74 | Subject and author are encoded using the EMAIL_CHARSET from the | |
75 | config (default UTF-8). | |
76 | ||
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) | |
88 | ||
89 | if multipart: | |
90 | message = MIMEMultipart() | |
91 | else: | |
92 | message = MIMEText("") | |
93 | message.set_charset(charset) | |
94 | ||
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) | |
102 | ||
103 | # add a Precedence header so autoresponders ignore us | |
104 | message['Precedence'] = 'bulk' | |
105 | ||
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) | |
111 | ||
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__ | |
116 | ||
117 | return message | |
118 | ||
119 | def standard_message(self, to, subject, content, author=None): | |
120 | """Send a standard message. | |
121 | ||
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 | |
127 | ||
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()) | |
134 | ||
135 | def bounce_message(self, bounced_message, to, error, | |
136 | subject='Failed issue tracker submission'): | |
137 | """Bounce a message, attaching the failed submission. | |
138 | ||
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. | |
146 | ||
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) | |
156 | ||
157 | message = self.get_standard_message(to, subject, multipart=True) | |
158 | ||
159 | # add the error text | |
160 | part = MIMEText('\n'.join(error)) | |
161 | message.attach(part) | |
162 | ||
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) | |
177 | ||
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 | |
187 | ||
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) | |
196 | ||
197 | def smtp_send(self, to, message, sender=None): | |
198 | """Send a message over SMTP, using roundup's config. | |
199 | ||
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 | """ | |
206 | ||
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) | |
226 | ||
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']) | |
233 | ||
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"]) | |
239 | ||
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"]) | |
244 | ||
245 | # vim: set et sts=4 sw=4 : |