source: trunk/email2trac.py.in @ 466

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

avoid namespace collision

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