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

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

EmailtoTracScript?:

email2trac.py.in:

  • Added support for version 0.10

ChangeLog?:

  • See Changelog
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 17.4 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 68 2006-05-16 09:13:13Z 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 parse(self, fp):
306                msg = email.message_from_file(fp)
307                if not msg:
308                        return
309
310                if self.DEBUG > 1:        # save the entire e-mail message text
311                        self.save_email_for_debug(msg)
312
313                self.db = self.env.get_db_cnx()
314                tkt = Ticket(self.env)
315                tkt['status'] = 'new'
316
317                if self.get_config('notification', 'smtp_enabled') in ['true']:
318                        self.notification = 1
319                else:
320                        self.notification = 0
321
322                # Some defaults
323                #
324                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
325                tkt['priority'] = self.get_config('ticket', 'default_priority')
326                tkt['severity'] = self.get_config('ticket', 'default_severity')
327                tkt['version'] = self.get_config('ticket', 'default_version')
328
329                if not msg['Subject']:
330                        tkt['summary'] = '(geen subject)'
331                else:
332                        tkt['summary'] = self.to_unicode(msg['Subject'])
333
334
335                if settings.has_key('component'):
336                        tkt['component'] = settings['component']
337                else:
338                        tkt['component'] = self.spam(msg)
339
340                # Must make this an option or so, discard SPAM messages or save then
341                # and delete later
342                #
343                #if self.SPAM_LEVEL and self.spam(msg):
344                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
345                #       sys.exit(1)
346
347                # Set default owner for component
348                #
349                self.set_owner(tkt)
350                author, email_addr = self.set_reply_fields(tkt, msg)
351
352                # produce e-mail like header
353                #
354                head = ''
355                if self.EMAIL_HEADER > 0:
356                        head = self.email_header_txt(msg)
357
358                if self.DEBUG > 0:
359                        self.debug_attachments(msg)
360
361                self.description(msg,tkt, head, author, email_addr)
362
363                # Insert ticket in database
364                #
365                if self.VERSION == 0.8:
366                        tkt['id'] = tkt.insert(self.db)
367                else:
368                        tkt['id'] = tkt.insert()
369
370                #
371                # Just how to show to update description
372                #
373                #tkt['description'] = '\n{{{\n\n Bas is op nieuw bezig\n\n }}}\n'
374                #tkt.save_changes(self.db, author, "Lekker bezig")
375                #
376
377                self.attachments(msg, tkt, author)
378                if self.notification:
379                        self.notify(tkt)
380
381
382        def description(self, msg, tkt, head, author, email):
383                """
384                put the message text in the ticket description
385                message text can be plain text or html or something else
386                """
387                has_description = 0
388                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
389                for part in msg.walk():
390
391                        # 'multipart/*' is a container for multipart messages
392                        #
393                        if part.get_content_maintype() == 'multipart':
394                                continue
395
396                        if part.get_content_type() == 'text/plain':
397                                # Try to decode, if fails then do not decode
398                                #
399                                body_text = part.get_payload(decode=1)         
400                                if not body_text:                       
401                                        body_text = part.get_payload(decode=0) 
402
403                                # Get contents charset (iso-8859-15 if not defined in mail headers)
404                                # UTF-8 encode body_text
405                                #
406                                charset = msg.get_content_charset('iso-8859-15')
407                                ubody_text = unicode(body_text, charset).encode('utf-8')
408
409                                tkt['description'] = '\n{{{\n\n%s\n}}}\n' %(ubody_text)
410
411                        elif part.get_content_type() == 'text/html':
412                                tkt['description'] = '%s\n\n(see attachment for HTML mail message)\n' \
413                                        %(head)
414                                body_text = tkt['description']
415
416                        else:
417                                tkt['description'] = '%s\n\n(see attachment for message)\n' %(head)
418                                body_text = tkt['description']
419
420                        has_description = 1
421                        break           # we have the description, so break
422
423                if not has_description:
424                        tkt['description'] = '%s\n\n(no plain text message, see attachments)' % head
425                        has_description = 1
426
427                if self.MAILTO:
428                        mailto = self.html_mailto_link(author, email, self.to_unicode(msg['subject']), ubody_text)
429                        tkt['description'] = '%s\n%s %s' %(head, mailto, tkt['description'])
430
431
432        def notify(self, tkt):
433                try:
434                        # create false {abs_}href properties, to trick Notify()
435                        #
436                        self.env.abs_href = Href(self.get_config('project', 'url'))
437                        self.env.href = Href(self.get_config('project', 'url'))
438
439                        tn = TicketNotifyEmail(self.env)
440                        if self.notify_template:
441                                tn.template_name = self.notify_template;
442
443                        tn.notify(tkt, newticket=True)
444
445                except Exception, e:
446                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
447                                % (tkt['id'], e)
448
449        def mail_line(self, str):
450                return '%s %s' % (self.comment, str)
451
452
453        def html_mailto_link(self, author, mail_addr, subject, body):
454                if not author:
455                        author = mail_addr
456                else:   
457                        author = self.to_unicode(author)
458
459                # Must find a fix
460                #
461                #arr = string.split(body, '\n')
462                #arr = map(self.mail_line, arr)
463                #body = string.join(arr, '\n')
464                #body = '%s wrote:\n%s' %(author, body)
465
466                # Temporary fix
467                str = 'mailto:%s?subject=%s' %(urllib.quote(mail_addr), urllib.quote('Re: %s' % subject))
468                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
469
470                return str
471
472        def attachments(self, message, ticket, user):
473                '''save any attachments as file in the ticket's directory'''
474
475                count = 0
476                first = 0
477                for part in message.walk():
478                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
479                                continue
480
481                        if not first:                                                                           # first content is the message
482                                first = 1
483                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
484                                        continue
485
486                        filename = part.get_filename()
487                        if not filename:
488                                count = count + 1
489                                filename = 'part%04d' % count
490
491                                ext = mimetypes.guess_extension(part.get_type())
492                                if not ext:
493                                        ext = '.bin'
494
495                                filename = '%s%s' % (filename, ext)
496                        else:
497                                filename = self.to_unicode(filename)
498
499                        # From the trac code
500                        #
501                        filename = filename.replace('\\', '/').replace(':', '/')
502                        filename = os.path.basename(filename)
503
504                        # We try to normalize the filename to utf-8 NFC if we can.
505                        # Files uploaded from OS X might be in NFD.
506                        #
507                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
508                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
509
510                        url_filename = urllib.quote(filename)
511                        if self.VERSION == 0.8:
512                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
513                                                        urllib.quote(str(ticket['id'])))
514                                if not os.path.exists(dir):
515                                        mkdir_p(dir, 0755)
516                        else:
517                                dir = '/tmp'
518
519                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
520                        text = part.get_payload(decode=1)
521                        if not text:
522                                text = '(None)'
523                        fd.write(text)
524                        fd.close()
525
526                        # get the filesize
527                        #
528                        stats = os.lstat(path)
529                        filesize = stats[stat.ST_SIZE]
530
531                        # Insert the attachment it differs for the different TRAC versions
532                        #
533                        if self.VERSION == 0.8:
534                                cursor = self.db.cursor()
535                                cursor.execute('INSERT INTO attachment VALUES("%s","%s","%s",%d,%d,"%s","%s","%s")'
536                                        %('ticket', urllib.quote(str(ticket['id'])), filename + '?format=raw', filesize,
537                                          int(time.time()),'', user, 'e-mail') )
538                                self.db.commit()
539                        else:
540                                fd = open(path)
541                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
542                                att.insert(url_filename, fd, filesize)
543                                fd.close()
544
545
546def mkdir_p(dir, mode):
547        '''do a mkdir -p'''
548
549        arr = string.split(dir, '/')
550        path = ''
551        for part in arr:
552                path = '%s/%s' % (path, part)
553                try:
554                        stats = os.stat(path)
555                except OSError:
556                        os.mkdir(path, mode)
557
558
559def ReadConfig(file, name):
560        """
561        Parse the config file
562        """
563
564        if not os.path.isfile(file):
565                print 'File %s does not exists' %file
566                sys.exit(1)
567
568        config = ConfigParser.ConfigParser()
569        try:
570                config.read(file)
571        except ConfigParser.MissingSectionHeaderError,detail:
572                print detail
573                sys.exit(1)
574
575
576        # Use given project name else use defaults
577        #
578        if name:
579                if not config.has_section(name):
580                        print "Not an valid project name: %s" %name
581                        print "Valid names: %s" %config.sections()
582                        sys.exit(1)
583
584                project =  dict()
585                for option in  config.options(name):
586                        project[option] = config.get(name, option)
587
588        else:
589                project = config.defaults()
590
591        return project
592
593if __name__ == '__main__':
594        # Default config file
595        #
596        configfile = '@email2trac_conf@'
597        project = ''
598        component = ''
599       
600        try:
601                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
602        except getopt.error,detail:
603                print __doc__
604                print detail
605                sys.exit(1)
606
607        project_name = None
608        for opt,value in opts:
609                if opt in [ '-h', '--help']:
610                        print __doc__
611                        sys.exit(0)
612                elif opt in ['-c', '--component']:
613                        component = value
614                elif opt in ['-f', '--file']:
615                        configfile = value
616                elif opt in ['-p', '--project']:
617                        project_name = value
618
619        settings = ReadConfig(configfile, project_name)
620        if not settings.has_key('project'):
621                print __doc__
622                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
623                sys.exit(1)
624
625        if component:
626                settings['component'] = component
627
628        if settings.has_key('trac_version'):
629                version = float(settings['trac_version'])
630        else:
631                version = trac_default_version
632
633        #debug HvB
634        #print settings
635
636        if version == 0.8:
637                from trac.Environment import Environment
638                from trac.Ticket import Ticket
639                from trac.Notify import TicketNotifyEmail
640                from trac.Href import Href
641                from trac import util
642        elif version == 0.9:
643                from trac import attachment
644                from trac.env import Environment
645                from trac.ticket import Ticket
646                from trac.web.href import Href
647                from trac import util
648                from trac.Notify import TicketNotifyEmail
649        elif version == 0.10:
650                from trac import attachment
651                from trac.env import Environment
652                from trac.ticket import Ticket
653                from trac.web.href import Href
654                from trac import util
655                # see http://projects.edgewall.com/trac/changeset/2799
656                from trac.ticket.notification import TicketNotifyEmail
657
658        env = Environment(settings['project'], create=0)
659        tktparser = TicketEmailParser(env, settings, version)
660        tktparser.parse(sys.stdin)
661
662# EOB
Note: See TracBrowser for help on using the repository browser.