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

Last change on this file since 71 was 71, checked in by bas, 16 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
Line 
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"""
21email2trac.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        [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
102                if self.VERSION == 0.8:
103                        self.get_config = self.env.get_config
104                else:
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
133                if parameters.has_key('alternate_notify_template'):
134                        self.notify_template = str(parameters['alternate_notify_template'])
135                else:
136                        self.notify_template = None
137
138                if parameters.has_key('reply_all'):
139                        self.REPLY_ALL = int(parameters['reply_all'])
140                else:
141                        self.REPLY_ALL = 0
142
143
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:
152                                return 'Spam'
153
154                elif message.has_key('X-Virus-found'):                  # treat virus mails as spam
155                        return 'Spam'
156
157                return self.get_config('ticket', 'default_component')
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
237        def set_owner(self, ticket):
238                """
239                Select default owner for ticket component
240                """
241                cursor = self.db.cursor()
242                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
243                cursor.execute(sql)
244                try:
245                        ticket['owner'] = cursor.fetchone()[0]
246                except TypeError, detail:
247                        ticket['owner'] = "UNKNOWN"
248
249        def set_reply_fields(self, ticket, message):
250                """
251                Bla Bla
252                """
253                author, email_addr  = email.Utils.parseaddr(message['from'])
254                email_str = self.to_unicode(message['from'])
255
256                # Look for email address in registered trac users
257                #
258                if self.VERSION == 0.8:
259                        users = []
260                else:
261                        users = [ u for (u, n, e) in self.env.get_known_users(self.db)
262                                if e == email_addr ]
263
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
270                #
271                if self.REPLY_ALL:
272                        #tos = message.get_all('to', [])
273                        ccs = message.get_all('cc', [])
274
275                        addrs = email.Utils.getaddresses(ccs)
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
286                        for name,mail in addrs:
287                                        try:
288                                                ticket['cc'] = '%s,%s' %(ticket['cc'], mail)
289                                        except:
290                                                ticket['cc'] = mail
291                return author, email_addr
292
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
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
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
337                        self.save_email_for_debug(msg)
338
339                self.db = self.env.get_db_cnx()
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               
348                tkt = Ticket(self.env)
349                tkt['status'] = 'new'
350
351                if self.get_config('notification', 'smtp_enabled') in ['true']:
352                        self.notification = 1
353                else:
354                        self.notification = 0
355
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
369                if settings.has_key('component'):
370                        tkt['component'] = settings['component']
371                else:
372                        tkt['component'] = self.spam(msg)
373
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
381                # Set default owner for component
382                #
383                self.set_owner(tkt)
384                author, email_addr = self.set_reply_fields(tkt, msg)
385
386                # produce e-mail like header
387                #
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
395                self.description(msg, tkt, head, author, email_addr)
396
397                # Insert ticket in database
398                #
399                if self.VERSION == 0.8:
400                        tkt['id'] = tkt.insert(self.db)
401                else:
402                        tkt['id'] = tkt.insert()
403
404                #
405                # Just how to show to update description
406                #
407                #tkt['summary'] = '#%s: %s' %(tkt['id'], tkt['summary'])
408                #tkt.save_changes(self.db, author, "")
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                """
420                has_description = 0
421                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
422                for part in msg.walk():
423
424                        # 'multipart/*' is a container for multipart messages
425                        #
426                        if part.get_content_maintype() == 'multipart':
427                                continue
428
429                        if part.get_content_type() == 'text/plain':
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) 
435
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')
441
442                                tkt['description'] = '\n{{{\n\n%s\n}}}\n' %(ubody_text)
443
444                        elif part.get_content_type() == 'text/html':
445                                tkt['description'] = '%s\n\n(see attachment for HTML mail message)\n' \
446                                        %(head)
447                                body_text = tkt['description']
448
449                        else:
450                                tkt['description'] = '%s\n\n(see attachment for message)\n' %(head)
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:
461                        mailto = self.html_mailto_link(author, email, self.to_unicode(msg['subject']), ubody_text)
462                        tkt['description'] = '%s\n%s %s' %(head, mailto, tkt['description'])
463
464
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'))
471
472                        tn = TicketNotifyEmail(self.env)
473                        if self.notify_template:
474                                tn.template_name = self.notify_template;
475
476                        tn.notify(tkt, newticket=True)
477
478                except Exception, e:
479                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
480                                % (tkt['id'], e)
481
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
500                str = 'mailto:%s?subject=%s' %(urllib.quote(mail_addr), urllib.quote('Re: %s' % subject))
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
532                        # From the trac code
533                        #
534                        filename = filename.replace('\\', '/').replace(':', '/')
535                        filename = os.path.basename(filename)
536
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
543                        url_filename = urllib.quote(filename)
544                        if self.VERSION == 0.8:
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)
549                        else:
550                                dir = '/tmp'
551
552                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
553                        text = part.get_payload(decode=1)
554                        if not text:
555                                text = '(None)'
556                        fd.write(text)
557                        fd.close()
558
559                        # get the filesize
560                        #
561                        stats = os.lstat(path)
562                        filesize = stats[stat.ST_SIZE]
563
564                        # Insert the attachment it differs for the different TRAC versions
565                        #
566                        if self.VERSION == 0.8:
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()
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()
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        #
629        configfile = '@email2trac_conf@'
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
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:
676                from trac import attachment
677                from trac.env import Environment
678                from trac.ticket import Ticket
679                from trac.web.href import Href
680                from trac import util
681                from trac.Notify import TicketNotifyEmail
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
687                from trac import util
688                # see http://projects.edgewall.com/trac/changeset/2799
689                from trac.ticket.notification import TicketNotifyEmail
690
691        env = Environment(settings['project'], create=0)
692        tktparser = TicketEmailParser(env, settings, version)
693        tktparser.parse(sys.stdin)
694
695# EOB
Note: See TracBrowser for help on using the repository browser.