source: trunk/email2trac.py.in @ 493

Last change on this file since 493 was 493, checked in by bas, 13 years ago

fixed some bugs in update_ticket_fields function, see #228

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 60.2 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
30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
33
34The scripts reads emails from stdin and inserts directly into a Trac database.
35
36How to use
37----------
38 * See https://subtrac.sara.nl/oss/email2trac/
39
40 * Create an config file:
41    [DEFAULT]                        # REQUIRED
42    project      : /data/trac/test   # REQUIRED
43    debug        : 1                 # OPTIONAL, if set print some DEBUG info
44
45    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
46    project      : /data/trac/jouvin # use -p|--project jouvin. 
47       
48 * default config file is : /etc/email2trac.conf
49
50 * Commandline opions:
51                -h,--help
52                                -d, --debug
53                -f,--file  <configuration file>
54                -n,--dry-run
55                -p, --project <project name>
56                -t, --ticket_prefix <name>
57
58SVN Info:
59        $Id: email2trac.py.in 493 2010-12-13 10:23:52Z bas $
60"""
61import os
62import sys
63import string
64import getopt
65import time
66import email
67import email.Iterators
68import email.Header
69import re
70import urllib
71import unicodedata
72import mimetypes
73import traceback
74import logging
75import logging.handlers
76import UserDict
77
78from datetime import tzinfo, timedelta, datetime
79from stat import *
80
81
82from trac import __version__ as trac_version
83from trac import config as trac_config
84
85# Some global variables
86#
87m = None
88
89class SaraDict(UserDict.UserDict):
90        def __init__(self, dictin = None):
91                UserDict.UserDict.__init__(self)
92                self.name = None
93               
94                if dictin:
95                        if dictin.has_key('name'):
96                                self.name = dictin['name']
97                                del dictin['name']
98                        self.data = dictin
99                       
100        def get_value(self, name):
101                if self.has_key(name):
102                        return self[name]
103                else:
104                        return None
105                               
106        def __repr__(self):
107                return repr(self.data)
108
109        def __str__(self):
110                return str(self.data)
111                       
112        def __getattr__(self, name):
113                """
114                override the class attribute get method. Return the value
115                from the dictionary
116                """
117                if self.data.has_key(name):
118                        return self.data[name]
119                else:
120                        return None
121                       
122        def __setattr__(self, name, value):
123                """
124                override the class attribute set method only when the UserDict
125                has set its class attribute
126                """
127                if self.__dict__.has_key('data'):
128                        self.data[name] = value
129                else:
130                        self.__dict__[name] = value
131
132        def __iter__(self):
133                return iter(self.data.keys())
134
135class TicketEmailParser(object):
136        env = None
137        comment = '> '
138
139        def __init__(self, env, parameters, logger, version):
140                self.env = env
141
142                # Database connection
143                #
144                self.db = None
145
146                # Save parameters
147                #
148                self.parameters = parameters
149                self.logger = logger
150
151                # Some useful mail constants
152                #
153                self.email_name = None
154                self.email_addr = None
155                self.email_from = None
156                self.author     = None
157                self.id         = None
158               
159                self.STRIP_CONTENT_TYPES = list()
160
161                ## fields properties via body_text
162                #
163                self.properties = dict()
164
165                self.VERSION = version
166
167                self.get_config = self.env.config.get
168
169                ## init function ##
170                #
171                self.setup_parameters()
172
173        def setup_parameters(self):
174                if self.parameters.umask:
175                        os.umask(self.parameters.umask)
176
177                if not self.parameters.spam_level:
178                        self.parameters.spam_level = 0
179
180                if not self.parameters.spam_header:
181                        self.parameters.spam_header = 'X-Spam-Score'
182
183                if not self.parameters.email_quote:
184                        self.parameters.email_quote = '> '
185
186                if not self.parameters.ticket_update_by_subject_lookback:
187                        self.parameters.ticket_update_by_subject_lookback = 30
188
189                if self.parameters.verbatim_format == None:
190                        self.parameters.verbatim_format = 1
191
192                if self.parameters.reflow == None:
193                        self.parameters.reflow = 1
194
195                if self.parameters.binhex:
196                        self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
197
198                if self.parameters.applesingle:
199                        self.STRIP_CONTENT_TYPES.append('application/applefile')
200
201                if self.parameters.appledouble:
202                        self.STRIP_CONTENT_TYPES.append('application/applefile')
203
204                if self.parameters.strip_content_types:
205                        items = self.parameters.strip_content_types.split(',')
206                        for item in items:
207                                self.STRIP_CONTENT_TYPES.append(item.strip())
208
209                if self.parameters.tmpdir:
210                        self.parameters.tmpdir = os.path.normcase(str(self.parameters['tmpdir']))
211                else:
212                        self.parameters.tmpdir = os.path.normcase('/tmp')
213
214                if self.parameters.email_triggers_workflow == None:
215                        self.parameters.email_triggers_workflow = 1
216
217                if not self.parameters.subject_field_separator:
218                        self.parameters.subject_field_separator = '&'
219                else:
220                        self.parameters.subject_field_separator = self.parameters.subject_field_separator.strip()
221
222                self.trac_smtp_from = self.get_config('notification', 'smtp_from')
223                self.smtp_default_domain = self.get_config('notification', 'smtp_default_domain')
224
225
226                self.system = None
227
228########## Email Header Functions ###########################################################
229
230        def spam(self, message):
231                """
232                # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
233                # Note if Spam_level then '*' are included
234                """
235                spam = False
236                if message.has_key(self.parameters.spam_header):
237                        spam_l = string.split(message[self.parameters.spam_header])
238
239                        try:
240                                number = spam_l[0].count('*')
241                        except IndexError, detail:
242                                number = 0
243                               
244                        if number >= self.parameters.spam_level:
245                                spam = True
246                               
247                # treat virus mails as spam
248                #
249                elif message.has_key('X-Virus-found'):                 
250                        spam = True
251
252                # How to handle SPAM messages
253                #
254                if self.parameters.drop_spam and spam:
255
256                        self.logger.info('Message is a SPAM. Automatic ticket insertion refused (SPAM level > %d)' %self.parameters.spam_level)
257                        return 'drop'   
258
259                elif spam:
260
261                        return 'Spam'   
262                else:
263
264                        return False
265
266        def email_header_acl(self, keyword, header_field, default):
267                """
268                This function wil check if the email address is allowed or denied
269                to send mail to the ticket list
270            """
271                self.logger.debug('function email_header_acl: %s' %keyword)
272
273                try:
274                        mail_addresses = self.parameters[keyword]
275
276                        # Check if we have an empty string
277                        #
278                        if not mail_addresses:
279                                return default
280
281                except KeyError, detail:
282                        self.logger.debug('%s not defined, all messages are allowed.' %(keyword))
283
284                        return default
285
286                mail_addresses = string.split(mail_addresses, ',')
287
288                for entry in mail_addresses:
289                        entry = entry.strip()
290                        TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
291                        result =  TO_RE.search(header_field)
292                        if result:
293                                return True
294
295                return False
296
297        def email_header_txt(self, m):
298                """
299                Display To and CC addresses in description field
300                """
301                s = ''
302
303                if m['To'] and len(m['To']) > 0:
304                        s = "'''To:''' %s\r\n" %(m['To'])
305                if m['Cc'] and len(m['Cc']) > 0:
306                        s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
307
308                return  self.email_to_unicode(s)
309
310
311        def get_sender_info(self, message):
312                """
313                Get the default author name and email address from the message
314                """
315
316                self.email_to = self.email_to_unicode(message['to'])
317                self.to_name, self.to_email_addr = email.Utils.parseaddr (self.email_to)
318
319                self.email_from = self.email_to_unicode(message['from'])
320                self.email_name, self.email_addr  = email.Utils.parseaddr(self.email_from)
321
322                ## Trac can not handle author's name that contains spaces
323                #
324                if self.email_addr == self.trac_smtp_from:
325                        if self.email_name:
326                                self.author = self.email_name
327                        else:
328                                self.author = "email2trac"
329                else:
330                        self.author = self.email_addr
331
332                if self.parameters.ignore_trac_user_settings:
333                        return
334
335                # Is this a registered user, use email address as search key:
336                # result:
337                #   u : login name
338                #   n : Name that the user has set in the settings tab
339                #   e : email address that the user has set in the settings tab
340                #
341                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
342                        if (
343                                (e and (e.lower() == self.email_addr.lower())) or
344                            (u + '@' + self.smtp_default_domain.lower() == self.email_addr.lower())
345                        )
346                        ]
347
348                if len(users) >= 1:
349                        self.email_from = users[0][0]
350                        self.author = users[0][0]
351
352        def set_reply_fields(self, ticket, message):
353                """
354                Set all the right fields for a new ticket
355                """
356                self.logger.debug('function set_reply_fields')
357
358                ## Only use name or email adress
359                #ticket['reporter'] = self.email_from
360                ticket['reporter'] = self.author
361
362
363                # Put all CC-addresses in ticket CC field
364                #
365                if self.parameters.reply_all:
366
367                        email_cc = ''
368
369                        cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
370
371                        if not cc_addrs:
372                                return
373
374                        ## Build a list of forbidden CC addresses
375                        #
376                        #to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
377                        #to_list = list()
378                        #for n,e in to_addrs:
379                        #       to_list.append(e)
380                               
381                        # Always Remove reporter email address from cc-list
382                        #
383                        try:
384                                cc_addrs.remove((self.author, self.email_addr))
385                        except ValueError, detail:
386                                pass
387
388                        for name,addr in cc_addrs:
389               
390                                ## Prevent mail loop
391                                #
392                                #if addr in to_list:
393
394                                if addr == self.trac_smtp_from:
395                                        self.logger.debug("Skipping %s mail address for CC-field" %(addr))
396                                        continue
397
398                                if email_cc:
399                                        email_cc = '%s, %s' %(email_cc, addr)
400                                else:
401                                        email_cc = addr
402
403                        if email_cc:
404                                self.logger.debug('set_reply_fields: %s' %email_cc)
405
406                                ticket['cc'] = self.email_to_unicode(email_cc)
407
408
409########## DEBUG functions  ###########################################################
410
411        def debug_body(self, message_body, tempfile=False):
412                if tempfile:
413                        import tempfile
414                        body_file = tempfile.mktemp('.email2trac')
415                else:
416                        body_file = os.path.join(self.parameters.tmpdir, 'body.txt')
417
418                if self.parameters.dry_run:
419                        print 'DRY-RUN: not saving body to %s' %(body_file)
420                        return
421
422                print 'writing body to %s' %(body_file)
423                fx = open(body_file, 'wb')
424                if not message_body:
425                                message_body = '(None)'
426
427                message_body = message_body.encode('utf-8')
428                #message_body = unicode(message_body, 'iso-8859-15')
429
430                fx.write(message_body)
431                fx.close()
432                try:
433                        os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
434                except OSError:
435                        pass
436
437        def debug_attachments(self, message_parts):
438                """
439                """
440                self.logger.debug('function debug_attachments')
441               
442                n = 0
443                for item in message_parts:
444                        # Skip inline text parts
445                        if not isinstance(item, tuple):
446                                continue
447                               
448                        (original, filename, part) = item
449
450                        n = n + 1
451                        print 'part%d: Content-Type: %s' % (n, part.get_content_type())
452               
453                        s = 'part%d: filename: %s' %(n, filename)
454                        self.print_unicode(s)
455       
456                        ## Forbidden chars
457                        #
458                        filename = filename.replace('\\', '_')
459                        filename = filename.replace('/', '_')
460       
461
462                        part_file = os.path.join(self.parameters.tmpdir, filename)
463                        s = 'writing part%d (%s)' % (n,part_file)
464                        self.print_unicode(s)
465
466                        if self.parameters.dry_run:
467                                print 'DRY_RUN: NOT saving attachments'
468                                continue
469
470                        part_file = util.text.unicode_quote(part_file)
471
472                        fx = open(part_file, 'wb')
473                        text = part.get_payload(decode=1)
474
475                        if not text:
476                                text = '(None)'
477
478                        fx.write(text)
479                        fx.close()
480
481                        try:
482                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
483                        except OSError:
484                                pass
485
486        def save_email_for_debug(self, message, create_tempfile=False):
487
488                if create_tempfile:
489                        import tempfile
490                        msg_file = tempfile.mktemp('.email2trac')
491                else:
492                        #msg_file = '/var/tmp/msg.txt'
493                        msg_file = os.path.join(self.parameters.tmpdir, 'msg.txt')
494
495                if self.parameters.dry_run:
496                        print 'DRY_RUN: NOT saving email message to %s' %(msg_file)
497                else:
498                        print 'saving email to %s' %(msg_file)
499
500                        fx = open(msg_file, 'wb')
501                        fx.write('%s' % message)
502                        fx.close()
503                       
504                        try:
505                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
506                        except OSError:
507                                pass
508
509                message_parts = self.get_message_parts(message)
510                message_parts = self.unique_attachment_names(message_parts)
511                body_text = self.get_body_text(message_parts)
512                self.debug_body(body_text, True)
513                self.debug_attachments(message_parts)
514
515########## Conversion functions  ###########################################################
516
517        def email_to_unicode(self, message_str):
518                """
519                Email has 7 bit ASCII code, convert it to unicode with the charset
520                that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
521                understands it.
522                """
523                self.logger.debug("function email_to_unicode")
524
525                results =  email.Header.decode_header(message_str)
526
527                s = None
528                for text,format in results:
529                        if format:
530                                try:
531                                        temp = unicode(text, format)
532                                except UnicodeError, detail:
533                                        # This always works
534                                        #
535                                        temp = unicode(text, 'iso-8859-15')
536                                except LookupError, detail:
537                                        #text = 'ERROR: Could not find charset: %s, please install' %format
538                                        #temp = unicode(text, 'iso-8859-15')
539                                        temp = message_str
540                                       
541                        else:
542                                temp = string.strip(text)
543                                temp = unicode(text, 'iso-8859-15')
544
545                        if s:
546                                s = '%s %s' %(s, temp)
547                        else:
548                                s = '%s' %temp
549
550                #s = s.encode('utf-8')
551                return s
552
553        def str_to_dict(self, s):
554                """
555                Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
556                """
557                self.logger.debug("function str_to_dict")
558
559                fields = string.split(s, self.parameters.subject_field_separator)
560
561                result = dict()
562                for field in fields:
563                        try:
564                                index, value = string.split(field, '=')
565
566                                # We can not change the description of a ticket via the subject
567                                # line. The description is the body of the email
568                                #
569                                if index.lower() in ['description']:
570                                        continue
571
572                                if value:
573                                        result[index.lower()] = value
574
575                        except ValueError:
576                                pass
577                return result
578
579        def print_unicode(self,s):
580                """
581                This function prints unicode strings uif possible else it will quote it
582                """
583                try:
584                        self.logger.debug(s)
585                except UnicodeEncodeError, detail:
586                        self.logger.debug(util.text.unicode_quote(s))
587
588########## TRAC ticket functions  ###########################################################
589
590        def check_permission_participants(self, tkt, action):
591                """
592                Check if the mailer is allowed to update the ticket
593                """
594                self.logger.debug('function check_permission_participants')
595
596                if tkt['reporter'].lower() in [self.author, self.email_addr]:
597                        self.logger.debug('ALLOW, %s is the ticket reporter' %(self.email_addr))
598
599                        return True
600
601                perm = PermissionSystem(self.env)
602                if perm.check_permission(action, self.author):
603                        self.logger.debug('ALLOW, %s has trac permission to update the ticket' %(self.author))
604
605                        return True
606               
607                # Is the updater in the CC?
608                try:
609                        cc_list = tkt['cc'].split(',')
610                        for cc in cc_list:
611                                if self.email_addr.lower() in cc.strip():
612                                        self.logger.debug('ALLOW, %s is in the CC' %(self.email_addr))
613
614                                        return True
615
616                except KeyError:
617                        pass
618
619                return False
620
621        def check_permission(self, tkt, action):
622                """
623                check if the reporter has the right permission for the action:
624          - TICKET_CREATE
625          - TICKET_MODIFY
626          - TICKET_APPEND
627          - TICKET_CHGPROP
628
629                There are three models:
630                        - None      : no checking at all
631                        - trac      : check the permission via trac permission model
632                        - email2trac: ....
633                """
634                self.logger.debug("function check_permission")
635
636                if self.parameters.ticket_permission_system in ['trac']:
637
638                        perm = PermissionSystem(self.env)
639                        if perm.check_permission(action, self.author):
640                                return True
641                        else:
642                                return False
643
644                elif self.parameters.ticket_permission_system in ['update_restricted_to_participants']:
645                        return (self.check_permission_participants(tkt, action))       
646
647                ## Default is to allow everybody ticket updates and ticket creation
648                #
649                else:
650                                return True
651
652
653        def update_ticket_fields(self, ticket, user_dict, new=None):
654                """
655                This will update the ticket fields. It will check if the
656                given fields are known and if the right values are specified
657                It will only update the ticket field value:
658                        - If the field is known
659                        - If the value supplied is valid for the ticket field.
660                          If not then there are two options:
661                           1) Skip the value (new=None)
662                           2) Set default value for field (new=1)
663                """
664                self.logger.debug("function update_ticket_fields")
665
666                ## Check only permission model on ticket updates
667                #
668                if not new:
669                        if self.parameters.ticket_permission_system:
670                                if not self.check_permission(ticket, 'TICKET_CHGPROP'):
671                                        self.logger.info('Reporter: %s has no permission to change ticket properties' %self.author)
672                                        return False
673
674                ## Build a system dictionary from the ticket fields
675                #  with field as index and option as value
676                #
677                sys_dict = dict()
678                for field in ticket.fields:
679                        try:
680                                sys_dict[field['name']] = field['options']
681
682                        except KeyError:
683                                #sys_dict[field['name']] = None
684                                pass
685
686                ## Check user supplied fields an compare them with the
687                #  system one's
688                #
689                for field,value in user_dict.items():
690                        if self.parameters.debug:
691                                s = 'user_field\t %s = %s' %(field,value)
692                                self.print_unicode(s)
693
694                        ## To prevent mail loop
695                        #
696                        if field == 'cc':
697
698                                cc_list = user_dict['cc'].split(',')
699
700                                if self.trac_smtp_from in cc_list:
701                                        self.logger.debug('MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from))
702
703                                        cc_list.remove(self.trac_smtp_from)
704
705                                value = ','.join(cc_list)
706                               
707
708                        ## Check if every value is allowed for this filed
709                        #
710                        if sys_dict.has_key(field):
711
712                                if value in sys_dict[field]:
713                                        ticket[field] = value
714                                else:
715                                        ## Must we set a default if value is not allowed
716                                        #
717                                        if new:
718                                                value = self.get_config('ticket', 'default_%s' %(field) )
719
720                        else:
721                                ## Only set if we have a value
722                                #
723                                #if value:
724                                #       ticket[field] = value
725                                ticket[field] = value
726
727                        if self.parameters.debug:
728                                s = 'ticket_field\t %s = %s' %(field,  ticket[field])
729                                self.print_unicode(s)
730
731        def ticket_update(self, m, id, spam):
732                """
733                If the current email is a reply to an existing ticket, this function
734                will append the contents of this email to that ticket, instead of
735                creating a new one.
736                """
737                self.logger.debug("function ticket_update")
738
739                if not self.parameters.ticket_update:
740                        self.logger.debug("ticket_update disabled")
741                        return False
742
743                ## Must we update ticket fields
744                #
745                update_fields = dict()
746                try:
747                        id, keywords = string.split(id, '?')
748
749                        update_fields = self.str_to_dict(keywords)
750
751                        ## Strip '#'
752                        #
753                        self.id = int(id[1:])
754
755                except ValueError:
756
757                        ## Strip '#'
758                        #
759                        self.id = int(id[1:])
760
761                self.logger.debug("ticket_update id %s" %id)
762
763                ## When is the change committed
764                #
765                if self.VERSION < 0.11:
766                        when = int(time.time())
767                else:
768                        when = datetime.now(util.datefmt.utc)
769
770                try:
771                        tkt = Ticket(self.env, self.id, self.db)
772
773                except util.TracError, detail:
774
775                        ## Not a valid ticket
776                        #
777                        self.id = None
778                        return False
779
780                ## Check the permission of the reporter
781                #
782                if self.parameters.ticket_permission_system:
783                        if not self.check_permission(tkt, 'TICKET_APPEND'):
784                                self.logger.info('Reporter: %s has no permission to add comments or attachments to tickets' %self.author)
785                                return False
786
787                ## How many changes has this ticket
788                #
789                # cnum = len(tkt.get_changelog())
790                grouped = TicketModule(self.env).grouped_changelog_entries(tkt, self.db)
791                cnum = sum(1 for e in grouped) + 1
792
793
794                ## reopen the ticket if it is was closed
795                #  We must use the ticket workflow framework
796                #
797                if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
798
799                        self.logger.debug('Workflow ticket update fields: ')
800
801                        from trac.ticket.default_workflow import ConfigurableTicketWorkflow
802                        from trac.test import Mock, MockPerm
803
804                        req = Mock(authname=self.author, perm=MockPerm(), args={})
805                        try:
806                                workflow = self.parameters['workflow_%s' %tkt['status']]
807                        except KeyError:
808                                ## fallback for compability (Will be deprecated)
809                                #
810                                if tkt['status'] in ['closed']:
811                                        workflow = self.parameters.workflow
812                                else:   
813                                        workflow = None
814
815                        controller = ConfigurableTicketWorkflow(self.env)
816                        #print controller.actions
817                        #print controller.actions.keys()
818                        #print controller.get_ticket_actions(req, tkt)
819                        #print controller.actions[workflow]
820                        #print controller.actions[workflow]['permissions'] is a list
821
822                        if workflow:
823
824                                if self.parameters.ticket_permission_system:
825
826                                        if self.check_permission(tkt, controller.actions[workflow]['permissions'][0]):
827                                                fields = controller.get_ticket_changes(req, tkt, workflow)
828                                        else:
829                                                fields = dict()
830                                                self.logger.info('Reporter: %s has no permission to trigger workflow' %self.author)
831
832                                else:
833                                        fields = controller.get_ticket_changes(req, tkt, workflow)
834
835                                for key in fields.keys():
836                                        self.logger.debug('\t %s : %s' %(key, fields[key]))
837                                        tkt[key] = fields[key]
838
839                ## Old pre 0.11 situation
840                #
841                elif self.parameters.email_triggers_workflow:
842
843                        self.logger.debug('email triggers workflow pre trac 0.11')
844
845                        if tkt['status'] in ['closed']:
846                                tkt['status'] = 'reopened'
847                                tkt['resolution'] = ''
848
849                else:
850                        self.logger.debug('email triggers workflow disabled')
851
852                ## Must we update some ticket fields properties via subject line
853                #
854                if update_fields:
855                        self.update_ticket_fields(tkt, update_fields)
856
857                message_parts = self.get_message_parts(m)
858                message_parts = self.unique_attachment_names(message_parts)
859
860                ## Must we update some ticket fields properties via body_text
861                #
862                if self.properties:
863                                self.update_ticket_fields(tkt, self.properties)
864
865                if self.parameters.email_header:
866                        message_parts.insert(0, self.email_header_txt(m))
867
868                body_text = self.get_body_text(message_parts)
869
870                error_with_attachments = self.attach_attachments(message_parts)
871
872                if body_text.strip() or update_fields or self.properties:
873                        if self.parameters.dry_run:
874                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
875                        else:
876                                if error_with_attachments:
877                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
878                                self.logger.debug('tkt.save_changes(%s, %d)' %(self.author, cnum))
879                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
880                       
881
882                if not spam:
883                        self.notify(tkt, False, when)
884
885                return True
886
887        def set_ticket_fields(self, ticket):
888                """
889                set the ticket fields to value specified
890                        - /etc/email2trac.conf with <prefix>_<field>
891                        - trac default values, trac.ini
892                """
893                self.logger.debug('function set_ticket_fields')
894
895                user_dict = dict()
896
897                for field in ticket.fields:
898
899                        name = field['name']
900
901                        ## default trac value
902                        #
903                        if not field.get('custom'):
904                                value = self.get_config('ticket', 'default_%s' %(name) )
905                                if (name in ['resolution']) and (value in ['fixed']):
906                                        value = None
907                        else:
908                                ##  Else get the default value for reporter
909                                #
910                                value = field.get('value')
911                                options = field.get('options')
912
913                                if value and options and (value not in options):
914                                         value = options[int(value)]
915       
916                        if self.parameters.debug:
917                                s = 'trac[%s] = %s' %(name, value)
918                                self.print_unicode(s)
919
920                        ## email2trac.conf settings
921                        #
922                        prefix = self.parameters.ticket_prefix
923                        try:
924                                value = self.parameters['%s_%s' %(prefix, name)]
925                                if self.parameters.debug:
926                                        s = 'email2trac[%s] = %s ' %(name, value)
927                                        self.print_unicode(s)
928
929                        except KeyError, detail:
930                                pass
931               
932                        if value:
933                                user_dict[name] = value
934                                if self.parameters.debug:
935                                        s = 'used %s = %s' %(name, value)
936                                        self.print_unicode(s)
937
938                self.update_ticket_fields(ticket, user_dict, new=1)
939
940                if 'status' not in user_dict.keys():
941                        ticket['status'] = 'new'
942
943
944        def ticket_update_by_subject(self, subject):
945                """
946                This list of Re: prefixes is probably incomplete. Taken from
947                wikipedia. Here is how the subject is matched
948                  - Re: <subject>
949                  - Re: (<Mail list label>:)+ <subject>
950
951                So we must have the last column
952                """
953                self.logger.debug('function ticket_update_by_subject')
954
955                matched_id = None
956                if self.parameters.ticket_update and self.parameters.ticket_update_by_subject:
957                               
958                        SUBJECT_RE = re.compile(r'^(?:(?:RE|AW|VS|SV|FW|FWD):\s*)+(.*)', re.IGNORECASE)
959                        result = SUBJECT_RE.search(subject)
960
961                        if result:
962                                ## This is a reply
963                                #
964                                orig_subject = result.group(1)
965
966                                self.logger.debug('subject search string: %s' %(orig_subject))
967
968                                cursor = self.db.cursor()
969                                summaries = [orig_subject, '%%: %s' % orig_subject]
970
971                                ## Convert days to seconds
972                                #
973                                lookback = int(time.mktime(time.gmtime())) - \
974                                                self.parameters.ticket_update_by_subject_lookback * 24 * 3600
975
976
977                                for summary in summaries:
978                                        self.logger.debug('Looking for summary matching: "%s"' % summary)
979
980                                        sql = """SELECT id FROM ticket
981                                                        WHERE changetime >= %s AND summary LIKE %s
982                                                        ORDER BY changetime DESC"""
983                                        cursor.execute(sql, [lookback, summary.strip()])
984
985                                        for row in cursor:
986                                                (matched_id,) = row
987
988                                                self.logger.debug('Found matching ticket id: %d' % matched_id)
989
990                                                break
991
992                                        if matched_id:
993                                                matched_id = '#%d' % matched_id
994                                                return (matched_id, orig_subject)
995                                       
996                                        subject = orig_subject
997
998                return (matched_id, subject)
999
1000
1001        def new_ticket(self, msg, subject, spam, set_fields = None):
1002                """
1003                Create a new ticket
1004                """
1005                self.logger.debug('function new_ticket')
1006
1007                tkt = Ticket(self.env)
1008
1009                self.set_reply_fields(tkt, msg)
1010
1011                self.set_ticket_fields(tkt)
1012
1013                ## Check the permission of the reporter
1014                #
1015                if self.parameters.ticket_permission_system:
1016                        if not self.check_permission(tkt, 'TICKET_CREATE'):
1017                                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1018                                return False
1019
1020                ## Old style setting for component, will be removed
1021                #
1022                if spam:
1023                        tkt['component'] = 'Spam'
1024
1025                elif self.parameters.has_key('component'):
1026                        tkt['component'] = self.parameters['component']
1027
1028                if not msg['Subject']:
1029                        tkt['summary'] = u'(No subject)'
1030                else:
1031                        tkt['summary'] = subject
1032
1033
1034                if set_fields:
1035                        rest, keywords = string.split(set_fields, '?')
1036
1037                        if keywords:
1038                                update_fields = self.str_to_dict(keywords)
1039                                self.update_ticket_fields(tkt, update_fields)
1040
1041
1042                message_parts = self.get_message_parts(msg)
1043
1044                ## Must we update some ticket fields properties via body_text
1045                #
1046                if self.properties:
1047                                self.update_ticket_fields(tkt, self.properties)
1048
1049                message_parts = self.unique_attachment_names(message_parts)
1050               
1051                ## produce e-mail like header
1052                #
1053                head = ''
1054                if self.parameters.email_header:
1055                        head = self.email_header_txt(msg)
1056                        message_parts.insert(0, head)
1057                       
1058                body_text = self.get_body_text(message_parts)
1059
1060                tkt['description'] = body_text
1061
1062                ## When is the change committed
1063                #
1064                if self.VERSION < 0.11:
1065                        when = int(time.time())
1066                else:
1067                        when = datetime.now(util.datefmt.utc)
1068
1069                if self.parameters.dry_run:
1070                        print 'DRY_RUN: tkt.insert()'
1071                else:
1072                        self.id = tkt.insert()
1073       
1074                changed = False
1075                comment = ''
1076
1077                ## some routines in trac are dependend on ticket id     
1078                #  like alternate notify template
1079                #
1080                if self.parameters.alternate_notify_template:
1081                        tkt['id'] = self.id
1082                        changed = True
1083
1084                ## Rewrite the description if we have mailto enabled
1085                #
1086                if self.parameters.mailto_link:
1087                        changed = True
1088                        comment = u'\nadded mailto line\n'
1089                        mailto = self.html_mailto_link( m['Subject'])
1090
1091                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1092                                %(head, mailto, body_text)
1093       
1094                ## Save the attachments to the ticket   
1095                #
1096                error_with_attachments =  self.attach_attachments(message_parts)
1097
1098                if error_with_attachments:
1099                        changed = True
1100                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1101
1102                if changed:
1103                        if self.parameters.dry_run:
1104                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1105                        else:
1106                                tkt.save_changes(tkt['reporter'], comment)
1107                                #print tkt.get_changelog(self.db, when)
1108
1109                if not spam:
1110                        self.notify(tkt, True)
1111
1112
1113        def attach_attachments(self, message_parts, update=False):
1114                '''
1115                save any attachments as files in the ticket's directory
1116                '''
1117                self.logger.debug('function attach_attachments()')
1118
1119                if self.parameters.dry_run:
1120                        print "DRY_RUN: no attachments attached to tickets"
1121                        return ''
1122
1123                count = 0
1124
1125                ## Get Maxium attachment size
1126                #
1127                max_size = int(self.get_config('attachment', 'max_size'))
1128                status   = None
1129               
1130                for item in message_parts:
1131                        ## Skip body parts
1132                        #
1133                        if not isinstance(item, tuple):
1134                                continue
1135                               
1136                        (original, filename, part) = item
1137
1138                        ## We have to determine the size so we use this temporary solution. we must escape it
1139                        #  else we get UnicodeErrors.
1140                        #
1141                        path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, util.text.unicode_quote(filename)))
1142                        text = part.get_payload(decode=1)
1143                        if not text:
1144                                text = '(None)'
1145                        fd.write(text)
1146                        fd.close()
1147
1148                        ## get the file_size
1149                        #
1150                        stats = os.lstat(path)
1151                        file_size = stats[ST_SIZE]
1152
1153                        ## Check if the attachment size is allowed
1154                        #
1155                        if (max_size != -1) and (file_size > max_size):
1156                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1157                                        %(status, original, file_size, max_size)
1158
1159                                os.unlink(path)
1160                                continue
1161                        else:
1162                                count = count + 1
1163                                       
1164                        ## Insert the attachment
1165                        #
1166                        fd = open(path, 'rb')
1167                        if self.system == 'discussion':
1168                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1169                                  % (self.id,))
1170                        else:
1171                                self.logger.debug('Attach %s to ticket %d' %(util.text.unicode_quote(filename), self.id))
1172                                att = attachment.Attachment(self.env, 'ticket', self.id)
1173 
1174                        ## This will break the ticket_update system, the body_text is vaporized
1175                        #  ;-(
1176                        #
1177                        if not update:
1178                                att.author = self.author
1179                                att.description = self.email_to_unicode('Added by email2trac')
1180
1181                        try:
1182                                self.logger.debug('Insert atachment')
1183                                att.insert(filename, fd, file_size)
1184                        except OSError, detail:
1185                                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1186                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1187
1188                        ## Remove the created temporary filename
1189                        #
1190                        fd.close()
1191                        os.unlink(path)
1192
1193                ## return error
1194                #
1195                return status
1196
1197########## Fullblog functions  #################################################
1198
1199        def blog(self, id):
1200                """
1201                The blog create/update function
1202                """
1203                ## import the modules
1204                #
1205                from tracfullblog.core import FullBlogCore
1206                from tracfullblog.model import BlogPost, BlogComment
1207                from trac.test import Mock, MockPerm
1208
1209                ## instantiate blog core
1210                #
1211                blog = FullBlogCore(self.env)
1212                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1213
1214                if id:
1215
1216                        ## update blog
1217                        #
1218                        comment = BlogComment(self.env, id)
1219                        comment.author = self.author
1220
1221                        message_parts = self.get_message_parts(m)
1222                        comment.comment = self.get_body_text(message_parts)
1223
1224                        blog.create_comment(req, comment)
1225
1226                else:
1227                        ## create blog
1228                        #
1229                        import time
1230                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1231
1232                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1233                       
1234                        post.author = self.author
1235                        post.title = self.email_to_unicode(m['Subject'])
1236
1237                        message_parts = self.get_message_parts(m)
1238                        post.body = self.get_body_text(message_parts)
1239                       
1240                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1241
1242
1243########## Discussion functions  ##############################################
1244
1245        def discussion_topic(self, content, subject):
1246
1247                ## Import modules.
1248                #
1249                from tracdiscussion.api import DiscussionApi
1250                from trac.util.datefmt import to_timestamp, utc
1251
1252                self.logger.debug('Creating a new topic in forum:', self.id)
1253
1254                ## Get dissussion API component.
1255                #
1256                api = self.env[DiscussionApi]
1257                context = self._create_context(content, subject)
1258
1259                ## Get forum for new topic.
1260                #
1261                forum = api.get_forum(context, self.id)
1262
1263                if not forum:
1264                        self.logger.error("ERROR: Replied forum doesn't exist")
1265
1266                ## Prepare topic.
1267                #
1268                topic = {'forum' : forum['id'],
1269                                 'subject' : context.subject,
1270                                 'time': to_timestamp(datetime.now(utc)),
1271                                 'author' : self.author,
1272                                 'subscribers' : [self.email_addr],
1273                                 'body' : self.get_body_text(context.content_parts)}
1274
1275                ## Add topic to DB and commit it.
1276                #
1277                self._add_topic(api, context, topic)
1278                self.db.commit()
1279
1280        def discussion_topic_reply(self, content, subject):
1281
1282                ## Import modules.
1283                #
1284                from tracdiscussion.api import DiscussionApi
1285                from trac.util.datefmt import to_timestamp, utc
1286
1287                self.logger.debug('Replying to discussion topic', self.id)
1288
1289                ## Get dissussion API component.
1290                #
1291                api = self.env[DiscussionApi]
1292                context = self._create_context(content, subject)
1293
1294                ## Get replied topic.
1295                #
1296                topic = api.get_topic(context, self.id)
1297
1298                if not topic:
1299                        self.logger.error("ERROR: Replied topic doesn't exist")
1300
1301                ## Prepare message.
1302                #
1303                message = {'forum' : topic['forum'],
1304                                   'topic' : topic['id'],
1305                                   'replyto' : -1,
1306                                   'time' : to_timestamp(datetime.now(utc)),
1307                                   'author' : self.author,
1308                                   'body' : self.get_body_text(context.content_parts)}
1309
1310                ## Add message to DB and commit it.
1311                #
1312                self._add_message(api, context, message)
1313                self.db.commit()
1314
1315        def discussion_message_reply(self, content, subject):
1316
1317                ## Import modules.
1318                #
1319                from tracdiscussion.api import DiscussionApi
1320                from trac.util.datefmt import to_timestamp, utc
1321
1322                self.loggger.debug('Replying to discussion message', self.id)
1323
1324                ## Get dissussion API component.
1325                #
1326                api = self.env[DiscussionApi]
1327                context = self._create_context(content, subject)
1328
1329                ## Get replied message.
1330                #
1331                message = api.get_message(context, self.id)
1332
1333                if not message:
1334                        self.logger.error("ERROR: Replied message doesn't exist")
1335
1336                ## Prepare message.
1337                #
1338                message = {'forum' : message['forum'],
1339                                   'topic' : message['topic'],
1340                                   'replyto' : message['id'],
1341                                   'time' : to_timestamp(datetime.now(utc)),
1342                                   'author' : self.author,
1343                                   'body' : self.get_body_text(context.content_parts)}
1344
1345                ## Add message to DB and commit it.
1346                #
1347                self._add_message(api, context, message)
1348                self.db.commit()
1349
1350        def _create_context(self, content, subject):
1351
1352                ## Import modules.
1353                #
1354                from trac.mimeview import Context
1355                from trac.web.api import Request
1356                from trac.perm import PermissionCache
1357
1358                ## TODO: Read server base URL from config.
1359                #  Create request object to mockup context creation.
1360                #
1361                environ = {'SERVER_PORT' : 80,
1362                                   'SERVER_NAME' : 'test',
1363                                   'REQUEST_METHOD' : 'POST',
1364                                   'wsgi.url_scheme' : 'http',
1365                                   'wsgi.input' : sys.stdin}
1366                chrome =  {'links': {},
1367                                   'scripts': [],
1368                                   'ctxtnav': [],
1369                                   'warnings': [],
1370                                   'notices': []}
1371
1372                if self.env.base_url_for_redirect:
1373                        environ['trac.base_url'] = self.env.base_url
1374
1375                req = Request(environ, None)
1376                req.chrome = chrome
1377                req.tz = 'missing'
1378                req.authname = self.author
1379                req.perm = PermissionCache(self.env, self.author)
1380
1381                ## Create and return context.
1382                #
1383                context = Context.from_request(req)
1384                context.realm = 'discussion-email2trac'
1385                context.cursor = self.db.cursor()
1386                context.content = content
1387                context.subject = subject
1388
1389                ## Read content parts from content.
1390                #
1391                context.content_parts = self.get_message_parts(content)
1392                context.content_parts = self.unique_attachment_names(
1393                  context.content_parts)
1394
1395                return context
1396
1397        def _add_topic(self, api, context, topic):
1398                context.req.perm.assert_permission('DISCUSSION_APPEND')
1399
1400                ## Filter topic.
1401                #
1402                for discussion_filter in api.discussion_filters:
1403                        accept, topic_or_error = discussion_filter.filter_topic(
1404                          context, topic)
1405                        if accept:
1406                                topic = topic_or_error
1407                        else:
1408                                raise TracError(topic_or_error)
1409
1410                ## Add a new topic.
1411                #
1412                api.add_topic(context, topic)
1413
1414                ## Get inserted topic with new ID.
1415                #
1416                topic = api.get_topic_by_time(context, topic['time'])
1417
1418                ## Attach attachments.
1419                #
1420                self.id = topic['id']
1421                self.attach_attachments(context.content_parts, True)
1422
1423                ## Notify change listeners.
1424                #
1425                for listener in api.topic_change_listeners:
1426                        listener.topic_created(context, topic)
1427
1428        def _add_message(self, api, context, message):
1429                context.req.perm.assert_permission('DISCUSSION_APPEND')
1430
1431                ## Filter message.
1432                #
1433                for discussion_filter in api.discussion_filters:
1434                        accept, message_or_error = discussion_filter.filter_message(
1435                          context, message)
1436                        if accept:
1437                                message = message_or_error
1438                        else:
1439                                raise TracError(message_or_error)
1440
1441                ## Add message.
1442                #
1443                api.add_message(context, message)
1444
1445                ## Get inserted message with new ID.
1446                #
1447                message = api.get_message_by_time(context, message['time'])
1448
1449                ## Attach attachments.
1450                #
1451                self.id = message['topic']
1452                self.attach_attachments(context.content_parts, True)
1453
1454                ## Notify change listeners.
1455                #
1456                for listener in api.message_change_listeners:
1457                        listener.message_created(context, message)
1458
1459########## MAIN function  ######################################################
1460
1461        def parse(self, fp):
1462                """
1463                """
1464                self.logger.debug('Main function parse')
1465                global m
1466
1467                m = email.message_from_file(fp)
1468               
1469                if not m:
1470                        self.logger.debug('This is not a valid email message format')
1471                        return
1472                       
1473                ## Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1474                #
1475                try:
1476                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1477                except AttributeError, detail:
1478                        pass
1479
1480                if self.parameters.debug:         # save the entire e-mail message text
1481                        self.save_email_for_debug(m, True)
1482
1483                self.db = self.env.get_db_cnx()
1484                self.get_sender_info(m)
1485
1486                if not self.email_header_acl('white_list', self.email_addr, True):
1487                        self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1488                        return False
1489
1490                if self.email_header_acl('black_list', self.email_addr, False):
1491                        self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1492                        return False
1493
1494                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1495                        self.logger.info('Message rejected : %s not in recipient list' %(self.to_email_addr))
1496                        return False
1497
1498                ## If spam drop the message
1499                #
1500                if self.spam(m) == 'drop':
1501                        return False
1502
1503                elif self.spam(m) == 'spam':
1504                        spam_msg = True
1505                else:
1506                        spam_msg = False
1507
1508                if not m['Subject']:
1509                        subject  = 'No Subject'
1510                else:
1511                        subject  = self.email_to_unicode(m['Subject'])
1512
1513                self.logger.debug('subject: %s' %subject)
1514
1515                ## [hic] #1529: Re: LRZ
1516                #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1517                #
1518                ticket_regex = r'''
1519                        (?P<new_fields>[#][?].*)
1520                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1521                        '''
1522                ## Check if  FullBlogPlugin is installed
1523                #
1524                blog_enabled = None
1525                blog_regex = ''
1526                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1527                        blog_enabled = True
1528                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1529
1530
1531                ## Check if DiscussionPlugin is installed
1532                #
1533                discussion_enabled = None
1534                discussion_regex = ''
1535                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1536                        discussion_enabled = True
1537                        discussion_regex = r'''
1538                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1539                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1540                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1541                        '''
1542
1543
1544                regex_str = ticket_regex + blog_regex + discussion_regex
1545                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1546
1547                ## Find out if this is a ticket, a blog or a discussion
1548                #
1549                result =  SYSTEM_RE.search(subject)
1550
1551                if result:
1552                        ## update ticket + fields
1553                        #
1554                        if result.group('reply'):
1555                                self.system = 'ticket'
1556
1557                                ## Skip the last ':' character
1558                                #
1559                                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1560                                        self.new_ticket(m, subject, spam_msg)
1561
1562                        ## New ticket + fields
1563                        #
1564                        elif result.group('new_fields'):
1565                                self.system = 'ticket'
1566                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1567
1568                        if blog_enabled:
1569                                if result.group('blog'):
1570                                        self.system = 'blog'
1571                                        self.blog(result.group('blog_id'))
1572
1573                        if discussion_enabled:
1574                                ## New topic.
1575                                #
1576                                if result.group('forum'):
1577                                        self.system = 'discussion'
1578                                        self.id = int(result.group('forum_id'))
1579                                        self.discussion_topic(m, subject[result.end('forum'):])
1580
1581                                ## Reply to topic.
1582                                #
1583                                elif result.group('topic'):
1584                                        self.system = 'discussion'
1585                                        self.id = int(result.group('topic_id'))
1586                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1587
1588                                ## Reply to topic message.
1589                                #
1590                                elif result.group('message'):
1591                                        self.system = 'discussion'
1592                                        self.id = int(result.group('message_id'))
1593                                        self.discussion_message_reply(m, subject[result.end('message'):])
1594
1595                else:
1596                        self.system = 'ticket'
1597                        (matched_id, subject) = self.ticket_update_by_subject(subject)
1598                        if matched_id:
1599                                if not self.ticket_update(m, matched_id, spam_msg):
1600                                        self.new_ticket(m, subject, spam_msg)
1601                        else:
1602                                ## No update by subject, so just create a new ticket
1603                                #
1604                                self.new_ticket(m, subject, spam_msg)
1605
1606
1607########## BODY TEXT functions  ###########################################################
1608
1609        def strip_signature(self, text):
1610                """
1611                Strip signature from message, inspired by Mailman software
1612                """
1613                self.logger.debug('function strip_signature')
1614
1615                body = []
1616                for line in text.splitlines():
1617                        if line == '-- ':
1618                                break
1619                        body.append(line)
1620
1621                return ('\n'.join(body))
1622
1623        def reflow(self, text, delsp = 0):
1624                """
1625                Reflow the message based on the format="flowed" specification (RFC 3676)
1626                """
1627                flowedlines = []
1628                quotelevel = 0
1629                prevflowed = 0
1630
1631                for line in text.splitlines():
1632                        from re import match
1633                       
1634                        ## Figure out the quote level and the content of the current line
1635                        #
1636                        m = match('(>*)( ?)(.*)', line)
1637                        linequotelevel = len(m.group(1))
1638                        line = m.group(3)
1639
1640                        ## Determine whether this line is flowed
1641                        #
1642                        if line and line != '-- ' and line[-1] == ' ':
1643                                flowed = 1
1644                        else:
1645                                flowed = 0
1646
1647                        if flowed and delsp and line and line[-1] == ' ':
1648                                line = line[:-1]
1649
1650                        ## If the previous line is flowed, append this line to it
1651                        #
1652                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1653                                flowedlines[-1] += line
1654
1655                        ## Otherwise, start a new line
1656                        #
1657                        else:
1658                                flowedlines.append('>' * linequotelevel + line)
1659
1660                        prevflowed = flowed
1661                       
1662
1663                return '\n'.join(flowedlines)
1664
1665        def strip_quotes(self, text):
1666                """
1667                Strip quotes from message by Nicolas Mendoza
1668                """
1669                self.logger.debug('function strip_quotes')
1670
1671                body = []
1672                for line in text.splitlines():
1673                        try:
1674
1675                                if line.startswith(self.parameters.email_quote):
1676                                        continue
1677
1678                        except UnicodeDecodeError:
1679
1680                                tmp_line = self.email_to_unicode(line)
1681                                if tmp_line.startswith(self.parameters.email_quote):
1682                                        continue
1683                               
1684                        body.append(line)
1685
1686                return ('\n'.join(body))
1687
1688        def inline_properties(self, text):
1689                """
1690                Parse text if we use inline keywords to set ticket fields
1691                """
1692                self.logger.debug('function inline_properties')
1693
1694                properties = dict()
1695                body = list()
1696
1697                INLINE_EXP = re.compile('\s*[@]\s*(\w+)\s*:(.*)$')
1698
1699                for line in text.splitlines():
1700                        match = INLINE_EXP.match(line)
1701                        if match:
1702                                keyword, value = match.groups()
1703                                self.properties[keyword] = value.strip()
1704
1705                                self.logger.debug('inline properties: %s : %s' %(keyword,value))
1706
1707                        else:
1708                                body.append(line)
1709                               
1710                return '\n'.join(body)
1711
1712
1713        def wrap_text(self, text, replace_whitespace = False):
1714                """
1715                Will break a lines longer then given length into several small
1716                lines of size given length
1717                """
1718                import textwrap
1719
1720                LINESEPARATOR = '\n'
1721                reformat = ''
1722
1723                for s in text.split(LINESEPARATOR):
1724                        tmp = textwrap.fill(s, self.parameters.use_textwrap)
1725                        if tmp:
1726                                reformat = '%s\n%s' %(reformat,tmp)
1727                        else:
1728                                reformat = '%s\n' %reformat
1729
1730                return reformat
1731
1732                # Python2.4 and higher
1733                #
1734                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1735                #
1736
1737########## EMAIL attachements functions ###########################################################
1738
1739        def inline_part(self, part):
1740                """
1741                """
1742                self.logger.debug('function inline_part()')
1743
1744                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1745
1746        def get_message_parts(self, msg):
1747                """
1748                parses the email message and returns a list of body parts and attachments
1749                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1750                """
1751                self.logger.debug('function get_message_parts()')
1752
1753                message_parts = list()
1754       
1755                ALTERNATIVE_MULTIPART = False
1756
1757                for part in msg.walk():
1758                        self.logger.debug('Message part: Main-Type: %s' % part.get_content_maintype())
1759                        self.logger.debug('Message part: Content-Type: %s' % part.get_content_type())
1760
1761                        ## Check content type
1762                        #
1763                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1764                                self.logger.debug("A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename()))
1765                                continue
1766
1767                        ## Catch some mulitpart execptions
1768                        #
1769                        if part.get_content_type() == 'multipart/alternative':
1770                                ALTERNATIVE_MULTIPART = True
1771                                continue
1772
1773                        ## Skip multipart containers
1774                        #
1775                        if part.get_content_maintype() == 'multipart':
1776                                self.logger.debug("Skipping multipart container")
1777
1778                                continue
1779                       
1780                        ## Check if this is an inline part. It's inline if there is co Cont-Disp header, or if there is one and it says "inline"
1781                        #
1782                        inline = self.inline_part(part)
1783
1784                        ## Drop HTML message
1785                        #
1786                        if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
1787                                if part.get_content_type() == 'text/html':
1788                                        self.logger.debug('Skipping alternative HTML message')
1789                                        ALTERNATIVE_MULTIPART = False
1790                                        continue
1791
1792                        ## Inline text parts are where the body is
1793                        #
1794                        if part.get_content_type() == 'text/plain' and inline:
1795                                self.logger.debug('               Inline body part')
1796
1797                                ## Try to decode, if fails then do not decode
1798                                #
1799                                body_text = part.get_payload(decode=1)
1800                                if not body_text:                       
1801                                        body_text = part.get_payload(decode=0)
1802
1803                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1804                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1805
1806                                if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
1807                                        body_text = self.reflow(body_text, delsp == 'yes')
1808       
1809                                if self.parameters.strip_signature:
1810                                        body_text = self.strip_signature(body_text)
1811
1812                                if self.parameters.strip_quotes:
1813                                        body_text = self.strip_quotes(body_text)
1814
1815                                if self.parameters.inline_properties:
1816                                        body_text = self.inline_properties(body_text)
1817
1818                                if self.parameters.use_textwrap:
1819                                        body_text = self.wrap_text(body_text)
1820
1821                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1822                                #
1823                                charset = part.get_content_charset()
1824                                if not charset:
1825                                        charset = 'iso-8859-15'
1826
1827                                try:
1828                                        ubody_text = unicode(body_text, charset)
1829
1830                                except UnicodeError, detail:
1831                                        ubody_text = unicode(body_text, 'iso-8859-15')
1832
1833                                except LookupError, detail:
1834                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1835
1836                                if self.parameters.verbatim_format:
1837                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1838                                else:
1839                                        message_parts.append('%s' %ubody_text)
1840                        else:
1841                                if self.parameters.debug:
1842                                        s = '              Filename: %s' % part.get_filename()
1843                                        self.print_unicode(s)
1844
1845                                ## First try to use email header function to convert filename.
1846                                #  If this fails the use the plan filename
1847                                #
1848                                try:
1849                                        filename = self.email_to_unicode(part.get_filename())
1850                                except UnicodeEncodeError, detail:
1851                                        filename = part.get_filename()
1852
1853                                message_parts.append((filename, part))
1854
1855                return message_parts
1856               
1857        def unique_attachment_names(self, message_parts):
1858                """
1859                """
1860                renamed_parts = []
1861                attachment_names = set()
1862
1863                for item in message_parts:
1864                       
1865                        ## If not an attachment, leave it alone
1866                        #
1867                        if not isinstance(item, tuple):
1868                                renamed_parts.append(item)
1869                                continue
1870                               
1871                        (filename, part) = item
1872
1873                        ## If no filename, use a default one
1874                        #
1875                        if not filename:
1876                                filename = 'untitled-part'
1877
1878                                ## Guess the extension from the content type, use non strict mode
1879                                #  some additional non-standard but commonly used MIME types
1880                                #  are also recognized
1881                                #
1882                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1883                                if not ext:
1884                                        ext = '.bin'
1885
1886                                filename = '%s%s' % (filename, ext)
1887
1888                        ## Discard relative paths for windows/unix in attachment names
1889                        #
1890                        #filename = filename.replace('\\', '/').replace(':', '/')
1891                        filename = filename.replace('\\', '_')
1892                        filename = filename.replace('/', '_')
1893
1894                        ## remove linefeed char
1895                        #
1896                        for forbidden_char in ['\r', '\n']:
1897                                filename = filename.replace(forbidden_char,'')
1898
1899                        ## We try to normalize the filename to utf-8 NFC if we can.
1900                        #  Files uploaded from OS X might be in NFD.
1901                        #  Check python version and then try it
1902                        #
1903                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1904                        #       try:
1905                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1906                        #       except TypeError:
1907                        #               pass
1908
1909                        ## Make the filename unique for this ticket
1910                        #
1911                        num = 0
1912                        unique_filename = filename
1913                        dummy_filename, ext = os.path.splitext(filename)
1914
1915                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1916                                num += 1
1917                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1918                               
1919                        if self.parameters.debug:
1920                                s = 'Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1921                                self.print_unicode(s)
1922
1923                        attachment_names.add(unique_filename)
1924
1925                        renamed_parts.append((filename, unique_filename, part))
1926       
1927                return renamed_parts
1928                       
1929                       
1930        def attachment_exists(self, filename):
1931
1932                if self.parameters.debug:
1933                        s = 'attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
1934                        self.print_unicode(s)
1935
1936                ## We have no valid ticket id
1937                #
1938                if not self.id:
1939                        return False
1940
1941                try:
1942                        if self.system == 'discussion':
1943                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1944                                  % (self.id,), filename)
1945                        else:
1946                                att = attachment.Attachment(self.env, 'ticket', self.id,
1947                                  filename)
1948                        return True
1949                except attachment.ResourceNotFound:
1950                        return False
1951
1952########## TRAC Ticket Text ###########################################################
1953                       
1954        def get_body_text(self, message_parts):
1955                body_text = []
1956               
1957                for part in message_parts:
1958               
1959                        ## Plain text part, append it
1960                        #
1961                        if not isinstance(part, tuple):
1962                                body_text.extend(part.strip().splitlines())
1963                                body_text.append("")
1964                                continue
1965
1966                        (original, filename, part) = part
1967                        inline = self.inline_part(part)
1968
1969#                       if part.get_content_type() == 'text/html':
1970#               
1971#                               body_text.append('{{{\n')
1972#                               body_text.append('#!html\n')
1973#
1974#                               ## Try to decode, if fails then do not decode
1975#                               #
1976#                               tmp  = part.get_payload(decode=1)
1977#                               if not tmp:                     
1978#                                       tmp = part.get_payload(decode=0)
1979#                               
1980#                               body_text.append(tmp)
1981#                               body_text.append('}}}\n')
1982                       
1983                        if part.get_content_maintype() == 'image' and inline:
1984                                if self.system != 'discussion':
1985                                        body_text.append('[[Image(%s)]]' % filename)
1986                                body_text.append("")
1987                        else:
1988                                if self.system != 'discussion':
1989                                        body_text.append('[attachment:"%s"]' % filename)
1990                                body_text.append("")
1991
1992                body_text = '\r\n'.join(body_text)
1993                return body_text
1994
1995        def html_mailto_link(self, subject):
1996                """
1997                This function returns a HTML mailto tag with the ticket id and author email address
1998                """
1999                if not self.author:
2000                        author = self.email_addr
2001                else:   
2002                        author = self.author
2003
2004                if not self.parameters.mailto_cc:
2005                        self.parameters.mailto_cc = ''
2006
2007                ## use urllib to escape the chars
2008                #
2009                s = 'mailto:%s?Subject=%s&Cc=%s' %(
2010                       urllib.quote(self.email_addr),
2011                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
2012                           urllib.quote(self.parameters.mailto_cc)
2013                           )
2014
2015                s = '\r\n{{{\r\n#!html\r\n<a\r\n href="%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
2016                return s
2017
2018########## TRAC notify section ###########################################################
2019
2020        def notify(self, tkt, new=True, modtime=0):
2021                """
2022                A wrapper for the TRAC notify function. So we can use templates
2023                """
2024                self.logger.debug('function notify()')
2025
2026                if self.parameters.dry_run:
2027                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
2028                                return
2029                try:
2030
2031                        #from trac.ticket.web_ui import TicketModule
2032                        #from trac.ticket.notification import TicketNotificationSystem
2033                        #ticket_sys = TicketNotificationSystem(self.env)
2034                        #a = TicketModule(self.env)
2035                        #print a.__dict__
2036                        #tn_sys = TicketNotificationSystem(self.env)
2037                        #print tn_sys
2038                        #print tn_sys.__dict__
2039                        #sys.exit(0)
2040
2041                        ## create false {abs_}href properties, to trick Notify()
2042                        #
2043                        if not (self.VERSION in [0.11, 0.12]):
2044                                self.env.abs_href = Href(self.get_config('project', 'url'))
2045                                self.env.href = Href(self.get_config('project', 'url'))
2046
2047
2048                        tn = TicketNotifyEmail(self.env)
2049
2050                        if self.parameters.alternate_notify_template:
2051
2052                                if self.VERSION >= 0.11:
2053
2054                                        from trac.web.chrome import Chrome
2055
2056                                        if  self.parameters.alternate_notify_template_update and not new:
2057                                                tn.template_name = self.parameters.alternate_notify_template_update
2058                                        else:
2059                                                tn.template_name = self.parameters.alternate_notify_template
2060
2061                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2062                                               
2063                                else:
2064
2065                                        tn.template_name = self.parameters.alternate_notify_template
2066
2067                        tn.notify(tkt, new, modtime)
2068
2069                except Exception, e:
2070                        self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2071
2072
2073
2074########## Parse Config File  ###########################################################
2075
2076def ReadConfig(file, name):
2077        """
2078        Parse the config file
2079        """
2080        if not os.path.isfile(file):
2081                print 'File %s does not exist' %file
2082                sys.exit(1)
2083
2084        config = trac_config.Configuration(file)
2085       
2086        parentdir = config.get('DEFAULT', 'parentdir')
2087        sections = config.sections()
2088
2089        ## use some trac internals to get the defaults
2090        #
2091        tmp = config.parser.defaults()
2092        project =  SaraDict()
2093
2094        for option, value in tmp.items():
2095                try:
2096                        project[option] = int(value)
2097                except ValueError:
2098                        project[option] = value
2099
2100        if name:
2101                if name in sections:
2102                        project =  SaraDict()
2103                        for option, value in  config.options(name):
2104                                try:
2105                                        project[option] = int(value)
2106                                except ValueError:
2107                                        project[option] = value
2108
2109                elif not parentdir:
2110                        print "Not a valid project name: %s, valid names are: %s" %(name, sections)
2111                        print "or set parentdir in the [DEFAULT] section"
2112                        sys.exit(1)
2113
2114        ## If parentdir the set project dir to parentdir + name
2115        #
2116        if not project.has_key('project'):
2117                if not parentdir:
2118                        print "You must set project or parentdir in your configuration file"
2119                        sys.exit(1)
2120                elif not name:
2121                        print "You must set project in your configuration file"
2122                else:
2123                        project['project'] = os.path.join(parentdir, name)
2124
2125        return project
2126
2127########## Setup Logging ###############################################################
2128
2129def setup_log(parameters, project_name, interactive=None):
2130        """
2131        Setup loging
2132
2133        Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2134    would be interpreted by the ConfigParser itself.
2135        """
2136        logger = logging.getLogger('email2trac %s' %project_name)
2137
2138        if interactive:
2139                parameters.log_type = 'stderr'
2140
2141        if not parameters.log_type:
2142                if sys.platform in ['win32', 'cygwin']:
2143                        paramters.log_type = 'eventlog'
2144                else:
2145                        parameters.log_type = 'syslog'
2146
2147        if parameters.log_type == 'file':
2148
2149                if not parameters.log_file:
2150                        parameters.log_file = 'email2trac.log'
2151
2152                if not os.path.isabs(parameters.log_file):
2153                        import tempfile
2154                        parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2155
2156                log_handler = logging.FileHandler(parameters.log_file)
2157
2158        elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2159                ## Requires win32 extensions
2160                #
2161                log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2162
2163        elif parameters.log_type in ('syslog', 'unix'):
2164                log_handler = logging.handlers.SysLogHandler('/dev/log')
2165
2166        elif parameters.log_type in ('stderr'):
2167                log_handler = logging.StreamHandler(sys.stderr)
2168
2169        else:
2170                log_handler = logging.handlers.BufferingHandler(0)
2171
2172        if parameters.log_format:
2173                parameters.log_format = parameters.log_format.replace('$(', '%(')
2174        else:
2175                parameters.log_format = '%(name)s: %(message)s'
2176
2177        log_formatter = logging.Formatter(parameters.log_format)
2178        log_handler.setFormatter(log_formatter)
2179        logger.addHandler(log_handler)
2180
2181        if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2182                logger.setLevel(logging.DEBUG)
2183
2184        elif parameters.log_level in ['INFO'] or parameters.verbose:
2185                logger.setLevel(logging.INFO)
2186
2187        elif parameters.log_level in ['WARNING']:
2188                logger.setLevel(logging.WARNING)
2189
2190        elif parameters.log_level in ['ERROR']:
2191                logger.setLevel(logging.ERROR)
2192
2193        elif parameters.log_level in ['CRITICAL']:
2194                logger.setLevel(logging.CRITICAL)
2195
2196        else:
2197                logger.setLevel(logging.INFO)
2198
2199        return logger
2200
2201
2202if __name__ == '__main__':
2203        ## Default config file
2204        #
2205        configfile = '@email2trac_conf@'
2206        project = ''
2207        component = ''
2208        ticket_prefix = 'default'
2209        dry_run = None
2210        verbose = None
2211        debug_interactive = None
2212
2213        SHORT_OPT = 'cdhf:np:t:v'
2214        LONG_OPT  =  ['component=', 'debug', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2215
2216        try:
2217                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2218        except getopt.error,detail:
2219                print __doc__
2220                print detail
2221                sys.exit(1)
2222       
2223        project_name = None
2224        for opt,value in opts:
2225                if opt in [ '-h', '--help']:
2226                        print __doc__
2227                        sys.exit(0)
2228                elif opt in ['-c', '--component']:
2229                        component = value
2230                elif opt in ['-d', '--debug']:
2231                        debug_interactive = 1
2232                elif opt in ['-f', '--file']:
2233                        configfile = value
2234                elif opt in ['-n', '--dry-run']:
2235                        dry_run = True
2236                elif opt in ['-p', '--project']:
2237                        project_name = value
2238                elif opt in ['-t', '--ticket_prefix']:
2239                        ticket_prefix = value
2240                elif opt in ['-v', '--verbose']:
2241                        verbose = True
2242       
2243        settings = ReadConfig(configfile, project_name)
2244
2245        ## The default prefix for ticket values in email2trac.conf
2246        #
2247        settings.ticket_prefix = ticket_prefix
2248        settings.dry_run = dry_run
2249        settings.verbose = verbose
2250
2251        if not settings.debug and debug_interactive:
2252                settings.debug = debug_interactive
2253
2254        if not settings.project:
2255                print __doc__
2256                print 'No Trac project is defined in the email2trac config file.'
2257                sys.exit(1)
2258
2259        logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2260       
2261        if component:
2262                settings['component'] = component
2263
2264        ## Determine major trac version used to be in email2trac.conf
2265        # Quick hack for 0.12
2266        #
2267        version = '0.%s' %(trac_version.split('.')[1])
2268        if version.startswith('0.12'):
2269                version = '0.12'
2270
2271        logger.debug("Found trac version: %s" %(version))
2272       
2273        try:
2274                if version == '0.10':
2275                        from trac import attachment
2276                        from trac.env import Environment
2277                        from trac.ticket import Ticket
2278                        from trac.web.href import Href
2279                        from trac import util
2280                        from trac.ticket.web_ui import TicketModule
2281
2282                        #
2283                        # return  util.text.to_unicode(str)
2284                        #
2285                        # see http://projects.edgewall.com/trac/changeset/2799
2286                        from trac.ticket.notification import TicketNotifyEmail
2287                        from trac import config as trac_config
2288                        from trac.core import TracError
2289
2290                elif version == '0.11':
2291                        from trac import attachment
2292                        from trac.env import Environment
2293                        from trac.ticket import Ticket
2294                        from trac.web.href import Href
2295                        from trac import config as trac_config
2296                        from trac import util
2297                        from trac.core import TracError
2298                        from trac.perm import PermissionSystem
2299                        from trac.ticket.web_ui import TicketModule
2300
2301                        #
2302                        # return  util.text.to_unicode(str)
2303                        #
2304                        # see http://projects.edgewall.com/trac/changeset/2799
2305                        from trac.ticket.notification import TicketNotifyEmail
2306
2307                elif version == '0.12':
2308                        from trac import attachment
2309                        from trac.env import Environment
2310                        from trac.ticket import Ticket
2311                        from trac.web.href import Href
2312                        from trac import config as trac_config
2313                        from trac import util
2314                        from trac.core import TracError
2315                        from trac.perm import PermissionSystem
2316                        from trac.ticket.web_ui import TicketModule
2317
2318                        #
2319                        # return  util.text.to_unicode(str)
2320                        #
2321                        # see http://projects.edgewall.com/trac/changeset/2799
2322                        from trac.ticket.notification import TicketNotifyEmail
2323
2324
2325                else:
2326                        logger.error('TRAC version %s is not supported' %version)
2327                        sys.exit(1)
2328
2329                ## Must be set before environment is created
2330                #
2331                if settings.has_key('python_egg_cache'):
2332                        python_egg_cache = str(settings['python_egg_cache'])
2333                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2334
2335                if settings.debug > 0:
2336                        logger.debug('Loading environment %s', settings.project)
2337
2338                try:
2339                        env = Environment(settings['project'], create=0)
2340                except IOError, detail:
2341                        print "Trac project does not exists: %s" %(settings['project'])
2342                        sys.exit(1)
2343
2344                tktparser = TicketEmailParser(env, settings, logger, float(version))
2345                tktparser.parse(sys.stdin)
2346
2347        ## Catch all errors and use the logging module
2348        #
2349        except Exception, error:
2350
2351                etype, evalue, etb = sys.exc_info()
2352                for e in traceback.format_exception(etype, evalue, etb):
2353                        logger.critical(e)
2354
2355                if m:
2356                        tktparser.save_email_for_debug(m, True)
2357
2358                sys.exit(1)
2359# EOB
Note: See TracBrowser for help on using the repository browser.