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

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

EmailtoTracScript?:

email2trac.py.in:

  • ubody_text was not initialises Reported by: Greg Deakyne
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 16.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 62 2006-04-10 14:41:51Z 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.config.get
104                else:
105                        self.get_config = self.env.get_config
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                return self.get_config('ticket', 'default_component')
155
156        def to_unicode(self, str):
157                """
158                Email has 7 bit ASCII code, convert it to unicode with the charset
159                that is encoded in 7-bit ASCII code and encode it as utf-8 so TRAC
160                understands it.
161                """
162                results =  Header.decode_header(str)
163                str = None
164                for text,format in results:
165                        if format:
166                                try:
167                                        temp = unicode(text, format)
168                                except UnicodeError:
169                                        # This always works
170                                        #
171                                        temp = unicode(text, 'iso-8859-15')
172                                temp =  temp.encode('utf-8')
173                        else:
174                                temp = string.strip(text)
175
176                        if str:
177                                str = '%s %s' %(str, temp)
178                        else:
179                                str = temp
180
181                return str
182
183        def debug_attachments(self, message):
184                n = 0
185                for part in message.walk():
186                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
187                                print 'TD: multipart container'
188                                continue
189
190                        n = n + 1
191                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
192                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
193
194                        if part.is_multipart():
195                                print 'TD: this part is multipart'
196                                payload = part.get_payload(decode=1)
197                                print 'TD: payload:', payload
198                        else:
199                                print 'TD: this part is not multipart'
200
201                        part_file = '/var/tmp/part%d' % n
202                        print 'TD: writing part%d (%s)' % (n,part_file)
203                        fx = open(part_file, 'wb')
204                        text = part.get_payload(decode=1)
205                        if not text:
206                                text = '(None)'
207                        fx.write(text)
208                        fx.close()
209                        try:
210                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
211                        except OSError:
212                                pass
213
214        def email_header_txt(self, m):
215#               if not m['Subject']:
216#                       subject = '(geen subject)'
217#               else:
218#                       subject = self.to_unicode(m['Subject'])
219#
220#               head = "'''Subject:''' %s [[BR]]" % subject
221#               if m['From'] and len(m['From']) > 0:
222#                       head = "%s'''From:''' %s [[BR]]" % (head, m['From'])
223#               if m['Date'] and len(m['Date']) > 0:
224#                       head = "%s'''Date:''' %s [[BR]]" %(head, m['Date'])
225
226                str = ''
227                if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
228                        str = "'''To:''' %s [[BR]]" %(m['To'])
229                if m['Cc'] and len(m['Cc']) > 0:
230                        str = "%s'''Cc:''' %s [[BR]]" % (str, m['Cc'])
231
232                return str
233
234        def set_owner(self, ticket):
235                """
236                Select default owner for ticket component
237                """
238                cursor = self.db.cursor()
239                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
240                cursor.execute(sql)
241                try:
242                        ticket['owner'] = cursor.fetchone()[0]
243                except TypeError, detail:
244                        ticket['owner'] = "UNKNOWN"
245
246        def set_reply_fields(self, ticket, message):
247                """
248                Bla Bla
249                """
250                author, email_addr  = email.Utils.parseaddr(message['from'])
251                email_str = self.to_unicode(message['from'])
252
253                # Look for email address in registered trac users
254                #
255                if self.VERSION > 0.8:
256                        users = [ u for (u, n, e) in self.env.get_known_users(self.db)
257                                if e == email_addr ]
258                else:
259                        users = []
260
261                if len(users) == 1:
262                        ticket['reporter'] = users[0]
263                else:
264                        ticket['reporter'] = email_str
265
266                # Put all CC-addresses in ticket CC field
267                #
268                if self.REPLY_ALL:
269                        #tos = message.get_all('to', [])
270                        ccs = message.get_all('cc', [])
271
272                        addrs = email.Utils.getaddresses(ccs)
273
274                        # Remove reporter email address if notification is
275                        # on
276                        #
277                        if self.notification:
278                                try:
279                                        addrs.remove((author, email_addr))
280                                except ValueError, detail:
281                                        pass
282
283                        for name,mail in addrs:
284                                        try:
285                                                ticket['cc'] = '%s,%s' %(ticket['cc'], mail)
286                                        except:
287                                                ticket['cc'] = mail
288                return author, email_addr
289
290        def save_email_for_debug(self, message):
291
292                msg_file = '/var/tmp/msg.txt'
293                print 'TD: saving email to %s' % msg_file
294                fx = open(msg_file, 'wb')
295                fx.write('%s' % message)
296                fx.close()
297                try:
298                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
299                except OSError:
300                        pass
301
302        def parse(self, fp):
303                msg = email.message_from_file(fp)
304                if not msg:
305                        return
306
307                if self.DEBUG > 1:        # save the entire e-mail message text
308                        self.save_email_for_debug(msg)
309
310                self.db = self.env.get_db_cnx()
311                tkt = Ticket(self.env)
312                tkt['status'] = 'new'
313
314                if self.get_config('notification', 'smtp_enabled') in ['true']:
315                        self.notification = 1
316
317                # Some defaults
318                #
319                tkt['milestone'] = self.get_config('ticket', 'default_milestone')
320                tkt['priority'] = self.get_config('ticket', 'default_priority')
321                tkt['severity'] = self.get_config('ticket', 'default_severity')
322                tkt['version'] = self.get_config('ticket', 'default_version')
323
324                if not msg['Subject']:
325                        tkt['summary'] = '(geen subject)'
326                else:
327                        tkt['summary'] = self.to_unicode(msg['Subject'])
328
329
330                if settings.has_key('component'):
331                        tkt['component'] = settings['component']
332                else:
333                        tkt['component'] = self.spam(msg)
334
335                # Must make this an option or so, discard SPAM messages or save then
336                # and delete later
337                #
338                #if self.SPAM_LEVEL and self.spam(msg):
339                #       print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
340                #       sys.exit(1)
341
342                # Set default owner for component
343                #
344                self.set_owner(tkt)
345                author, email_addr = self.set_reply_fields(tkt, msg)
346
347                # produce e-mail like header
348                #
349                head = ''
350                if self.EMAIL_HEADER > 0:
351                        head = self.email_header_txt(msg)
352
353                if self.DEBUG > 0:
354                        self.debug_attachments(msg)
355
356                self.description(msg,tkt, head, author, email_addr)
357
358                # Insert ticket in database
359                #
360                if self.VERSION > 0.8:
361                        tkt['id'] = tkt.insert()
362                else:
363                        tkt['id'] = tkt.insert(self.db)
364
365                #
366                # Just how to show to update description
367                #
368                #tkt['description'] = '\n{{{\n\n Bas is op nieuw bezig\n\n }}}\n'
369                #tkt.save_changes(self.db, author, "Lekker bezig")
370                #
371
372                self.attachments(msg, tkt, author)
373                if self.notification:
374                        self.notify(tkt)
375
376
377        def description(self, msg, tkt, head, author, email):
378                """
379                put the message text in the ticket description
380                message text can be plain text or html or something else
381                """
382                has_description = 0
383                ubody_text = '\n{{{\nNo plain text message\n}}}\n'
384                for part in msg.walk():
385
386                        # 'multipart/*' is a container for multipart messages
387                        #
388                        if part.get_content_maintype() == 'multipart':
389                                continue
390
391                        if part.get_content_type() == 'text/plain':
392                                # Try to decode, if fails then do not decode
393                                #
394                                body_text = part.get_payload(decode=1)         
395                                if not body_text:                       
396                                        body_text = part.get_payload(decode=0) 
397
398                                # Get contents charset (iso-8859-15 if not defined in mail headers)
399                                # UTF-8 encode body_text
400                                #
401                                charset = msg.get_content_charset('iso-8859-15')
402                                ubody_text = unicode(body_text, charset).encode('utf-8')
403
404                                tkt['description'] = '\n{{{\n\n%s\n}}}\n' %(ubody_text)
405
406                        elif part.get_content_type() == 'text/html':
407                                tkt['description'] = '%s\n\n(see attachment for HTML mail message)\n' \
408                                        %(head)
409                                body_text = tkt['description']
410
411                        else:
412                                tkt['description'] = '%s\n\n(see attachment for message)\n' %(head)
413                                body_text = tkt['description']
414
415                        has_description = 1
416                        break           # we have the description, so break
417
418                if not has_description:
419                        tkt['description'] = '%s\n\n(no plain text message, see attachments)' % head
420                        has_description = 1
421
422                if self.MAILTO:
423                        mailto = self.html_mailto_link(author, email, self.to_unicode(msg['subject']), ubody_text)
424                        tkt['description'] = '%s\n%s %s' %(head, mailto, tkt['description'])
425
426
427        def notify(self, tkt):
428                try:
429                        # create false {abs_}href properties, to trick Notify()
430                        #
431                        self.env.abs_href = Href(self.get_config('project', 'url'))
432                        self.env.href = Href(self.get_config('project', 'url'))
433
434                        tn = TicketNotifyEmail(self.env)
435                        if self.notify_template:
436                                tn.template_name = self.notify_template;
437
438                        tn.notify(tkt, newticket=True)
439
440                except Exception, e:
441                        print 'TD: Failure sending notification on creation of ticket #%s: %s' \
442                                % (tkt['id'], e)
443
444        def mail_line(self, str):
445                return '%s %s' % (self.comment, str)
446
447
448        def html_mailto_link(self, author, mail_addr, subject, body):
449                if not author:
450                        author = mail_addr
451                else:   
452                        author = self.to_unicode(author)
453
454                # Must find a fix
455                #
456                #arr = string.split(body, '\n')
457                #arr = map(self.mail_line, arr)
458                #body = string.join(arr, '\n')
459                #body = '%s wrote:\n%s' %(author, body)
460
461                # Temporary fix
462                str = 'mailto:%s?subject=%s' %(urllib.quote(mail_addr), urllib.quote('Re: %s' % subject))
463                str = '\n{{{\n#!html\n<a href="%s">Reply to: %s</a>\n}}}\n' %(str, author)
464
465                return str
466
467        def attachments(self, message, ticket, user):
468                '''save any attachments as file in the ticket's directory'''
469
470                count = 0
471                first = 0
472                for part in message.walk():
473                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
474                                continue
475
476                        if not first:                                                                           # first content is the message
477                                first = 1
478                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
479                                        continue
480
481                        filename = part.get_filename()
482                        if not filename:
483                                count = count + 1
484                                filename = 'part%04d' % count
485
486                                ext = mimetypes.guess_extension(part.get_type())
487                                if not ext:
488                                        ext = '.bin'
489
490                                filename = '%s%s' % (filename, ext)
491                        else:
492                                filename = self.to_unicode(filename)
493
494                        # From the trac code
495                        #
496                        filename = filename.replace('\\', '/').replace(':', '/')
497                        filename = os.path.basename(filename)
498
499                        # We try to normalize the filename to utf-8 NFC if we can.
500                        # Files uploaded from OS X might be in NFD.
501                        #
502                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
503                                filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
504
505                        url_filename = urllib.quote(filename)
506                        if self.VERSION > 0.8:
507                                dir = '/tmp'
508                        else:
509                                dir = os.path.join(self.env.get_attachments_dir(), 'ticket',
510                                                        urllib.quote(str(ticket['id'])))
511                                if not os.path.exists(dir):
512                                        mkdir_p(dir, 0755)
513
514                        path, fd =  util.create_unique_file(os.path.join(dir, url_filename))
515                        text = part.get_payload(decode=1)
516                        if not text:
517                                text = '(None)'
518                        fd.write(text)
519                        fd.close()
520
521                        # get the filesize
522                        #
523                        stats = os.lstat(path)
524                        filesize = stats[stat.ST_SIZE]
525
526                        # Insert the attachment it differs for the different TRAC versions
527                        #
528                        if self.VERSION > 0.8:
529                                fd = open(path)
530                                att = attachment.Attachment(self.env, 'ticket', ticket['id'])
531                                att.insert(url_filename, fd, filesize)
532                                fd.close()
533                        else:
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
540
541def mkdir_p(dir, mode):
542        '''do a mkdir -p'''
543
544        arr = string.split(dir, '/')
545        path = ''
546        for part in arr:
547                path = '%s/%s' % (path, part)
548                try:
549                        stats = os.stat(path)
550                except OSError:
551                        os.mkdir(path, mode)
552
553
554def ReadConfig(file, name):
555        """
556        Parse the config file
557        """
558
559        if not os.path.isfile(file):
560                print 'File %s does not exists' %file
561                sys.exit(1)
562
563        config = ConfigParser.ConfigParser()
564        try:
565                config.read(file)
566        except ConfigParser.MissingSectionHeaderError,detail:
567                print detail
568                sys.exit(1)
569
570
571        # Use given project name else use defaults
572        #
573        if name:
574                if not config.has_section(name):
575                        print "Not an valid project name: %s" %name
576                        print "Valid names: %s" %config.sections()
577                        sys.exit(1)
578
579                project =  dict()
580                for option in  config.options(name):
581                        project[option] = config.get(name, option)
582
583        else:
584                project = config.defaults()
585
586        return project
587
588if __name__ == '__main__':
589        # Default config file
590        #
591        configfile = '@email2trac_conf@'
592        project = ''
593        component = ''
594       
595        try:
596                opts, args = getopt.getopt(sys.argv[1:], 'chf:p:', ['component=','help', 'file=', 'project='])
597        except getopt.error,detail:
598                print __doc__
599                print detail
600                sys.exit(1)
601
602        project_name = None
603        for opt,value in opts:
604                if opt in [ '-h', '--help']:
605                        print __doc__
606                        sys.exit(0)
607                elif opt in ['-c', '--component']:
608                        component = value
609                elif opt in ['-f', '--file']:
610                        configfile = value
611                elif opt in ['-p', '--project']:
612                        project_name = value
613
614        settings = ReadConfig(configfile, project_name)
615        if not settings.has_key('project'):
616                print __doc__
617                print 'No project defined in config file, eg:\n\t project: /data/trac/bas'
618                sys.exit(1)
619
620        if component:
621                settings['component'] = component
622
623        if settings.has_key('trac_version'):
624                version = float(settings['trac_version'])
625        else:
626                version = trac_default_version
627
628        #debug HvB
629        #print settings
630
631        if version > 0.8:
632                from trac import attachment
633                from trac.env import Environment
634                from trac.ticket import Ticket
635                from trac.Notify import TicketNotifyEmail
636                from trac.web.href import Href
637                from trac import util
638        else:
639                from trac.Environment import Environment
640                from trac.Ticket import Ticket
641                from trac.Notify import TicketNotifyEmail
642                from trac.Href import Href
643                from trac import util
644
645        env = Environment(settings['project'], create=0)
646        tktparser = TicketEmailParser(env, settings, version)
647        tktparser.parse(sys.stdin)
648
649# EOB
Note: See TracBrowser for help on using the repository browser.