source: emailtotracscript/0.9/email2trac.py @ 9

Last change on this file since 9 was 9, checked in by bas, 16 years ago

EmailtoTracScript?:

Makefile:

  • Added clean option
  • Changed some defaults

email2trac.py:

  • Updated documentation

INSTALL:

  • Changed the docs
File size: 14.6 KB
Line 
1#!/usr/bin/python
2# Copyright (C) 2002
3#
4# This file is part of the email2trac utils
5#
6# This program is free software; you can redistribute it and/or modify it
7# under the terms of the GNU General Public License as published by the
8# Free Software Foundation; either version 2, or (at your option) any
9# later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
19#
20"""
21emailfilter.py -- Email tickets to Trac.
22
23A simple MTA filter to create Trac tickets from inbound emails.
24
25Copyright 2005, Daniel Lundin <daniel@edgewall.com>
26Copyright 2005, Edgewall Software
27
28Changed By: Bas van der Vlies <basv@sara.nl>
29Date      : 13 September 2005
30Descr.    : Added config file and command line options, spam level
31            detection, reply address and mailto option. Unicode support
32
33Changed By: Walter de Jong <walter@sara.nl>
34Descr.    : multipart-message code and trac attachments
35
36
37The scripts reads emails from stdin and inserts directly into a Trac database.
38MIME headers are mapped as follows:
39
40        * From:      => Reporter
41                     => CC (Optional via reply_address option)
42        * Subject:   => Summary
43        * Body       => Description
44        * Component  => Can be set to SPAM via spam_level option
45
46How to use
47----------
48 * Create an config file:
49        [DEFAULT]                    # REQUIRED
50        project: /data/trac/test     # REQUIRED
51        debug: 1                     # OPTIONAL, if set print some DEBUG info
52        spam_level: 4                # OPTIONAL, if set check for SPAM mail
53        reply_address: 1             # OPTIONAL, if set then fill in ticket CC field
54        umask: 022                   # OPTIONAL, if set then use this umask for creation of the attachments
55        mailto_link: 1               # OPTIONAL, if set then [mailto:<CC>] in description
56       
57 * Configure script as a mail (pipe) filter with your MTA
58    typically, this involves adding a line like this to /etc/aliases:
59       somename: | /usr/sara/sbin/run_email2trac
60
61    Check your MTA's documentation for specifics.
62
63 * default config file is : /etc/email2trac.conf
64
65SVN Info:
66        $Id: email2trac.py 1523 2005-11-01 09:27:51Z bas $
67"""
68
69import os
70import sys
71import string
72import getopt
73import stat
74import time
75import email
76import re
77import urllib
78import unicodedata
79import ConfigParser
80from email import Header
81from stat import *
82import mimetypes
83
84trac_default_version = 0.9
85
86class TicketEmailParser(object):
87        env = None
88        comment = '> '
89   
90        def __init__(self, env, parameters, version):
91                self.env = env
92
93                # Database connection
94                #
95                self.db = None
96
97                self.VERSION = version
98                if self.VERSION > 0.8:
99                        self.new_ticket = ticket.Ticket
100                        self.get_config = self.env.config.get
101                else:
102                        self.new_ticket = Ticket.Ticket
103                        self.get_config = self.env.get_config
104
105                if parameters.has_key('umask'):
106                        os.umask(int(parameters['umask'], 8))
107
108                if parameters.has_key('debug'):
109                        self.DEBUG = int(parameters['debug'])
110                else:
111                        self.DEBUG = 0
112
113                if parameters.has_key('reply_address'):
114                        self.CC = int(parameters['reply_address'])
115                else:
116                        self.CC = 0
117
118                if parameters.has_key('mailto_link'):
119                        self.MAILTO = int(parameters['mailto_link'])
120                else:
121                        self.MAILTO = 0
122
123                if parameters.has_key('spam_level'):
124                        self.SPAM_LEVEL = int(parameters['spam_level'])
125                else:
126                        self.SPAM_LEVEL = 0
127
128                if parameters.has_key('email_comment'):
129                        self.comment = str(parameters['email_comment'])
130
131                if parameters.has_key('email_header'):
132                        self.EMAIL_HEADER = int(parameters['email_header'])
133                else:
134                        self.EMAIL_HEADER = 0
135
136
137        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
138        # Note if Spam_level then '*' are included
139        def spam(self, message):
140                if message.has_key('X-Spam-Score'):
141                        spam_l = string.split(message['X-Spam-Score'])
142                        number = spam_l[0].count('*')
143
144                        if number >= self.SPAM_LEVEL:
145                                return number
146
147                return 0
148
149        def to_unicode(self, str):
150                """
151                Email has 7 bit ASCII code, convert it to unicode with the charset
152                that is encoded in 7-bit ASCII code and encode it as utf-8 so TRAC
153                understands it.
154                """
155                results =  Header.decode_header(str)
156                str = None
157                for text,format in results:
158                        if format:
159                                try:
160                                        temp = unicode(text, format)
161                                except UnicodeError:
162                                        # This always works
163                                        #
164                                        temp = unicode(text, 'iso-8859-15')
165                                temp =  temp.encode('utf-8')
166                        else:
167                                temp = string.strip(text)
168
169                        if str:
170                                str = '%s %s' %(str, temp)
171                        else:
172                                str = temp
173
174                return str
175
176        def debug_attachments(self, message):
177                n = 0
178                for part in message.walk():
179                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
180                                print 'TD: multipart container'
181                                continue
182
183                        n = n + 1
184                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
185                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
186
187                        if part.is_multipart():
188                                print 'TD: this part is multipart'
189                                payload = part.get_payload(decode=1)
190                                print 'TD: payload:', payload
191                        else:
192                                print 'TD: this part is not multipart'
193
194                        part_file = '/var/tmp/part%d' % n
195                        print 'TD: writing part%d (%s)' % (n,part_file)
196                        fx = open(part_file, 'wb')
197                        text = part.get_payload(decode=1)
198                        if not text:
199                                text = '(None)'
200                        fx.write(text)
201                        fx.close()
202                        try:
203                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
204                        except OSError:
205                                pass
206
207        def email_header_txt(self, m):
208#               if not m['Subject']:
209#                       subject = '(geen subject)'
210#               else:
211#                       subject = self.to_unicode(m['Subject'])
212#
213#               head = "'''Subject:''' %s [[BR]]" % subject
214#               if m['From'] and len(m['From']) > 0:
215#                       head = "%s'''From:''' %s [[BR]]" % (head, m['From'])
216#               if m['Date'] and len(m['Date']) > 0:
217#                       head = "%s'''Date:''' %s [[BR]]" %(head, m['Date'])
218
219                str = ''
220                if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
221                        str = "'''To:''' %s [[BR]]" %(m['To'])
222                if m['Cc'] and len(m['Cc']) > 0:
223                        str = "%s'''Cc:''' %s [[BR]]" % (str, m['Cc'])
224
225                return str
226
227        def parse(self, fp):
228                msg = email.message_from_file(fp)
229                if not msg:
230                        return
231
232                if self.DEBUG > 1:        # save the entire e-mail message text
233                        msg_file = '/var/tmp/msg.txt' 
234                        print 'TD: saving email to %s' % msg_file
235                        fx = open(msg_file, 'wb')
236                        fx.write('%s' % msg)
237                        fx.close()
238                        try:
239                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
240                        except OSError:
241                                pass
242
243                self.db = self.env.get_db_cnx()
244                tkt = self.new_ticket(self.env)
245                tkt['status'] = 'new'
246
247                # Some defaults
248                #
249                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
250                tkt['priority'] = self.get_config('ticket', 'default_priority')
251                tkt['severity'] = self.get_config('ticket', 'default_severity')
252                tkt['version'] = self.get_config('ticket', 'default_version')
253
254                if not msg['Subject']:
255                        tkt['summary'] = '(geen subject)'
256                else:
257                        tkt['summary'] = self.to_unicode(msg['Subject'])
258
259                if self.SPAM_LEVEL and self.spam(msg):
260                        print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
261                        sys.exit(1)
262
263                if settings.has_key('component'):
264                        tkt['component'] = settings['component']
265                else:
266                        tkt['component'] = self.get_config('ticket', 'default_component')
267
268                # Get default owner for component
269                #
270                cursor = self.db.cursor()
271                sql = 'SELECT owner FROM component WHERE name=\'%s\'' % tkt['component']
272                cursor.execute(sql)
273                tkt['owner'] = cursor.fetchone()[0]
274
275                from_str = self.to_unicode(msg['from'])
276                tkt['reporter'] = from_str
277                if self.CC:
278                        tkt['cc'] = from_str
279
280# produce e-mail like header
281                head = ''
282                if self.EMAIL_HEADER > 0:
283                        head = self.email_header_txt(msg)
284
285                if self.DEBUG > 0:
286                        self.debug_attachments(msg)
287
288#
289#       put the message text in the ticket description
290#       message text can be plain text or html or something else
291#
292                has_description = 0
293                for part in msg.walk():
294                        if part.get_content_maintype() == 'multipart':                  # 'multipart/*' is a container for multipart messages
295                                continue
296
297                        if part.get_content_type() == 'text/plain':
298                                body_text = part.get_payload(decode=1)                  # try to decode
299                                if not body_text:                                       # decode failed
300                                        body_text = part.get_payload(decode=0)          # do not decode
301
302                                tkt['description'] = '\n{{{\n\n%s\n}}}\n' % body_text
303
304                        elif part.get_content_type() == 'text/html':
305                                tkt['description'] = '%s\n\n(see attachment for HTML mail message)\n' % head
306                                body_text = tkt['description']
307
308                        else:
309                                tkt['description'] = '%s\n\n(see attachment for message)\n' % head
310                                body_text = tkt['description']
311
312                        has_description = 1
313                        break           # we have the description, so break
314
315                if not has_description:
316                        tkt['description'] = '%s\n\n(no plain text message, see attachments)' % head
317                        has_description = 1
318
319                author, email_addr  = email.Utils.parseaddr(msg['from'])
320                if self.MAILTO:
321                        mailto = self.html_mailto_link(author, email_addr, self.to_unicode(msg['subject']), body_text)
322                        tkt['description'] = '%s\n%s %s' %(head, mailto, tkt['description'])
323
324                if self.VERSION > 0.8:
325                        tkt['id'] = tkt.insert()
326                else:
327                        tkt['id'] = tkt.insert(self.db)
328
329                #
330                # Just how to show to update description
331                #
332                #tkt['description'] = '\n{{{\n\n Bas is op nieuw bezig\n\n }}}\n'
333                #tkt.save_changes(self.db, author, "Lekker bezig")
334                #
335                self.attachments(msg, tkt, author)
336
337
338        def mail_line(self, str):
339                return '%s %s' % (self.comment, str)
340
341
342        def html_mailto_link(self, author, mail_addr, subject, body):
343                if not author:
344                        author = mail_addr
345                else:   
346                        author = self.to_unicode(author)
347
348                # Must find a fix
349                #
350                #arr = string.split(body, '\n')
351                #arr = map(self.mail_line, arr)
352
353                #body = string.join(arr, '\n')
354                #body = '%s wrote:\n%s' %(author, body)
355
356                # Obsolete for reference
357                #
358                #body = self.to_unicode(body)
359                #body = urllib.quote(body)
360                #body = Header.encode(body)
361                #
362
363                # Temporary fix
364                body = '> Type your reply'
365                str = 'mailto:%s?subject=%s&body=%s' % (urllib.quote(mail_addr), urllib.quote('Re: %s' % subject), urllib.quote(body))
366                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
367
368                return str
369
370        def attachments(self, message, ticket, user):
371                '''save any attachments as file in the ticket's directory'''
372
373                count = 0
374                first = 0
375                for part in message.walk():
376                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
377                                continue
378
379                        if not first:                                                                           # first content is the message
380                                first = 1
381                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
382                                        continue
383
384                        filename = part.get_filename() 
385                        if not filename:
386                                count = count + 1
387                                filename = 'part%04d' % count
388
389                                ext = mimetypes.guess_extension(part.get_type())
390                                if not ext:
391                                        ext = '.bin'
392
393                                filename = '%s%s' % (filename, ext)
394                        else:
395                                filename = self.to_unicode(filename)
396
397                        if '/' in filename:
398                                filename = os.path.basename(filename)
399
400                        url_filename = urllib.quote(filename)
401
402                        if self.VERSION > 0.8:
403                                tmpfile = '/tmp/email2trac-ticket%sattachment' % str(ticket['id'])
404                        else:
405                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket', 
406                                                        urllib.quote(str(ticket['id'])))
407                                if not os.path.exists(dir):
408                                        mkdir_p(dir, 0755)
409
410                                tmpfile = os.path.join(dir, url_filename)
411
412                        f = open(tmpfile, 'wb')
413                        text = part.get_payload(decode=1)
414                        if not text:
415                                text = '(None)'
416                        f.write(text)
417
418                        # get the filesize
419                        #
420                        stats = os.lstat(tmpfile) 
421                        filesize = stats[stat.ST_SIZE]
422
423                        # Insert the attachment it differs for the different TRAC versions
424                        #
425                        if self.VERSION > 0.8:
426                                att = attachment.Attachment(self.env,'ticket',ticket['id'])
427                                att.insert(url_filename,f,filesize)
428                                f.close()
429                        else:
430                                cursor = self.db.cursor()
431                                cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")' 
432                                        %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize, 
433                                          int(time.time()),'', user, 'e-mail') ) 
434                                self.db.commit()
435
436
437def mkdir_p(dir, mode):
438        '''do a mkdir -p'''
439
440        arr = string.split(dir, '/')
441        path = ''
442        for part in arr:
443                path = '%s/%s' % (path, part)
444                try:
445                        stats = os.stat(path)
446                except OSError:
447                        os.mkdir(path, mode)
448
449
450def ReadConfig(file):
451        """
452        Parse the config file
453        """
454
455        if not os.path.isfile(file):
456                print 'File %s does not exists' %file
457                sys.exit(1)
458
459        config = ConfigParser.ConfigParser()
460
461        try:
462          config.read(file)
463        except ConfigParser.MissingSectionHeaderError,detail:
464          print detail
465          sys.exit(1)
466
467        defaults = config.defaults()
468        if not defaults.has_key('project') and  not defaults.has_key('project_root'):
469                print 'You have to define the location of your trac project'
470                print 'or the root of your projects, eg:'
471                print '\t project: /var/trac/<projectname>'
472                print '\t project_root: /var/trac'
473                sys.exit(1)
474         
475        return defaults
476
477
478if __name__ == '__main__':
479        # Default config file
480        #
481        configfile = '/etc/email2trac.conf'
482        project = ''
483        component = ''
484       
485        try:
486                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
487        except getopt.error,detail:
488                print __doc__
489                print detail
490                sys.exit(1)
491
492        for opt,value in opts:
493                if opt in [ '-h', '--help']:
494                        print __doc__
495                        sys.exit(0)
496                elif opt in ['-c', '--component']:
497                        component = value
498                elif opt in ['-f', '--file']:
499                        configfile = value
500                elif opt in ['-p', '--project']:
501                        project = value
502
503        settings = ReadConfig(configfile)
504
505        if not settings.has_key('project') and not project:
506                print 'You must specify a project either in configuration file'
507                print 'or with option --project'
508                sys.exit(1)
509               
510        if project:
511                if settings.has_key('project_root') :
512                        settings['project'] = os.path.join(settings['project_root'], project)
513                else:
514                        print 'You must specify a project root in configuration file'
515                        print 'in order to use option --project'
516                        sys.exit(1)
517        else:
518                if not re.search("/",settings['project']):
519                        if settings.has_key('project_root') :
520                                settings['project'] = os.path.join(settings['project_root'], settings['project'])
521                        else:
522                                print 'You must specify a project root in configuration file'
523                                print 'or specify the project full path'
524                                sys.exit(1)
525
526        if component:
527                settings['component'] = component
528
529        if settings.has_key('trac_version'):
530                version = float(settings['trac_version'])
531        else:
532                version = trac_default_version
533
534        if version > 0.8:
535                from trac import attachment, config, env, ticket
536                env = env.Environment(settings['project'], create=0)
537                ticket_mod = ticket
538        else:
539                from trac import Environment, Ticket
540                env = Environment.Environment(settings['project'], create=0)
541                ticket_mod = Ticket
542               
543        tktparser = TicketEmailParser(env, settings, version)
544        tktparser.parse(sys.stdin)
545
546# EOB
Note: See TracBrowser for help on using the repository browser.