source: emailtotracscript/trunk/email2trac.py.in @ 71

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

EmailtoTracScript?:

email2trac.py.in:

  • Starting with merging of tickets when a user replies via email

Makefile.in

  • replace all install commands with short options, compatible with MacOSX
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 17.9 KB
RevLine 
[22]1#!@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"""
[54]21email2trac.py -- Email tickets to Trac.
[22]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
[68]54    umask        : 022               # OPTIONAL, if set then use this umask for creation of the attachments
[22]55        mailto_link  : 1                 # OPTIONAL, if set then [mailto:<CC>] in description
56        trac_version : 0.8               # OPTIONAL, default is 0.9
57
58        [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
59        project      : /data/trac/jouvin # use -p|--project jouvin. 
60       
61 * default config file is : /etc/email2trac.conf
62
63 * Commandline opions:
64                -h | --help
65                -c <value> | --component=<value>
66                -f <config file> | --file=<config file>
67                -p <project name> | --project=<project name>
68
69SVN Info:
70        $Id: email2trac.py.in 71 2006-05-19 08:53:47Z bas $
71"""
72
73import os
74import sys
75import string
76import getopt
77import stat
78import time
79import email
80import re
81import urllib
82import unicodedata
83import ConfigParser
84from email import Header
85from stat import *
86import mimetypes
87
88trac_default_version = 0.9
89
90class TicketEmailParser(object):
91        env = None
92        comment = '> '
93   
94        def __init__(self, env, parameters, version):
95                self.env = env
96
97                # Database connection
98                #
99                self.db = None
100
101                self.VERSION = version
[68]102                if self.VERSION == 0.8:
103                        self.get_config = self.env.get_config
104                else:
[22]105                        self.get_config = self.env.config.get
106
107                if parameters.has_key('umask'):
108                        os.umask(int(parameters['umask'], 8))
109
110                if parameters.has_key('debug'):
111                        self.DEBUG = int(parameters['debug'])
112                else:
113                        self.DEBUG = 0
114
115                if parameters.has_key('mailto_link'):
116                        self.MAILTO = int(parameters['mailto_link'])
117                else:
118                        self.MAILTO = 0
119
120                if parameters.has_key('spam_level'):
121                        self.SPAM_LEVEL = int(parameters['spam_level'])
122                else:
123                        self.SPAM_LEVEL = 0
124
125                if parameters.has_key('email_comment'):
126                        self.comment = str(parameters['email_comment'])
127
128                if parameters.has_key('email_header'):
129                        self.EMAIL_HEADER = int(parameters['email_header'])
130                else:
131                        self.EMAIL_HEADER = 0
132
[42]133                if parameters.has_key('alternate_notify_template'):
134                        self.notify_template = str(parameters['alternate_notify_template'])
135                else:
136                        self.notify_template = None
[22]137
[43]138                if parameters.has_key('reply_all'):
139                        self.REPLY_ALL = int(parameters['reply_all'])
140                else:
141                        self.REPLY_ALL = 0
[42]142
[43]143
[22]144        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
145        # Note if Spam_level then '*' are included
146        def spam(self, message):
147                if message.has_key('X-Spam-Score'):
148                        spam_l = string.split(message['X-Spam-Score'])
149                        number = spam_l[0].count('*')
150
151                        if number >= self.SPAM_LEVEL:
[41]152                                return 'Spam'
[22]153
[67]154                elif message.has_key('X-Virus-found'):                  # treat virus mails as spam
155                        return 'Spam'
156
[41]157                return self.get_config('ticket', 'default_component')
[22]158
159        def to_unicode(self, str):
160                """
161                Email has 7 bit ASCII code, convert it to unicode with the charset
162                that is encoded in 7-bit ASCII code and encode it as utf-8 so TRAC
163                understands it.
164                """
165                results =  Header.decode_header(str)
166                str = None
167                for text,format in results:
168                        if format:
169                                try:
170                                        temp = unicode(text, format)
171                                except UnicodeError:
172                                        # This always works
173                                        #
174                                        temp = unicode(text, 'iso-8859-15')
175                                temp =  temp.encode('utf-8')
176                        else:
177                                temp = string.strip(text)
178
179                        if str:
180                                str = '%s %s' %(str, temp)
181                        else:
182                                str = temp
183
184                return str
185
186        def debug_attachments(self, message):
187                n = 0
188                for part in message.walk():
189                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
190                                print 'TD: multipart container'
191                                continue
192
193                        n = n + 1
194                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
195                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
196
197                        if part.is_multipart():
198                                print 'TD: this part is multipart'
199                                payload = part.get_payload(decode=1)
200                                print 'TD: payload:', payload
201                        else:
202                                print 'TD: this part is not multipart'
203
204                        part_file = '/var/tmp/part%d' % n
205                        print 'TD: writing part%d (%s)' % (n,part_file)
206                        fx = open(part_file, 'wb')
207                        text = part.get_payload(decode=1)
208                        if not text:
209                                text = '(None)'
210                        fx.write(text)
211                        fx.close()
212                        try:
213                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
214                        except OSError:
215                                pass
216
217        def email_header_txt(self, m):
218#               if not m['Subject']:
219#                       subject = '(geen subject)'
220#               else:
221#                       subject = self.to_unicode(m['Subject'])
222#
223#               head = "'''Subject:''' %s [[BR]]" % subject
224#               if m['From'] and len(m['From']) > 0:
225#                       head = "%s'''From:''' %s [[BR]]" % (head, m['From'])
226#               if m['Date'] and len(m['Date']) > 0:
227#                       head = "%s'''Date:''' %s [[BR]]" %(head, m['Date'])
228
229                str = ''
230                if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
231                        str = "'''To:''' %s [[BR]]" %(m['To'])
232                if m['Cc'] and len(m['Cc']) > 0:
233                        str = "%s'''Cc:''' %s [[BR]]" % (str, m['Cc'])
234
235                return str
236
[43]237        def set_owner(self, ticket):
[45]238                """
239                Select default owner for ticket component
240                """
[43]241                cursor = self.db.cursor()
242                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
243                cursor.execute(sql)
[61]244                try:
245                        ticket['owner'] = cursor.fetchone()[0]
246                except TypeError, detail:
247                        ticket['owner'] = "UNKNOWN"
[43]248
249        def set_reply_fields(self, ticket, message):
[45]250                """
251                Bla Bla
252                """
[43]253                author, email_addr  = email.Utils.parseaddr(message['from'])
254                email_str = self.to_unicode(message['from'])
255
[45]256                # Look for email address in registered trac users
257                #
[68]258                if self.VERSION == 0.8:
259                        users = []
260                else:
[45]261                        users = [ u for (u, n, e) in self.env.get_known_users(self.db)
262                                if e == email_addr ]
[43]263
[45]264                if len(users) == 1:
265                        ticket['reporter'] = users[0]
266                else:
267                        ticket['reporter'] = email_str
268
269                # Put all CC-addresses in ticket CC field
[43]270                #
271                if self.REPLY_ALL:
[45]272                        #tos = message.get_all('to', [])
[43]273                        ccs = message.get_all('cc', [])
274
[45]275                        addrs = email.Utils.getaddresses(ccs)
[43]276
277                        # Remove reporter email address if notification is
278                        # on
279                        #
280                        if self.notification:
281                                try:
282                                        addrs.remove((author, email_addr))
283                                except ValueError, detail:
284                                        pass
285
[45]286                        for name,mail in addrs:
287                                        try:
288                                                ticket['cc'] = '%s,%s' %(ticket['cc'], mail)
289                                        except:
290                                                ticket['cc'] = mail
[43]291                return author, email_addr
292
[44]293        def save_email_for_debug(self, message):
294
295                msg_file = '/var/tmp/msg.txt'
296                print 'TD: saving email to %s' % msg_file
297                fx = open(msg_file, 'wb')
298                fx.write('%s' % message)
299                fx.close()
300                try:
301                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
302                except OSError:
303                        pass
304
[71]305        def ticket_update(self, m):
306
307                if not m['Subject']:
308                        return False
309                else:
310                        subject  = self.to_unicode(m['Subject'])
311                print subject
312
313                TICKET_RE = re.compile(r"""
314                                        (?P<ticketnr>[#][0-9]+:)
315                                        """, re.VERBOSE)
316
317                result =  TICKET_RE.search(subject)
318                if not result:
319                        return False
320
321                # Strip # and : frim ticket_id
322                ticket_id = result.group('ticketnr')
323                ticket_id = int(ticket_id[1:-1])
324                print ticket_id
325
326                tkt = Ticket(self.db, ticket_id)
327                print tkt
328                return True
329
330
[22]331        def parse(self, fp):
332                msg = email.message_from_file(fp)
333                if not msg:
334                        return
335
336                if self.DEBUG > 1:        # save the entire e-mail message text
[44]337                        self.save_email_for_debug(msg)
[22]338
339                self.db = self.env.get_db_cnx()
[71]340                if self.ticket_update(msg):
341                        print 'Ticket has been updated now new one'
342                        sys.exit(0)
343                else:
344                        print 'New ticket'
345
346                sys.exit(0)
347               
[41]348                tkt = Ticket(self.env)
[22]349                tkt['status'] = 'new'
350
[43]351                if self.get_config('notification', 'smtp_enabled') in ['true']:
352                        self.notification = 1
[65]353                else:
354                        self.notification = 0
[43]355
[22]356                # Some defaults
357                #
358                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
359                tkt['priority'] = self.get_config('ticket', 'default_priority')
360                tkt['severity'] = self.get_config('ticket', 'default_severity')
361                tkt['version'] = self.get_config('ticket', 'default_version')
362
363                if not msg['Subject']:
364                        tkt['summary'] = '(geen subject)'
365                else:
366                        tkt['summary'] = self.to_unicode(msg['Subject'])
367
368
[41]369                if settings.has_key('component'):
[22]370                        tkt['component'] = settings['component']
371                else:
[41]372                        tkt['component'] = self.spam(msg)
[22]373
[38]374                # Must make this an option or so, discard SPAM messages or save then
375                # and delete later
376                #
377                #if self.SPAM_LEVEL and self.spam(msg):
378                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
379                #       sys.exit(1)
380
[43]381                # Set default owner for component
[22]382                #
[43]383                self.set_owner(tkt)
384                author, email_addr = self.set_reply_fields(tkt, msg)
[22]385
[45]386                # produce e-mail like header
387                #
[22]388                head = ''
389                if self.EMAIL_HEADER > 0:
390                        head = self.email_header_txt(msg)
391
392                if self.DEBUG > 0:
393                        self.debug_attachments(msg)
394
[71]395                self.description(msg, tkt, head, author, email_addr)
[45]396
397                # Insert ticket in database
398                #
[68]399                if self.VERSION == 0.8:
400                        tkt['id'] = tkt.insert(self.db)
401                else:
[45]402                        tkt['id'] = tkt.insert()
403
404                #
405                # Just how to show to update description
406                #
[71]407                #tkt['summary'] = '#%s: %s' %(tkt['id'], tkt['summary'])
408                #tkt.save_changes(self.db, author, "")
[45]409
410                self.attachments(msg, tkt, author)
411                if self.notification:
412                        self.notify(tkt)
413
414
415        def description(self, msg, tkt, head, author, email):
416                """
417                put the message text in the ticket description
418                message text can be plain text or html or something else
419                """
[22]420                has_description = 0
[62]421                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
[22]422                for part in msg.walk():
[45]423
424                        # 'multipart/*' is a container for multipart messages
425                        #
426                        if part.get_content_maintype() == 'multipart':
[22]427                                continue
428
429                        if part.get_content_type() == 'text/plain':
[45]430                                # Try to decode, if fails then do not decode
431                                #
432                                body_text = part.get_payload(decode=1)         
433                                if not body_text:                       
434                                        body_text = part.get_payload(decode=0) 
[22]435
[45]436                                # Get contents charset (iso-8859-15 if not defined in mail headers)
437                                # UTF-8 encode body_text
438                                #
439                                charset = msg.get_content_charset('iso-8859-15')
440                                ubody_text = unicode(body_text, charset).encode('utf-8')
[22]441
[45]442                                tkt['description'] = '\n{{{\n\n%s\n}}}\n' %(ubody_text)
443
[22]444                        elif part.get_content_type() == 'text/html':
[45]445                                tkt['description'] = '%s\n\n(see attachment for HTML mail message)\n' \
446                                        %(head)
[22]447                                body_text = tkt['description']
448
449                        else:
[45]450                                tkt['description'] = '%s\n\n(see attachment for message)\n' %(head)
[22]451                                body_text = tkt['description']
452
453                        has_description = 1
454                        break           # we have the description, so break
455
456                if not has_description:
457                        tkt['description'] = '%s\n\n(no plain text message, see attachments)' % head
458                        has_description = 1
459
460                if self.MAILTO:
[45]461                        mailto = self.html_mailto_link(author, email, self.to_unicode(msg['subject']), ubody_text)
[22]462                        tkt['description'] = '%s\n%s %s' %(head, mailto, tkt['description'])
463
464
[41]465        def notify(self, tkt):
466                try:
467                        # create false {abs_}href properties, to trick Notify()
468                        #
469                        self.env.abs_href = Href(self.get_config('project', 'url'))
470                        self.env.href = Href(self.get_config('project', 'url'))
[22]471
[41]472                        tn = TicketNotifyEmail(self.env)
[42]473                        if self.notify_template:
474                                tn.template_name = self.notify_template;
475
[41]476                        tn.notify(tkt, newticket=True)
477
478                except Exception, e:
[42]479                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
480                                % (tkt['id'], e)
[41]481
[22]482        def mail_line(self, str):
483                return '%s %s' % (self.comment, str)
484
485
486        def html_mailto_link(self, author, mail_addr, subject, body):
487                if not author:
488                        author = mail_addr
489                else:   
490                        author = self.to_unicode(author)
491
492                # Must find a fix
493                #
494                #arr = string.split(body, '\n')
495                #arr = map(self.mail_line, arr)
496                #body = string.join(arr, '\n')
497                #body = '%s wrote:\n%s' %(author, body)
498
499                # Temporary fix
[54]500                str = 'mailto:%s?subject=%s' %(urllib.quote(mail_addr), urllib.quote('Re: %s' % subject))
[22]501                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
502
503                return str
504
505        def attachments(self, message, ticket, user):
506                '''save any attachments as file in the ticket's directory'''
507
508                count = 0
509                first = 0
510                for part in message.walk():
511                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
512                                continue
513
514                        if not first:                                                                           # first content is the message
515                                first = 1
516                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
517                                        continue
518
519                        filename = part.get_filename()
520                        if not filename:
521                                count = count + 1
522                                filename = 'part%04d' % count
523
524                                ext = mimetypes.guess_extension(part.get_type())
525                                if not ext:
526                                        ext = '.bin'
527
528                                filename = '%s%s' % (filename, ext)
529                        else:
530                                filename = self.to_unicode(filename)
531
[48]532                        # From the trac code
533                        #
534                        filename = filename.replace('\\', '/').replace(':', '/')
535                        filename = os.path.basename(filename)
[22]536
[48]537                        # We try to normalize the filename to utf-8 NFC if we can.
538                        # Files uploaded from OS X might be in NFD.
539                        #
540                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
541                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
542
[22]543                        url_filename = urllib.quote(filename)
[68]544                        if self.VERSION == 0.8:
[22]545                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
546                                                        urllib.quote(str(ticket['id'])))
547                                if not os.path.exists(dir):
548                                        mkdir_p(dir, 0755)
[68]549                        else:
550                                dir = '/tmp'
[22]551
[48]552                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
[22]553                        text = part.get_payload(decode=1)
554                        if not text:
555                                text = '(None)'
[48]556                        fd.write(text)
557                        fd.close()
[22]558
559                        # get the filesize
560                        #
[48]561                        stats = os.lstat(path)
[22]562                        filesize = stats[stat.ST_SIZE]
563
564                        # Insert the attachment it differs for the different TRAC versions
565                        #
[68]566                        if self.VERSION == 0.8:
[22]567                                cursor = self.db.cursor()
568                                cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
569                                        %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
570                                          int(time.time()),'', user, 'e-mail') )
571                                self.db.commit()
[68]572                        else:
573                                fd = open(path)
574                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
575                                att.insert(url_filename, fd, filesize)
576                                fd.close()
[22]577
578
579def mkdir_p(dir, mode):
580        '''do a mkdir -p'''
581
582        arr = string.split(dir, '/')
583        path = ''
584        for part in arr:
585                path = '%s/%s' % (path, part)
586                try:
587                        stats = os.stat(path)
588                except OSError:
589                        os.mkdir(path, mode)
590
591
592def ReadConfig(file, name):
593        """
594        Parse the config file
595        """
596
597        if not os.path.isfile(file):
598                print 'File %s does not exists' %file
599                sys.exit(1)
600
601        config = ConfigParser.ConfigParser()
602        try:
603                config.read(file)
604        except ConfigParser.MissingSectionHeaderError,detail:
605                print detail
606                sys.exit(1)
607
608
609        # Use given project name else use defaults
610        #
611        if name:
612                if not config.has_section(name):
613                        print "Not an valid project name: %s" %name
614                        print "Valid names: %s" %config.sections()
615                        sys.exit(1)
616
617                project =  dict()
618                for option in  config.options(name):
619                        project[option] = config.get(name, option)
620
621        else:
622                project = config.defaults()
623
624        return project
625
626if __name__ == '__main__':
627        # Default config file
628        #
[24]629        configfile = '@email2trac_conf@'
[22]630        project = ''
631        component = ''
632       
633        try:
634                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
635        except getopt.error,detail:
636                print __doc__
637                print detail
638                sys.exit(1)
639
640        project_name = None
641        for opt,value in opts:
642                if opt in [ '-h', '--help']:
643                        print __doc__
644                        sys.exit(0)
645                elif opt in ['-c', '--component']:
646                        component = value
647                elif opt in ['-f', '--file']:
648                        configfile = value
649                elif opt in ['-p', '--project']:
650                        project_name = value
651
652        settings = ReadConfig(configfile, project_name)
653        if not settings.has_key('project'):
654                print __doc__
655                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
656                sys.exit(1)
657
658        if component:
659                settings['component'] = component
660
661        if settings.has_key('trac_version'):
662                version = float(settings['trac_version'])
663        else:
664                version = trac_default_version
665
666        #debug HvB
667        #print settings
668
[68]669        if version == 0.8:
670                from trac.Environment import Environment
671                from trac.Ticket import Ticket
672                from trac.Notify import TicketNotifyEmail
673                from trac.Href import Href
674                from trac import util
675        elif version == 0.9:
[41]676                from trac import attachment
677                from trac.env import Environment
678                from trac.ticket import Ticket
679                from trac.web.href import Href
[48]680                from trac import util
[41]681                from trac.Notify import TicketNotifyEmail
[68]682        elif version == 0.10:
683                from trac import attachment
684                from trac.env import Environment
685                from trac.ticket import Ticket
686                from trac.web.href import Href
[48]687                from trac import util
[68]688                # see http://projects.edgewall.com/trac/changeset/2799
689                from trac.ticket.notification import TicketNotifyEmail
[41]690
691        env = Environment(settings['project'], create=0)
[22]692        tktparser = TicketEmailParser(env, settings, version)
693        tktparser.parse(sys.stdin)
694
695# EOB
Note: See TracBrowser for help on using the repository browser.