source: trunk/email2trac.py.in @ 453

Last change on this file since 453 was 453, checked in by bas, 14 years ago

removed obsolete conversion to int for debug

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