source: trunk/email2trac.py.in @ 216

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

email2trac.py.in:

  • reopen ticket if an emails is received, closes #70
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 30.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# For vi/emacs or other use tabstop=4 (vi: set ts=4)
21#
22"""
23email2trac.py -- Email tickets to Trac.
24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
30Changed By: Bas van der Vlies <basv@sara.nl>
31Date      : 13 September 2005
32Descr.    : Added config file and command line options, spam level
33            detection, reply address and mailto option. Unicode support
34
35Changed By: Walter de Jong <walter@sara.nl>
36Descr.    : multipart-message code and trac attachments
37
38
39The scripts reads emails from stdin and inserts directly into a Trac database.
40MIME headers are mapped as follows:
41
42        * From:      => Reporter
43                     => CC (Optional via reply_all option)
44        * Subject:   => Summary
45        * Body       => Description
46        * Component  => Can be set to SPAM via spam_level option
47
48How to use
49----------
50 * Create an config file:
51        [DEFAULT]                      # REQUIRED
52        project      : /data/trac/test # REQUIRED
53        debug        : 1               # OPTIONAL, if set print some DEBUG info
54        spam_level   : 4               # OPTIONAL, if set check for SPAM mail
55        reply_all    : 1               # OPTIONAL, if set then fill in ticket CC field
56        umask        : 022             # OPTIONAL, if set then use this umask for creation of the attachments
57        mailto_link  : 1               # OPTIONAL, if set then [mailto:<>] in description
58        mailto_cc    : basv@sara.nl    # OPTIONAL, use this address as CC in mailto line
59        ticket_update: 1               # OPTIONAL, if set then check if this is an update for a ticket
60        trac_version : 0.9             # OPTIONAL, default is 0.10
61
62        [jouvin]                       # OPTIONAL project declaration, if set both fields necessary
63        project      : /data/trac/jouvin # use -p|--project jouvin. 
64       
65 * default config file is : /etc/email2trac.conf
66
67 * Commandline opions:
68                -h,--help
69                -f,--file  <configuration file>
70                -n,--dry-run
71                -p, --project <project name>
72                -t, --ticket_prefix <name>
73
74SVN Info:
75        $Id: email2trac.py.in 215 2008-07-31 12:25:04Z bas $
76"""
77import os
78import sys
79import string
80import getopt
81import stat
82import time
83import email
84import email.Iterators
85import email.Header
86import re
87import urllib
88import unicodedata
89import ConfigParser
90from stat import *
91import mimetypes
92import traceback
93
94
95# Will fail where unavailable, e.g. Windows
96#
97try:
98    import syslog
99    SYSLOG_AVAILABLE = True
100except ImportError:
101    SYSLOG_AVAILABLE = False
102
103from datetime import tzinfo, timedelta, datetime
104from trac import config as trac_config
105
106# Some global variables
107#
108trac_default_version = '0.10'
109m = None
110
111# A UTC class needed for trac version 0.11, added by
112# tbaschak at ktc dot mb dot ca
113#
114class UTC(tzinfo):
115        """UTC"""
116        ZERO = timedelta(0)
117        HOUR = timedelta(hours=1)
118       
119        def utcoffset(self, dt):
120                return self.ZERO
121               
122        def tzname(self, dt):
123                return "UTC"
124               
125        def dst(self, dt):
126                return self.ZERO
127
128
129class TicketEmailParser(object):
130        env = None
131        comment = '> '
132   
133        def __init__(self, env, parameters, version):
134                self.env = env
135
136                # Database connection
137                #
138                self.db = None
139
140                # Save parameters
141                #
142                self.parameters = parameters
143
144                # Some useful mail constants
145                #
146                self.author = None
147                self.email_addr = None
148                self.email_from = None
149
150                self.VERSION = version
151                self.DRY_RUN = parameters['dry_run']
152
153                self.get_config = self.env.config.get
154
155                if parameters.has_key('umask'):
156                        os.umask(int(parameters['umask'], 8))
157
158                if parameters.has_key('debug'):
159                        self.DEBUG = int(parameters['debug'])
160                else:
161                        self.DEBUG = 0
162
163                if parameters.has_key('mailto_link'):
164                        self.MAILTO = int(parameters['mailto_link'])
165                        if parameters.has_key('mailto_cc'):
166                                self.MAILTO_CC = parameters['mailto_cc']
167                        else:
168                                self.MAILTO_CC = ''
169                else:
170                        self.MAILTO = 0
171
172                if parameters.has_key('spam_level'):
173                        self.SPAM_LEVEL = int(parameters['spam_level'])
174                else:
175                        self.SPAM_LEVEL = 0
176
177                if parameters.has_key('spam_header'):
178                        self.SPAM_HEADER = parameters['spam_header']
179                else:
180                        self.SPAM_HEADER = 'X-Spam-Score'
181
182                if parameters.has_key('email_quote'):
183                        self.EMAIL_QUOTE = str(parameters['email_quote'])
184                else:   
185                        self.EMAIL_QUOTE = '> '
186
187                if parameters.has_key('email_header'):
188                        self.EMAIL_HEADER = int(parameters['email_header'])
189                else:
190                        self.EMAIL_HEADER = 0
191
192                if parameters.has_key('alternate_notify_template'):
193                        self.notify_template = str(parameters['alternate_notify_template'])
194                else:
195                        self.notify_template = None
196
197                if parameters.has_key('reply_all'):
198                        self.REPLY_ALL = int(parameters['reply_all'])
199                else:
200                        self.REPLY_ALL = 0
201
202                if parameters.has_key('ticket_update'):
203                        self.TICKET_UPDATE = int(parameters['ticket_update'])
204                else:
205                        self.TICKET_UPDATE = 0
206
207                if parameters.has_key('drop_spam'):
208                        self.DROP_SPAM = int(parameters['drop_spam'])
209                else:
210                        self.DROP_SPAM = 0
211
212                if parameters.has_key('verbatim_format'):
213                        self.VERBATIM_FORMAT = int(parameters['verbatim_format'])
214                else:
215                        self.VERBATIM_FORMAT = 1
216
217                if parameters.has_key('strip_signature'):
218                        self.STRIP_SIGNATURE = int(parameters['strip_signature'])
219                else:
220                        self.STRIP_SIGNATURE = 0
221
222                if parameters.has_key('strip_quotes'):
223                        self.STRIP_QUOTES = int(parameters['strip_quotes'])
224                else:
225                        self.STRIP_QUOTES = 0
226
227                if parameters.has_key('use_textwrap'):
228                        self.USE_TEXTWRAP = int(parameters['use_textwrap'])
229                else:
230                        self.USE_TEXTWRAP = 0
231
232                if parameters.has_key('python_egg_cache'):
233                        self.python_egg_cache = str(parameters['python_egg_cache'])
234                        os.environ['PYTHON_EGG_CACHE'] = self.python_egg_cache
235
236                # Use OS independend functions
237                #
238                self.TMPDIR = os.path.normcase('/tmp')
239                if parameters.has_key('tmpdir'):
240                        self.TMPDIR = os.path.normcase(str(parameters['tmpdir']))
241
242                if parameters.has_key('ignore_trac_user_settings'):
243                        self.IGNORE_TRAC_USER_SETTINGS = int(parameters['ignore_trac_user_settings'])
244                else:
245                        self.IGNORE_TRAC_USER_SETTINGS = 0
246
247        def spam(self, message):
248                """
249                # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
250                # Note if Spam_level then '*' are included
251                """
252                spam = False
253                if message.has_key(self.SPAM_HEADER):
254                        spam_l = string.split(message[self.SPAM_HEADER])
255
256                        try:
257                                number = spam_l[0].count('*')
258                        except IndexError, detail:
259                                number = 0
260                               
261                        if number >= self.SPAM_LEVEL:
262                                spam = True
263                               
264                # treat virus mails as spam
265                #
266                elif message.has_key('X-Virus-found'):                 
267                        spam = True
268
269                # How to handle SPAM messages
270                #
271                if self.DROP_SPAM and spam:
272                        if self.DEBUG > 2 :
273                                print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
274
275                        return 'drop'   
276
277                elif spam:
278
279                        return 'Spam'   
280
281                else:
282
283                        return False
284
285
286        def blacklisted_from(self):
287                """
288                This function wil check if the email address is in the black list
289                """
290                try:
291                        black_list = self.parameters['black_list']
292                except KeyError, detail:
293                        black_list = 'MAILER-DAEMON@'
294
295                black_list = string.split(black_list)   
296                if not black_list.count('MAILER-DAEMON@'):
297                        black_list.append('MAILER-DAEMON@')
298
299                for entry in black_list:       
300                        entry = entry.strip()
301                        FROM_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
302                        result =  FROM_RE.search(self.email_addr)
303                        if result:
304                                return True
305
306                return False
307
308        def email_to_unicode(self, message_str):
309                """
310                Email has 7 bit ASCII code, convert it to unicode with the charset
311        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
312                understands it.
313                """
314                results =  email.Header.decode_header(message_str)
315                str = None
316                for text,format in results:
317                        if format:
318                                try:
319                                        temp = unicode(text, format)
320                                except UnicodeError, detail:
321                                        # This always works
322                                        #
323                                        temp = unicode(text, 'iso-8859-15')
324                                except LookupError, detail:
325                                        #text = 'ERROR: Could not find charset: %s, please install' %format
326                                        #temp = unicode(text, 'iso-8859-15')
327                                        temp = message_str
328                                       
329                        else:
330                                temp = string.strip(text)
331                                temp = unicode(text, 'iso-8859-15')
332
333                        if str:
334                                str = '%s %s' %(str, temp)
335                        else:
336                                str = '%s' %temp
337
338                #str = str.encode('utf-8')
339                return str
340
341        def debug_attachments(self, message):
342                n = 0
343                for part in message.walk():
344                        if part.get_content_maintype() == 'multipart':      # multipart/* is just a container
345                                print 'TD: multipart container'
346                                continue
347
348                        n = n + 1
349                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
350                        print 'TD: part%d: filename: %s' % (n, part.get_filename())
351
352                        if part.is_multipart():
353                                print 'TD: this part is multipart'
354                                payload = part.get_payload(decode=1)
355                                print 'TD: payload:', payload
356                        else:
357                                print 'TD: this part is not multipart'
358
359                        file = 'part%d' %n
360                        part_file = os.path.join(self.TMPDIR, file)
361                        #part_file = '/var/tmp/part%d' % n
362                        print 'TD: writing part%d (%s)' % (n,part_file)
363                        fx = open(part_file, 'wb')
364                        text = part.get_payload(decode=1)
365                        if not text:
366                                text = '(None)'
367                        fx.write(text)
368                        fx.close()
369                        try:
370                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
371                        except OSError:
372                                pass
373
374        def email_header_txt(self, m):
375                """
376                Display To and CC addresses in description field
377                """
378                str = ''
379                #if m['To'] and len(m['To']) > 0 and m['To'] != 'hic@sara.nl':
380                if m['To'] and len(m['To']) > 0:
381                        str = "'''To:''' %s\r\n" %(m['To'])
382                if m['Cc'] and len(m['Cc']) > 0:
383                        str = "%s'''Cc:''' %s\r\n" % (str, m['Cc'])
384
385                return  self.email_to_unicode(str)
386
387
388        def set_owner(self, ticket):
389                """
390                Select default owner for ticket component
391                """
392                #### return self.get_config('ticket', 'default_component')
393                cursor = self.db.cursor()
394                sql = "SELECT owner FROM component WHERE name='%s'" % ticket['component']
395                cursor.execute(sql)
396                try:
397                        ticket['owner'] = cursor.fetchone()[0]
398                except TypeError, detail:
399                        ticket['owner'] = None
400
401        def get_sender_info(self, message):
402                """
403                Get the default author name and email address from the message
404                """
405
406                self.email_from = self.email_to_unicode(message['from'])
407                self.author, self.email_addr  = email.Utils.parseaddr(self.email_from)
408
409                # Maybe for later user
410                #self.email_from =  self.email_to_unicode(self.email_addr)
411
412
413                if self.IGNORE_TRAC_USER_SETTINGS:
414                        return
415
416                # Is this a registered user, use email address as search key:
417                # result:
418                #   u : login name
419                #   n : Name that the user has set in the settings tab
420                #   e : email address that the user has set in the settings tab
421                #
422                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
423                                if e == self.email_addr ]
424
425                if len(users) == 1:
426                        self.email_from = users[0][0]
427
428        def set_reply_fields(self, ticket, message):
429                """
430                Set all the right fields for a new ticket
431                """
432                ticket['reporter'] = self.email_from
433
434                # Put all CC-addresses in ticket CC field
435                #
436                if self.REPLY_ALL:
437                        #tos = message.get_all('to', [])
438                        ccs = message.get_all('cc', [])
439
440                        addrs = email.Utils.getaddresses(ccs)
441                        if not addrs:
442                                return
443
444                        # Remove reporter email address if notification is
445                        # on
446                        #
447                        if self.notification:
448                                try:
449                                        addrs.remove((self.author, self.email_addr))
450                                except ValueError, detail:
451                                        pass
452
453                        for name,mail in addrs:
454                                try:
455                                        mail_list = '%s, %s' %(mail_list, mail)
456                                except:
457                                        mail_list = mail
458
459                        if mail_list:
460                                ticket['cc'] = self.email_to_unicode(mail_list)
461
462        def save_email_for_debug(self, message, tempfile=False):
463                if tempfile:
464                        import tempfile
465                        msg_file = tempfile.mktemp('.email2trac')
466                else:
467                        #msg_file = '/var/tmp/msg.txt'
468                        msg_file = os.path.join(self.TMPDIR, 'msg.txt')
469
470                print 'TD: saving email to %s' % msg_file
471                fx = open(msg_file, 'wb')
472                fx.write('%s' % message)
473                fx.close()
474                try:
475                        os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
476                except OSError:
477                        pass
478
479        def str_to_dict(self, str):
480                """
481                Transfrom a str of the form [<key>=<value>]+ to dict[<key>] = <value>
482                """
483                # Skip the last ':' character
484                #
485                fields = string.split(str[:-1], ',')
486
487                result = dict()
488                for field in fields:
489                        try:
490                                index, value = string.split(field,'=')
491
492                                # We can not change the description of a ticket via the subject
493                                # line. The description is the body of the email
494                                #
495                                if index.lower() in ['description']:
496                                        continue
497
498                                if value:
499                                        result[index.lower()] = value
500
501                        except ValueError:
502                                pass
503
504                return result
505
506        def update_ticket_fields(self, ticket, user_dict, use_default=None):
507                """
508                This will update the ticket fields. It will check if the
509                given fields are known and if the right values are specified
510                It will only update the ticket field value:
511                        - If the field is known
512                        - If the value supplied is valid for the ticket field.
513                          If not then there are two options:
514                           1) Skip the value (use_default=None)
515                           2) Set default value for field (use_default=1)
516                """
517
518                # Build a system dictionary from the ticket fields
519                # with field as index and option as value
520                #
521                sys_dict = dict()
522                for field in ticket.fields:
523                        try:
524                                sys_dict[field['name']] = field['options']
525
526                        except KeyError:
527                                sys_dict[field['name']] = None
528                                pass
529
530                # Check user supplied fields an compare them with the
531                # system one's
532                #
533                for field,value in user_dict.items():
534                        if self.DEBUG >= 10:
535                                print  'user_field\t %s = %s' %(field,value)
536
537                        if sys_dict.has_key(field):
538
539                                # Check if value is an allowed system option, if TypeError then
540                                # every value is allowed
541                                #
542                                try:
543                                        if value in sys_dict[field]:
544                                                ticket[field] = value
545                                        else:
546                                                # Must we set a default if value is not allowed
547                                                #
548                                                if use_default:
549                                                        value = self.get_config('ticket', 'default_%s' %(field) )
550                                                        ticket[field] = value
551
552                                except TypeError:
553                                        ticket[field] = value
554
555                                if self.DEBUG >= 10:
556                                        print  'ticket_field\t %s = %s' %(field,  ticket[field])
557                                       
558        def ticket_update(self, m, spam):
559                """
560                If the current email is a reply to an existing ticket, this function
561                will append the contents of this email to that ticket, instead of
562                creating a new one.
563                """
564
565                if not m['Subject']:
566                        return False
567                else:
568                        subject  = self.email_to_unicode(m['Subject'])
569
570                # [hic] #1529: Re: LRZ
571                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
572                #
573                TICKET_RE = re.compile(r"""
574                                        (?P<ticketnr>[#][0-9]+:)
575                                        |(?P<ticketnr_fields>[#][\d]+\?.*?:)
576                                        """, re.VERBOSE)
577
578                result =  TICKET_RE.search(subject)
579                if not result:
580                        return False
581
582                # Must we update ticket fields
583                #
584                update_tkt_fields = dict()
585                try:
586                        nr, keywords = string.split(result.group('ticketnr_fields'), '?')
587                        update_tkt_fields = self.str_to_dict(keywords)
588
589                        # Strip '#'
590                        #
591                        ticket_id = int(nr[1:])
592
593                except AttributeError:
594                        # Strip '#' and ':'
595                        #
596                        nr = result.group('ticketnr')
597                        ticket_id = int(nr[1:-1])
598
599
600                # When is the change committed
601                #
602                #
603                if self.VERSION == 0.11:
604                        utc = UTC()
605                        when = datetime.now(utc)
606                else:
607                        when = int(time.time())
608
609                try:
610                        tkt = Ticket(self.env, ticket_id, self.db)
611                except util.TracError, detail:
612                        return False
613
614                # Must we update some ticket fields properties
615                #
616                if update_tkt_fields:
617                        self.update_ticket_fields(tkt, update_tkt_fields)
618
619                body_text = self.get_body_text(m)
620
621                # reopen the ticket if is was closed
622                #
623                if tkt['status'] in ['closed']:
624                        tkt['status'] = 'reopened'
625                        tkt['resolution'] = ''
626
627                if self.EMAIL_HEADER:
628                        head = self.email_header_txt(m)
629                        body_text = u"%s\r\n%s" %(head, body_text)
630
631                tkt.save_changes(self.author, body_text, when)
632                tkt['id'] = ticket_id
633
634                if self.VERSION  == 0.9:
635                        str = self.attachments(m, tkt, True)
636                else:
637                        str = self.attachments(m, tkt)
638
639                if self.notification and not spam:
640                        self.notify(tkt, False, when)
641
642                return True
643
644        def set_ticket_fields(self, ticket):
645                """
646                set the ticket fields to value specified
647                        - /etc/email2trac.conf with <prefix>_<field>
648                        - trac default values, trac.ini
649                """
650                user_dict = dict()
651
652                for field in ticket.fields:
653
654                        name = field['name']
655
656                        # skip some fields like resolution
657                        #
658                        if name in [ 'resolution' ]:
659                                continue
660
661                        # default trac value
662                        #
663                        value = self.get_config('ticket', 'default_%s' %(name) )
664                        if self.DEBUG > 10:
665                                print 'trac.ini name %s = %s' %(name, value)
666
667                        prefix = self.parameters['ticket_prefix']
668                        try:
669                                value = self.parameters['%s_%s' %(prefix, name)]
670                                if self.DEBUG > 10:
671                                        print 'email2trac.conf %s = %s ' %(name, value)
672
673                        except KeyError, detail:
674                                pass
675               
676                        if self.DEBUG:
677                                print 'user_dict[%s] = %s' %(name, value)
678
679                        user_dict[name] = value
680
681                self.update_ticket_fields(ticket, user_dict, use_default=1)
682
683                # Set status ticket
684                #`
685                ticket['status'] = 'new'
686
687
688
689        def new_ticket(self, msg, spam):
690                """
691                Create a new ticket
692                """
693                tkt = Ticket(self.env)
694
695                self.set_ticket_fields(tkt)
696
697                # Some defaults
698                #
699                #tkt['status'] = 'new'
700                #tkt['milestone'] = self.get_config('ticket', 'default_milestone')
701                #tkt['priority'] = self.get_config('ticket', 'default_priority')
702                #tkt['severity'] = self.get_config('ticket', 'default_severity')
703                #tkt['version'] = self.get_config('ticket', 'default_version')
704                #tkt['type'] = self.get_config('ticket', 'default_type')
705
706                # Old style setting for component, will be removed
707                #
708                if spam:
709                        tkt['component'] = 'Spam'
710
711                elif self.parameters.has_key('component'):
712                        tkt['component'] = self.parameters['component']
713
714                if not msg['Subject']:
715                        tkt['summary'] = u'(No subject)'
716                else:
717                        tkt['summary'] = self.email_to_unicode(msg['Subject'])
718
719
720                self.set_reply_fields(tkt, msg)
721
722                # produce e-mail like header
723                #
724                head = ''
725                if self.EMAIL_HEADER > 0:
726                        head = self.email_header_txt(msg)
727                       
728                body_text = self.get_body_text(msg)
729
730                tkt['description'] = '%s\r\n%s' \
731                        %(head, body_text)
732
733                #when = int(time.time())
734                #
735                utc = UTC()
736                when = datetime.now(utc)
737
738                if self.DRY_RUN:
739                        ticket_id = 'DRY_RUN'
740                else:
741                        ticket_id = tkt.insert()
742                       
743                tkt['id'] = ticket_id
744
745                changed = False
746                comment = ''
747
748                # Rewrite the description if we have mailto enabled
749                #
750                if self.MAILTO:
751                        changed = True
752                        comment = u'\nadded mailto line\n'
753                        #mailto = self.html_mailto_link(tkt['summary'], ticket_id, body_text)
754                        mailto = self.html_mailto_link( m['Subject'], ticket_id, body_text)
755                        tkt['description'] = u'%s\r\n%s%s\r\n' \
756                                %(head, mailto, body_text)
757
758                str =  self.attachments(msg, tkt)
759                if str:
760                        changed = True
761                        comment = '%s\n%s\n' %(comment, str)
762
763                if changed:
764                        if self.DRY_RUN:
765                                print 'DRY_RUN: tkt.save_changes(self.author, comment)'
766                        else:
767                                tkt.save_changes(self.author, comment)
768                                #print tkt.get_changelog(self.db, when)
769
770                if self.notification:
771                        if self.DRY_RUN:
772                                print 'DRY_RUN: self.notify(tkt, True)'
773                        else:
774                                if not spam:
775                                        self.notify(tkt, True)
776                                #self.notify(tkt, False)
777
778        def parse(self, fp):
779                global m
780
781                m = email.message_from_file(fp)
782                if not m:
783                        return
784
785                if self.DEBUG > 1:        # save the entire e-mail message text
786                        self.save_email_for_debug(m)
787                        self.debug_attachments(m)
788
789                self.db = self.env.get_db_cnx()
790                self.get_sender_info(m)
791
792                if self.blacklisted_from():
793                        if self.DEBUG > 1 :
794                                print 'Message rejected : From: in blacklist'
795                        return False
796
797                # If drop the message
798                #
799                if self.spam(m) == 'drop':
800                        return False
801
802                elif self.spam(m) == 'spam':
803                        spam_msg = True
804
805                else:
806                        spam_msg = False
807
808                if self.get_config('notification', 'smtp_enabled') in ['true']:
809                        self.notification = 1
810                else:
811                        self.notification = 0
812
813                # Must we update existing tickets
814                #
815                if self.TICKET_UPDATE > 0:
816                        if self.ticket_update(m, spam_msg):
817                                return True
818
819                self.new_ticket(m, spam_msg)
820
821        def strip_signature(self, text):
822                """
823                Strip signature from message, inspired by Mailman software
824                """
825                body = []
826                for line in text.splitlines():
827                        if line == '-- ':
828                                break
829                        body.append(line)
830
831                return ('\n'.join(body))
832
833        def strip_quotes(self, text):
834                """
835                Strip quotes from message by Nicolas Mendoza
836                """
837                body = []
838                for line in text.splitlines():
839                        if line.startswith(self.EMAIL_QUOTE):
840                                continue
841                        body.append(line)
842
843                return ('\n'.join(body))
844
845        def wrap_text(self, text, replace_whitespace = False):
846                """
847                Will break a lines longer then given length into several small
848                lines of size given length
849                """
850                import textwrap
851
852                LINESEPARATOR = '\n'
853                reformat = ''
854
855                for s in text.split(LINESEPARATOR):
856                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
857                        if tmp:
858                                reformat = '%s\n%s' %(reformat,tmp)
859                        else:
860                                reformat = '%s\n' %reformat
861
862                return reformat
863
864                # Python2.4 and higher
865                #
866                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
867                #
868
869
870        def get_body_text(self, msg):
871                """
872                put the message text in the ticket description or in the changes field.
873                message text can be plain text or html or something else
874                """
875                has_description = 0
876                encoding = True
877                ubody_text = u'No plain text message'
878                for part in msg.walk():
879
880                        # 'multipart/*' is a container for multipart messages
881                        #
882                        if part.get_content_maintype() == 'multipart':
883                                continue
884
885                        if part.get_content_type() == 'text/plain':
886                                # Try to decode, if fails then do not decode
887                                #
888                                body_text = part.get_payload(decode=1)
889                                if not body_text:                       
890                                        body_text = part.get_payload(decode=0)
891       
892                                if self.STRIP_SIGNATURE:
893                                        body_text = self.strip_signature(body_text)
894
895                                if self.STRIP_QUOTES:
896                                        body_text = self.strip_quotes(body_text)
897
898                                if self.USE_TEXTWRAP:
899                                        body_text = self.wrap_text(body_text)
900
901                                # Get contents charset (iso-8859-15 if not defined in mail headers)
902                                #
903                                charset = part.get_content_charset()
904                                if not charset:
905                                        charset = 'iso-8859-15'
906
907                                try:
908                                        ubody_text = unicode(body_text, charset)
909
910                                except UnicodeError, detail:
911                                        ubody_text = unicode(body_text, 'iso-8859-15')
912
913                                except LookupError, detail:
914                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
915
916                        elif part.get_content_type() == 'text/html':
917                                ubody_text = '(see attachment for HTML mail message)'
918
919                        else:
920                                ubody_text = '(see attachment for message)'
921
922                        has_description = 1
923                        break           # we have the description, so break
924
925                if not has_description:
926                        ubody_text = '(see attachment for message)'
927
928                # A patch so that the web-interface will not update the description
929                # field of a ticket
930                #
931                ubody_text = ('\r\n'.join(ubody_text.splitlines()))
932
933                #  If we can unicode it try to encode it for trac
934                #  else we a lot of garbage
935                #
936                #if encoding:
937                #       ubody_text = ubody_text.encode('utf-8')
938
939                if self.VERBATIM_FORMAT:
940                        ubody_text = '{{{\r\n%s\r\n}}}' %ubody_text
941                else:
942                        ubody_text = '%s' %ubody_text
943
944                return ubody_text
945
946        def notify(self, tkt , new=True, modtime=0):
947                """
948                A wrapper for the TRAC notify function. So we can use templates
949                """
950                try:
951                        # create false {abs_}href properties, to trick Notify()
952                        #
953                        if not self.VERSION == 0.11:
954                                self.env.abs_href = Href(self.get_config('project', 'url'))
955                                self.env.href = Href(self.get_config('project', 'url'))
956
957                        tn = TicketNotifyEmail(self.env)
958
959                        if self.notify_template:
960                                tn.template_name = self.notify_template;
961
962                        tn.notify(tkt, new, modtime)
963
964                except Exception, e:
965                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(tkt['id'], e)
966
967        def html_mailto_link(self, subject, id, body):
968                if not self.author:
969                        author = self.email_addr
970                else:   
971                        author = self.author
972
973                # Must find a fix
974                #
975                #arr = string.split(body, '\n')
976                #arr = map(self.mail_line, arr)
977                #body = string.join(arr, '\n')
978                #body = '%s wrote:\n%s' %(author, body)
979
980                # Temporary fix
981                #
982                str = 'mailto:%s?Subject=%s&Cc=%s' %(
983                       urllib.quote(self.email_addr),
984                           urllib.quote('Re: #%s: %s' %(id, subject)),
985                           urllib.quote(self.MAILTO_CC)
986                           )
987
988                str = '\r\n{{{\r\n#!html\r\n<a\r\n href="%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(str, author)
989                return str
990
991        def attachments(self, message, ticket, update=False):
992                '''
993                save any attachments as files in the ticket's directory
994                '''
995                count = 0
996                first = 0
997                number = 0
998
999                # Get Maxium attachment size
1000                #
1001                max_size = int(self.get_config('attachment', 'max_size'))
1002                status   = ''
1003
1004                for part in message.walk():
1005                        if part.get_content_maintype() == 'multipart':          # multipart/* is just a container
1006                                continue
1007
1008                        if not first:                                                                           # first content is the message
1009                                first = 1
1010                                if part.get_content_type() == 'text/plain':             # if first is text, is was already put in the description
1011                                        continue
1012
1013                        filename = part.get_filename()
1014                        if not filename:
1015                                number = number + 1
1016                                filename = 'part%04d' % number
1017
1018                                ext = mimetypes.guess_extension(part.get_content_type())
1019                                if not ext:
1020                                        ext = '.bin'
1021
1022                                filename = '%s%s' % (filename, ext)
1023                        else:
1024                                filename = self.email_to_unicode(filename)
1025
1026                        # From the trac code
1027                        #
1028                        filename = filename.replace('\\', '/').replace(':', '/')
1029                        filename = os.path.basename(filename)
1030
1031                        # We try to normalize the filename to utf-8 NFC if we can.
1032                        # Files uploaded from OS X might be in NFD.
1033                        # Check python version and then try it
1034                        #
1035                        if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1036                                try:
1037                                        filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1038                                except TypeError:
1039                                        pass
1040
1041                        url_filename = urllib.quote(filename)
1042                        #
1043                        # Must be tuneables HvB
1044                        #
1045                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, url_filename))
1046                        text = part.get_payload(decode=1)
1047                        if not text:
1048                                text = '(None)'
1049                        fd.write(text)
1050                        fd.close()
1051
1052                        # get the file_size
1053                        #
1054                        stats = os.lstat(path)
1055                        file_size = stats[stat.ST_SIZE]
1056
1057                        # Check if the attachment size is allowed
1058                        #
1059                        if (max_size != -1) and (file_size > max_size):
1060                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1061                                        %(status, filename, file_size, max_size)
1062
1063                                os.unlink(path)
1064                                continue
1065                        else:
1066                                count = count + 1
1067                                       
1068                        # Insert the attachment
1069                        #
1070                        fd = open(path)
1071                        att = attachment.Attachment(self.env, 'ticket', ticket['id'])
1072
1073                        # This will break the ticket_update system, the body_text is vaporized
1074                        # ;-(
1075                        #
1076                        if not update:
1077                                att.author = self.author
1078                                att.description = self.email_to_unicode('Added by email2trac')
1079
1080                        att.insert(url_filename, fd, file_size)
1081                        #except  util.TracError, detail:
1082                        #       print detail
1083
1084                        # Remove the created temporary filename
1085                        #
1086                        fd.close()
1087                        os.unlink(path)
1088
1089                # Return how many attachments
1090                #
1091                status = 'This message has %d attachment(s)\n%s' %(count, status)
1092                return status
1093
1094
1095def mkdir_p(dir, mode):
1096        '''do a mkdir -p'''
1097
1098        arr = string.split(dir, '/')
1099        path = ''
1100        for part in arr:
1101                path = '%s/%s' % (path, part)
1102                try:
1103                        stats = os.stat(path)
1104                except OSError:
1105                        os.mkdir(path, mode)
1106
1107def ReadConfig(file, name):
1108        """
1109        Parse the config file
1110        """
1111
1112        if not os.path.isfile(file):
1113                print 'File %s does not exist' %file
1114                sys.exit(1)
1115
1116        config = trac_config.Configuration(file)
1117
1118        # Use given project name else use defaults
1119        #
1120        if name:
1121                sections = config.sections()
1122                if not name in sections:
1123                        print "Not a valid project name: %s" %name
1124                        print "Valid names: %s" %sections
1125                        sys.exit(1)
1126
1127                project =  dict()
1128                for option, value in  config.options(name):
1129                        project[option] = value
1130
1131        else:
1132                project = config.defaults()
1133
1134        return project
1135
1136
1137if __name__ == '__main__':
1138        # Default config file
1139        #
1140        configfile = '@email2trac_conf@'
1141        project = ''
1142        component = ''
1143        ticket_prefix = 'default'
1144        dry_run = None
1145
1146        ENABLE_SYSLOG = 0
1147
1148
1149        SHORT_OPT = 'chf:np:t:'
1150        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=']
1151
1152        try:
1153                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
1154        except getopt.error,detail:
1155                print __doc__
1156                print detail
1157                sys.exit(1)
1158       
1159        project_name = None
1160        for opt,value in opts:
1161                if opt in [ '-h', '--help']:
1162                        print __doc__
1163                        sys.exit(0)
1164                elif opt in ['-c', '--component']:
1165                        component = value
1166                elif opt in ['-f', '--file']:
1167                        configfile = value
1168                elif opt in ['-n', '--dry-run']:
1169                        dry_run = True
1170                elif opt in ['-p', '--project']:
1171                        project_name = value
1172                elif opt in ['-t', '--ticket_prefix']:
1173                        ticket_prefix = value
1174       
1175        settings = ReadConfig(configfile, project_name)
1176        if not settings.has_key('project'):
1177                print __doc__
1178                print 'No Trac project is defined in the email2trac config file.'
1179                sys.exit(1)
1180       
1181        if component:
1182                settings['component'] = component
1183
1184        # The default prefix for ticket values in email2trac.conf
1185        #
1186        settings['ticket_prefix'] = ticket_prefix
1187        settings['dry_run'] = dry_run
1188       
1189        if settings.has_key('trac_version'):
1190                version = settings['trac_version']
1191        else:
1192                version = trac_default_version
1193
1194
1195        #debug HvB
1196        #print settings
1197
1198        try:
1199                if version == '0.9':
1200                        from trac import attachment
1201                        from trac.env import Environment
1202                        from trac.ticket import Ticket
1203                        from trac.web.href import Href
1204                        from trac import util
1205                        from trac.Notify import TicketNotifyEmail
1206                elif version == '0.10':
1207                        from trac import attachment
1208                        from trac.env import Environment
1209                        from trac.ticket import Ticket
1210                        from trac.web.href import Href
1211                        from trac import util
1212                        #
1213                        # return  util.text.to_unicode(str)
1214                        #
1215                        # see http://projects.edgewall.com/trac/changeset/2799
1216                        from trac.ticket.notification import TicketNotifyEmail
1217                        from trac import config as trac_config
1218                elif version == '0.11':
1219                        from trac import attachment
1220                        from trac.env import Environment
1221                        from trac.ticket import Ticket
1222                        from trac.web.href import Href
1223                        from trac import config as trac_config
1224                        from trac import util
1225                        #
1226                        # return  util.text.to_unicode(str)
1227                        #
1228                        # see http://projects.edgewall.com/trac/changeset/2799
1229                        from trac.ticket.notification import TicketNotifyEmail
1230                else:
1231                        print 'TRAC version %s is not supported' %version
1232                        sys.exit(1)
1233                       
1234                if settings.has_key('enable_syslog'):
1235                        if SYSLOG_AVAILABLE:
1236                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
1237
1238                env = Environment(settings['project'], create=0)
1239                tktparser = TicketEmailParser(env, settings, float(version))
1240                tktparser.parse(sys.stdin)
1241
1242        # Catch all errors ans log to SYSLOG if we have enabled this
1243        # else stdout
1244        #
1245        except Exception, error:
1246                if ENABLE_SYSLOG:
1247                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
1248
1249                        etype, evalue, etb = sys.exc_info()
1250                        for e in traceback.format_exception(etype, evalue, etb):
1251                                syslog.syslog(e)
1252
1253                        syslog.closelog()
1254                else:
1255                        traceback.print_exc()
1256
1257                if m:
1258                        tktparser.save_email_for_debug(m, True)
1259
1260# EOB
Note: See TracBrowser for help on using the repository browser.