Code

Extracted duplicated mail-sending code from mailgw, roundupdb and client.py to
[roundup.git] / roundup / roundupdb.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: roundupdb.py,v 1.89 2003-09-08 09:28:28 jlgijsbers Exp $
20 __doc__ = """
21 Extending hyperdb with types specific to issue-tracking.
22 """
24 import re, os, smtplib, socket, time, random
25 import cStringIO, base64, quopri, mimetypes
27 from rfc2822 import encode_header
29 from roundup import password, date, hyperdb
31 # MessageSendError is imported for backwards compatibility
32 from roundup.mailer import Mailer, straddr, MessageSendError
34 class Database:
35     def getuid(self):
36         """Return the id of the "user" node associated with the user
37         that owns this connection to the hyperdatabase."""
38         return self.user.lookup(self.journaltag)
40     def getUserTimezone(self):
41         """Return user timezone defined in 'timezone' property of user class.
42         If no such property exists return 0
43         """
44         userid = self.getuid()
45         try:
46             timezone = int(self.user.get(userid, 'timezone'))
47         except (KeyError, ValueError, TypeError):
48             # If there is no class 'user' or current user doesn't have timezone 
49             # property or that property is not numeric assume he/she lives in 
50             # Greenwich :)
51             timezone = 0
52         return timezone
54     def figure_curuserid(self):
55         """Figure out the 'curuserid'."""
56         if self.journaltag is None:
57             self.curuserid = None
58         elif self.journaltag == 'admin':
59             # admin user may not exist, but always has ID 1
60             self.curuserid = '1'
61         else:
62             self.curuserid = self.user.lookup(self.journaltag)
64     def confirm_registration(self, otk):
65         props = self.otks.getall(otk)
66         for propname, proptype in self.user.getprops().items():
67             value = props.get(propname, None)
68             if value is None:
69                 pass
70             elif isinstance(proptype, hyperdb.Date):
71                 props[propname] = date.Date(value)
72             elif isinstance(proptype, hyperdb.Interval):
73                 props[propname] = date.Interval(value)
74             elif isinstance(proptype, hyperdb.Password):
75                 props[propname] = password.Password()
76                 props[propname].unpack(value)
78         # tag new user creation with 'admin'
79         self.journaltag = 'admin'
80         self.figure_curuserid()
82         # create the new user
83         cl = self.user
84       
85         props['roles'] = self.config.NEW_WEB_USER_ROLES
86         del props['__time']
87         userid = cl.create(**props)
88         # clear the props from the otk database
89         self.otks.destroy(otk)
90         self.commit()
91         
92         return userid
95 class DetectorError(RuntimeError):
96     """ Raised by detectors that want to indicate that something's amiss
97     """
98     pass
100 # deviation from spec - was called IssueClass
101 class IssueClass:
102     """ This class is intended to be mixed-in with a hyperdb backend
103         implementation. The backend should provide a mechanism that
104         enforces the title, messages, files, nosy and superseder
105         properties:
106             properties['title'] = hyperdb.String(indexme='yes')
107             properties['messages'] = hyperdb.Multilink("msg")
108             properties['files'] = hyperdb.Multilink("file")
109             properties['nosy'] = hyperdb.Multilink("user")
110             properties['superseder'] = hyperdb.Multilink(classname)
111     """
113     # New methods:
114     def addmessage(self, nodeid, summary, text):
115         """Add a message to an issue's mail spool.
117         A new "msg" node is constructed using the current date, the user that
118         owns the database connection as the author, and the specified summary
119         text.
121         The "files" and "recipients" fields are left empty.
123         The given text is saved as the body of the message and the node is
124         appended to the "messages" field of the specified issue.
125         """
127     # XXX "bcc" is an optional extra here...
128     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
129             from_address=None, cc=[]): #, bcc=[]):
130         """Send a message to the members of an issue's nosy list.
132         The message is sent only to users on the nosy list who are not
133         already on the "recipients" list for the message.
134         
135         These users are then added to the message's "recipients" list.
137         """
138         users = self.db.user
139         messages = self.db.msg
141         # figure the recipient ids
142         sendto = []
143         r = {}
144         recipients = messages.get(msgid, 'recipients')
145         for recipid in messages.get(msgid, 'recipients'):
146             r[recipid] = 1
148         # figure the author's id, and indicate they've received the message
149         authid = messages.get(msgid, 'author')
151         # possibly send the message to the author, as long as they aren't
152         # anonymous
153         if (users.get(authid, 'username') != 'anonymous' and
154                 not r.has_key(authid)):
155             if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
156                 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues)):
157                 # make sure they have an address
158                 add = users.get(authid, 'address')
159                 if add:
160                     # send it to them
161                     sendto.append(add)
162                     recipients.append(authid)
164         r[authid] = 1
166         # now deal with cc people.
167         for cc_userid in cc :
168             if r.has_key(cc_userid):
169                 continue
170             # make sure they have an address
171             add = users.get(cc_userid, 'address')
172             if add:
173                 # send it to them
174                 sendto.append(add)
175                 recipients.append(cc_userid)
177         # now figure the nosy people who weren't recipients
178         nosy = self.get(nodeid, whichnosy)
179         for nosyid in nosy:
180             # Don't send nosy mail to the anonymous user (that user
181             # shouldn't appear in the nosy list, but just in case they
182             # do...)
183             if users.get(nosyid, 'username') == 'anonymous':
184                 continue
185             # make sure they haven't seen the message already
186             if not r.has_key(nosyid):
187                 # make sure they have an address
188                 add = users.get(nosyid, 'address')
189                 if add:
190                     # send it to them
191                     sendto.append(add)
192                     recipients.append(nosyid)
194         # generate a change note
195         if oldvalues:
196             note = self.generateChangeNote(nodeid, oldvalues)
197         else:
198             note = self.generateCreateNote(nodeid)
200         # we have new recipients
201         if sendto:
202             # update the message's recipients list
203             messages.set(msgid, recipients=recipients)
205             # send the message
206             self.send_message(nodeid, msgid, note, sendto, from_address)
208     # backwards compatibility - don't remove
209     sendmessage = nosymessage
211     def send_message(self, nodeid, msgid, note, sendto, from_address=None):
212         '''Actually send the nominated message from this node to the sendto
213            recipients, with the note appended.
214         '''
215         users = self.db.user
216         messages = self.db.msg
217         files = self.db.file
219         # determine the messageid and inreplyto of the message
220         inreplyto = messages.get(msgid, 'inreplyto')
221         messageid = messages.get(msgid, 'messageid')
223         # make up a messageid if there isn't one (web edit)
224         if not messageid:
225             # this is an old message that didn't get a messageid, so
226             # create one
227             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
228                 self.classname, nodeid, self.db.config.MAIL_DOMAIN)
229             messages.set(msgid, messageid=messageid)
231         # send an email to the people who missed out
232         cn = self.classname
233         title = self.get(nodeid, 'title') or '%s message copy'%cn
234         # figure author information
235         authid = messages.get(msgid, 'author')
236         authname = users.get(authid, 'realname')
237         if not authname:
238             authname = users.get(authid, 'username')
239         authaddr = users.get(authid, 'address')
240         if authaddr:
241             authaddr = " <%s>" % straddr( ('',authaddr) )
242         else:
243             authaddr = ''
245         # make the message body
246         m = ['']
248         # put in roundup's signature
249         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
250             m.append(self.email_signature(nodeid, msgid))
252         # add author information
253         if len(self.get(nodeid,'messages')) == 1:
254             m.append("New submission from %s%s:"%(authname, authaddr))
255         else:
256             m.append("%s%s added the comment:"%(authname, authaddr))
257         m.append('')
259         # add the content
260         m.append(messages.get(msgid, 'content'))
262         # add the change note
263         if note:
264             m.append(note)
266         # put in roundup's signature
267         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
268             m.append(self.email_signature(nodeid, msgid))
270         # encode the content as quoted-printable
271         content = cStringIO.StringIO('\n'.join(m))
272         content_encoded = cStringIO.StringIO()
273         quopri.encode(content, content_encoded, 0)
274         content_encoded = content_encoded.getvalue()
276         # get the files for this message
277         message_files = messages.get(msgid, 'files')
279         # make sure the To line is always the same (for testing mostly)
280         sendto.sort()
282         # make sure we have a from address
283         if from_address is None:
284             from_address = self.db.config.TRACKER_EMAIL
286         # additional bit for after the From: "name"
287         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
288         if from_tag:
289             from_tag = ' ' + from_tag
291         subject = '[%s%s] %s' % (cn, nodeid, encode_header(title))
292         author = straddr((encode_header(authname) + from_tag, from_address))
294         # create the message
295         mailer = Mailer(self.db.config)
296         message, writer = mailer.get_standard_message(', '.join(sendto),
297                                                       subject, author)
299         tracker_name = encode_header(self.db.config.TRACKER_NAME)
300         writer.addheader('Reply-To', straddr((tracker_name, from_address)))
301         if messageid:
302             writer.addheader('Message-Id', messageid)
303         if inreplyto:
304             writer.addheader('In-Reply-To', inreplyto)
306         # attach files
307         if message_files:
308             part = writer.startmultipartbody('mixed')
309             part = writer.nextpart()
310             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
311             body = part.startbody('text/plain; charset=utf-8')
312             body.write(content_encoded)
313             for fileid in message_files:
314                 name = files.get(fileid, 'name')
315                 mime_type = files.get(fileid, 'type')
316                 content = files.get(fileid, 'content')
317                 part = writer.nextpart()
318                 if mime_type == 'text/plain':
319                     part.addheader('Content-Disposition',
320                         'attachment;\n filename="%s"'%name)
321                     part.addheader('Content-Transfer-Encoding', '7bit')
322                     body = part.startbody('text/plain')
323                     body.write(content)
324                 else:
325                     # some other type, so encode it
326                     if not mime_type:
327                         # this should have been done when the file was saved
328                         mime_type = mimetypes.guess_type(name)[0]
329                     if mime_type is None:
330                         mime_type = 'application/octet-stream'
331                     part.addheader('Content-Disposition',
332                         'attachment;\n filename="%s"'%name)
333                     part.addheader('Content-Transfer-Encoding', 'base64')
334                     body = part.startbody(mime_type)
335                     body.write(base64.encodestring(content))
336             writer.lastpart()
337         else:
338             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
339             body = writer.startbody('text/plain; charset=utf-8')
340             body.write(content_encoded)
342         mailer.smtp_send(sendto, message)
344     def email_signature(self, nodeid, msgid):
345         ''' Add a signature to the e-mail with some useful information
346         '''
347         # simplistic check to see if the url is valid,
348         # then append a trailing slash if it is missing
349         base = self.db.config.TRACKER_WEB 
350         if (not isinstance(base , type('')) or
351             not (base.startswith('http://') or base.startswith('https://'))):
352             base = "Configuration Error: TRACKER_WEB isn't a " \
353                 "fully-qualified URL"
354         elif base[-1] != '/' :
355             base += '/'
356         web = base + self.classname + nodeid
358         # ensure the email address is properly quoted
359         email = straddr((self.db.config.TRACKER_NAME,
360             self.db.config.TRACKER_EMAIL))
362         line = '_' * max(len(web)+2, len(email))
363         return '%s\n%s\n<%s>\n%s'%(line, email, web, line)
366     def generateCreateNote(self, nodeid):
367         """Generate a create note that lists initial property values
368         """
369         cn = self.classname
370         cl = self.db.classes[cn]
371         props = cl.getprops(protected=0)
373         # list the values
374         m = []
375         l = props.items()
376         l.sort()
377         for propname, prop in l:
378             value = cl.get(nodeid, propname, None)
379             # skip boring entries
380             if not value:
381                 continue
382             if isinstance(prop, hyperdb.Link):
383                 link = self.db.classes[prop.classname]
384                 if value:
385                     key = link.labelprop(default_to_id=1)
386                     if key:
387                         value = link.get(value, key)
388                 else:
389                     value = ''
390             elif isinstance(prop, hyperdb.Multilink):
391                 if value is None: value = []
392                 l = []
393                 link = self.db.classes[prop.classname]
394                 key = link.labelprop(default_to_id=1)
395                 if key:
396                     value = [link.get(entry, key) for entry in value]
397                 value.sort()
398                 value = ', '.join(value)
399             m.append('%s: %s'%(propname, value))
400         m.insert(0, '----------')
401         m.insert(0, '')
402         return '\n'.join(m)
404     def generateChangeNote(self, nodeid, oldvalues):
405         """Generate a change note that lists property changes
406         """
407         if __debug__ :
408             if not isinstance(oldvalues, type({})) :
409                 raise TypeError("'oldvalues' must be dict-like, not %s."%
410                     type(oldvalues))
412         cn = self.classname
413         cl = self.db.classes[cn]
414         changed = {}
415         props = cl.getprops(protected=0)
417         # determine what changed
418         for key in oldvalues.keys():
419             if key in ['files','messages']:
420                 continue
421             if key in ('activity', 'creator', 'creation'):
422                 continue
423             new_value = cl.get(nodeid, key)
424             # the old value might be non existent
425             try:
426                 old_value = oldvalues[key]
427                 if type(new_value) is type([]):
428                     new_value.sort()
429                     old_value.sort()
430                 if new_value != old_value:
431                     changed[key] = old_value
432             except:
433                 changed[key] = new_value
435         # list the changes
436         m = []
437         l = changed.items()
438         l.sort()
439         for propname, oldvalue in l:
440             prop = props[propname]
441             value = cl.get(nodeid, propname, None)
442             if isinstance(prop, hyperdb.Link):
443                 link = self.db.classes[prop.classname]
444                 key = link.labelprop(default_to_id=1)
445                 if key:
446                     if value:
447                         value = link.get(value, key)
448                     else:
449                         value = ''
450                     if oldvalue:
451                         oldvalue = link.get(oldvalue, key)
452                     else:
453                         oldvalue = ''
454                 change = '%s -> %s'%(oldvalue, value)
455             elif isinstance(prop, hyperdb.Multilink):
456                 change = ''
457                 if value is None: value = []
458                 if oldvalue is None: oldvalue = []
459                 l = []
460                 link = self.db.classes[prop.classname]
461                 key = link.labelprop(default_to_id=1)
462                 # check for additions
463                 for entry in value:
464                     if entry in oldvalue: continue
465                     if key:
466                         l.append(link.get(entry, key))
467                     else:
468                         l.append(entry)
469                 if l:
470                     l.sort()
471                     change = '+%s'%(', '.join(l))
472                     l = []
473                 # check for removals
474                 for entry in oldvalue:
475                     if entry in value: continue
476                     if key:
477                         l.append(link.get(entry, key))
478                     else:
479                         l.append(entry)
480                 if l:
481                     l.sort()
482                     change += ' -%s'%(', '.join(l))
483             else:
484                 change = '%s -> %s'%(oldvalue, value)
485             m.append('%s: %s'%(propname, change))
486         if m:
487             m.insert(0, '----------')
488             m.insert(0, '')
489         return '\n'.join(m)
491 # vim: set filetype=python ts=4 sw=4 et si