source: emailtotracscript/trunk/email2trac.py @ 13

Last change on this file since 13 was 10, checked in by bas, 18 years ago

EmailtoTracScript?:

email2trac.py:

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