source: tags/0.5.4/email2trac.py.in @ 290

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

EmailtoTracScript?:

email2trac.py.in:

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