source: trunk/email2trac.py.in @ 472

Last change on this file since 472 was 472, checked in by bas, 12 years ago

email2trac.py.in:

  • the default trac value for resolution is fixed ;-). This a 'temporarly patch' to disable this. You can override the behaviour in email2trac.conf, see #84
  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 58.4 KB
Line 
1#!@PYTHON@
2# Copyright (C) 2002
3#
4# This file is part of the email2trac utils
5#
6# This program is free software; you can redistribute it and/or modify it
7# under the terms of the GNU General Public License as published by the
8# Free Software Foundation; either version 2, or (at your option) any
9# later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
19#
20# For vi/emacs or other use tabstop=4 (vi: set ts=4)
21#
22"""
23email2trac.py -- Email tickets to Trac.
24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
33
34The scripts reads emails from stdin and inserts directly into a Trac database.
35
36How to use
37----------
38 * See https://subtrac.sara.nl/oss/email2trac/
39
40 * Create an config file:
41    [DEFAULT]                        # REQUIRED
42    project      : /data/trac/test   # REQUIRED
43    debug        : 1                 # OPTIONAL, if set print some DEBUG info
44
45    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
46    project      : /data/trac/jouvin # use -p|--project jouvin. 
47       
48 * default config file is : /etc/email2trac.conf
49
50 * Commandline opions:
51                -h,--help
52                                -d, --debug
53                -f,--file  <configuration file>
54                -n,--dry-run
55                -p, --project <project name>
56                -t, --ticket_prefix <name>
57
58SVN Info:
59        $Id: email2trac.py.in 472 2010-08-06 11:35:37Z 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                #
781                cnum = len(tkt.get_changelog())
782
783                ## reopen the ticket if it is was closed
784                #  We must use the ticket workflow framework
785                #
786                if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
787
788                        self.logger.debug('Workflow ticket update fields: ')
789
790                        from trac.ticket.default_workflow import ConfigurableTicketWorkflow
791                        from trac.test import Mock, MockPerm
792
793                        req = Mock(authname=self.author, perm=MockPerm(), args={})
794                        try:
795                                workflow = self.parameters['workflow_%s' %tkt['status']]
796                        except KeyError:
797                                ## fallback for compability (Will be deprecated)
798                                #
799                                if tkt['status'] in ['closed']:
800                                        workflow = self.parameters.workflow
801                                else:   
802                                        workflow = None
803
804                        controller = ConfigurableTicketWorkflow(self.env)
805                        #print controller.actions
806                        #print controller.actions.keys()
807                        #print controller.get_ticket_actions(req, tkt)
808                        #print controller.actions[workflow]
809                        #print controller.actions[workflow]['permissions'] is a list
810
811                        if workflow:
812
813                                if self.parameters.ticket_permission_system:
814
815                                        if self.check_permission(tkt, controller.actions[workflow]['permissions'][0]):
816                                                fields = controller.get_ticket_changes(req, tkt, workflow)
817                                        else:
818                                                fields = dict()
819                                                self.logger.info('Reporter: %s has no permission to trigger workflow' %self.author)
820
821                                else:
822                                        fields = controller.get_ticket_changes(req, tkt, workflow)
823
824                                for key in fields.keys():
825                                        self.logger.debug('\t %s : %s' %(key, fields[key]))
826                                        tkt[key] = fields[key]
827
828                ## Old pre 0.11 situation
829                #
830                elif self.parameters.email_triggers_workflow:
831
832                        self.logger.debug('email triggers workflow pre trac 0.11')
833
834                        if tkt['status'] in ['closed']:
835                                tkt['status'] = 'reopened'
836                                tkt['resolution'] = ''
837
838                else:
839                        self.logger.debug('email triggers workflow disabled')
840
841                ## Must we update some ticket fields properties via subjectline
842                #
843                if update_fields:
844                        self.update_ticket_fields(tkt, update_fields)
845
846                message_parts = self.get_message_parts(m)
847                message_parts = self.unique_attachment_names(message_parts)
848
849                # Must we update some ticket fields properties via body_text
850                #
851                if self.properties:
852                                self.update_ticket_fields(tkt, self.properties)
853
854                if self.parameters.email_header:
855                        message_parts.insert(0, self.email_header_txt(m))
856
857                body_text = self.body_text(message_parts)
858
859                error_with_attachments = self.attach_attachments(message_parts)
860
861                if body_text.strip() or update_fields or self.properties:
862                        if self.parameters.dry_run:
863                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
864                        else:
865                                if error_with_attachments:
866                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
867                                self.logger.debug('tkt.save_changes(%s, %d)' %(self.author, cnum))
868                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
869                       
870
871                if not spam:
872                        self.notify(tkt, False, when)
873
874                return True
875
876        def set_ticket_fields(self, ticket):
877                """
878                set the ticket fields to value specified
879                        - /etc/email2trac.conf with <prefix>_<field>
880                        - trac default values, trac.ini
881                """
882                self.logger.debug('function set_ticket_fields')
883
884                user_dict = dict()
885
886                for field in ticket.fields:
887
888                        name = field['name']
889
890                        ## default trac value
891                        #
892                        if not field.get('custom'):
893                                value = self.get_config('ticket', 'default_%s' %(name) )
894                                if (name in ['resolution']) and (value in ['fixed']):
895                                        value = None
896                        else:
897                                ##  Else we get the default value for reporter
898                                #
899                                value = field.get('value')
900                                options = field.get('options')
901
902                                if value and options and (value not in options):
903                                         value = options[int(value)]
904       
905                        if self.parameters.debug:
906                                s = 'trac[%s] = %s' %(name, value)
907                                self.print_unicode(s)
908
909                        ## email2trac.conf settings
910                        #
911                        prefix = self.parameters.ticket_prefix
912                        try:
913                                value = self.parameters['%s_%s' %(prefix, name)]
914                                if self.parameters.debug:
915                                        s = 'email2trac[%s] = %s ' %(name, value)
916                                        self.print_unicode(s)
917
918                        except KeyError, detail:
919                                pass
920               
921                        if value:
922                                user_dict[name] = value
923                                if self.parameters.debug:
924                                        s = 'used %s = %s' %(name, value)
925                                        self.print_unicode(s)
926
927                self.update_ticket_fields(ticket, user_dict, use_default=1)
928
929                if 'status' not in user_dict.keys():
930                        ticket['status'] = 'new'
931
932
933        def ticket_update_by_subject(self, subject):
934                """
935                This list of Re: prefixes is probably incomplete. Taken from
936                wikipedia. Here is how the subject is matched
937                  - Re: <subject>
938                  - Re: (<Mail list label>:)+ <subject>
939
940                So we must have the last column
941                """
942                self.logger.debug('function ticket_update_by_subject')
943
944                matched_id = None
945                if self.parameters.ticket_update and self.parameters.ticket_update_by_subject:
946                               
947                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
948                        result = SUBJECT_RE.search(subject)
949
950                        if result:
951                                # This is a reply
952                                orig_subject = result.group(3)
953
954                                self.logger.debug('subject search string: %s' %(orig_subject))
955
956                                cursor = self.db.cursor()
957                                summaries = [orig_subject, '%%: %s' % orig_subject]
958
959                                ##
960                                # Convert days to seconds
961                                lookback = int(time.mktime(time.gmtime())) - \
962                                                self.parameters.ticket_update_by_subject_lookback * 24 * 3600
963
964
965                                for summary in summaries:
966                                        self.logger.debug('Looking for summary matching: "%s"' % summary)
967
968                                        sql = """SELECT id FROM ticket
969                                                        WHERE changetime >= %s AND summary LIKE %s
970                                                        ORDER BY changetime DESC"""
971                                        cursor.execute(sql, [lookback, summary.strip()])
972
973                                        for row in cursor:
974                                                (matched_id,) = row
975
976                                                self.logger.debug('Found matching ticket id: %d' % matched_id)
977
978                                                break
979
980                                        if matched_id:
981                                                matched_id = '#%d' % matched_id
982                                                return matched_id
983
984                return matched_id
985
986
987        def new_ticket(self, msg, subject, spam, set_fields = None):
988                """
989                Create a new ticket
990                """
991                self.logger.debug('function new_ticket')
992
993                tkt = Ticket(self.env)
994
995                self.set_reply_fields(tkt, msg)
996
997                self.set_ticket_fields(tkt)
998
999                # Check the permission of the reporter
1000                #
1001                if self.parameters.ticket_permission_system:
1002                        if not self.check_permission(tkt, 'TICKET_CREATE'):
1003                                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1004                                return False
1005
1006                # Old style setting for component, will be removed
1007                #
1008                if spam:
1009                        tkt['component'] = 'Spam'
1010
1011                elif self.parameters.has_key('component'):
1012                        tkt['component'] = self.parameters['component']
1013
1014                if not msg['Subject']:
1015                        tkt['summary'] = u'(No subject)'
1016                else:
1017                        tkt['summary'] = subject
1018
1019
1020                if set_fields:
1021                        rest, keywords = string.split(set_fields, '?')
1022
1023                        if keywords:
1024                                update_fields = self.str_to_dict(keywords)
1025                                self.update_ticket_fields(tkt, update_fields)
1026
1027
1028                message_parts = self.get_message_parts(msg)
1029
1030                # Must we update some ticket fields properties via body_text
1031                #
1032                if self.properties:
1033                                self.update_ticket_fields(tkt, self.properties)
1034
1035                message_parts = self.unique_attachment_names(message_parts)
1036               
1037                # produce e-mail like header
1038                #
1039                head = ''
1040                if self.parameters.email_header:
1041                        head = self.email_header_txt(msg)
1042                        message_parts.insert(0, head)
1043                       
1044                body_text = self.body_text(message_parts)
1045
1046                tkt['description'] = body_text
1047
1048                # When is the change committed
1049                #
1050                if self.VERSION < 0.11:
1051                        when = int(time.time())
1052                else:
1053                        when = datetime.now(util.datefmt.utc)
1054
1055                if self.parameters.dry_run:
1056                        print 'DRY_RUN: tkt.insert()'
1057                else:
1058                        self.id = tkt.insert()
1059       
1060                changed = False
1061                comment = ''
1062
1063                # some routines in trac are dependend on ticket id     
1064                # like alternate notify template
1065                #
1066                if self.parameters.alternate_notify_template:
1067                        tkt['id'] = self.id
1068                        changed = True
1069
1070                ## Rewrite the description if we have mailto enabled
1071                #
1072                if self.parameters.mailto_link:
1073                        changed = True
1074                        comment = u'\nadded mailto line\n'
1075                        mailto = self.html_mailto_link( m['Subject'])
1076
1077                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1078                                %(head, mailto, body_text)
1079       
1080                ## Save the attachments to the ticket   
1081                #
1082                error_with_attachments =  self.attach_attachments(message_parts)
1083
1084                if error_with_attachments:
1085                        changed = True
1086                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1087
1088                if changed:
1089                        if self.parameters.dry_run:
1090                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1091                        else:
1092                                tkt.save_changes(tkt['reporter'], comment)
1093                                #print tkt.get_changelog(self.db, when)
1094
1095                if not spam:
1096                        self.notify(tkt, True)
1097
1098
1099        def attach_attachments(self, message_parts, update=False):
1100                '''
1101                save any attachments as files in the ticket's directory
1102                '''
1103                self.logger.debug('function attach_attachments()')
1104
1105                if self.parameters.dry_run:
1106                        print "DRY_RUN: no attachments attached to tickets"
1107                        return ''
1108
1109                count = 0
1110
1111                # Get Maxium attachment size
1112                #
1113                max_size = int(self.get_config('attachment', 'max_size'))
1114                status   = None
1115               
1116                for item in message_parts:
1117                        # Skip body parts
1118                        if not isinstance(item, tuple):
1119                                continue
1120                               
1121                        (original, filename, part) = item
1122                        #
1123                        # We have to determine the size so we use this temporary solution. we must escape it
1124                        # else we get UnicodeErrors.
1125                        #
1126                        path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, util.text.unicode_quote(filename)))
1127                        text = part.get_payload(decode=1)
1128                        if not text:
1129                                text = '(None)'
1130                        fd.write(text)
1131                        fd.close()
1132
1133                        # get the file_size
1134                        #
1135                        stats = os.lstat(path)
1136                        file_size = stats[ST_SIZE]
1137
1138                        # Check if the attachment size is allowed
1139                        #
1140                        if (max_size != -1) and (file_size > max_size):
1141                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1142                                        %(status, original, file_size, max_size)
1143
1144                                os.unlink(path)
1145                                continue
1146                        else:
1147                                count = count + 1
1148                                       
1149                        # Insert the attachment
1150                        #
1151                        fd = open(path, 'rb')
1152                        if self.system == 'discussion':
1153                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1154                                  % (self.id,))
1155                        else:
1156                                self.logger.debug('Attach %s to ticket %d' %(util.text.unicode_quote(filename), self.id))
1157                                att = attachment.Attachment(self.env, 'ticket', self.id)
1158 
1159                        # This will break the ticket_update system, the body_text is vaporized
1160                        # ;-(
1161                        #
1162                        if not update:
1163                                att.author = self.author
1164                                att.description = self.email_to_unicode('Added by email2trac')
1165
1166                        try:
1167                                self.logger.debug('Insert atachment')
1168                                att.insert(filename, fd, file_size)
1169                        except OSError, detail:
1170                                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1171                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1172
1173                        # Remove the created temporary filename
1174                        #
1175                        fd.close()
1176                        os.unlink(path)
1177
1178                ## return error
1179                #
1180                return status
1181
1182########## Fullblog functions  #################################################
1183
1184        def blog(self, id):
1185                """
1186                The blog create/update function
1187                """
1188                # import the modules
1189                #
1190                from tracfullblog.core import FullBlogCore
1191                from tracfullblog.model import BlogPost, BlogComment
1192                from trac.test import Mock, MockPerm
1193
1194                # instantiate blog core
1195                blog = FullBlogCore(self.env)
1196                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1197
1198                if id:
1199
1200                        # update blog
1201                        #
1202                        comment = BlogComment(self.env, id)
1203                        comment.author = self.author
1204
1205                        message_parts = self.get_message_parts(m)
1206                        comment.comment = self.body_text(message_parts)
1207
1208                        blog.create_comment(req, comment)
1209
1210                else:
1211                        # create blog
1212                        #
1213                        import time
1214                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1215
1216                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1217                       
1218                        post.author = self.author
1219                        post.title = self.email_to_unicode(m['Subject'])
1220
1221                        message_parts = self.get_message_parts(m)
1222                        post.body = self.body_text(message_parts)
1223                       
1224                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1225
1226
1227########## Discussion functions  ##############################################
1228
1229        def discussion_topic(self, content, subject):
1230
1231                # Import modules.
1232                from tracdiscussion.api import DiscussionApi
1233                from trac.util.datefmt import to_timestamp, utc
1234
1235                self.logger.debug('Creating a new topic in forum:', self.id)
1236
1237                # Get dissussion API component.
1238                api = self.env[DiscussionApi]
1239                context = self._create_context(content, subject)
1240
1241                # Get forum for new topic.
1242                forum = api.get_forum(context, self.id)
1243
1244                if not forum:
1245                        self.logger.error("ERROR: Replied forum doesn't exist")
1246
1247                # Prepare topic.
1248                topic = {'forum' : forum['id'],
1249                                 'subject' : context.subject,
1250                                 'time': to_timestamp(datetime.now(utc)),
1251                                 'author' : self.author,
1252                                 'subscribers' : [self.email_addr],
1253                                 'body' : self.body_text(context.content_parts)}
1254
1255                # Add topic to DB and commit it.
1256                self._add_topic(api, context, topic)
1257                self.db.commit()
1258
1259        def discussion_topic_reply(self, content, subject):
1260
1261                # Import modules.
1262                from tracdiscussion.api import DiscussionApi
1263                from trac.util.datefmt import to_timestamp, utc
1264
1265                self.logger.debug('Replying to discussion topic', self.id)
1266
1267                # Get dissussion API component.
1268                api = self.env[DiscussionApi]
1269                context = self._create_context(content, subject)
1270
1271                # Get replied topic.
1272                topic = api.get_topic(context, self.id)
1273
1274                if not topic:
1275                        self.logger.error("ERROR: Replied topic doesn't exist")
1276
1277                # Prepare message.
1278                message = {'forum' : topic['forum'],
1279                                   'topic' : topic['id'],
1280                                   'replyto' : -1,
1281                                   'time' : to_timestamp(datetime.now(utc)),
1282                                   'author' : self.author,
1283                                   'body' : self.body_text(context.content_parts)}
1284
1285                # Add message to DB and commit it.
1286                self._add_message(api, context, message)
1287                self.db.commit()
1288
1289        def discussion_message_reply(self, content, subject):
1290
1291                # Import modules.
1292                from tracdiscussion.api import DiscussionApi
1293                from trac.util.datefmt import to_timestamp, utc
1294
1295                self.loggger.debug('Replying to discussion message', self.id)
1296
1297                # Get dissussion API component.
1298                api = self.env[DiscussionApi]
1299                context = self._create_context(content, subject)
1300
1301                # Get replied message.
1302                message = api.get_message(context, self.id)
1303
1304                if not message:
1305                        self.logger.error("ERROR: Replied message doesn't exist")
1306
1307                # Prepare message.
1308                message = {'forum' : message['forum'],
1309                                   'topic' : message['topic'],
1310                                   'replyto' : message['id'],
1311                                   'time' : to_timestamp(datetime.now(utc)),
1312                                   'author' : self.author,
1313                                   'body' : self.body_text(context.content_parts)}
1314
1315                # Add message to DB and commit it.
1316                self._add_message(api, context, message)
1317                self.db.commit()
1318
1319        def _create_context(self, content, subject):
1320
1321                # Import modules.
1322                from trac.mimeview import Context
1323                from trac.web.api import Request
1324                from trac.perm import PermissionCache
1325
1326                # TODO: Read server base URL from config.
1327                # Create request object to mockup context creation.
1328                #
1329                environ = {'SERVER_PORT' : 80,
1330                                   'SERVER_NAME' : 'test',
1331                                   'REQUEST_METHOD' : 'POST',
1332                                   'wsgi.url_scheme' : 'http',
1333                                   'wsgi.input' : sys.stdin}
1334                chrome =  {'links': {},
1335                                   'scripts': [],
1336                                   'ctxtnav': [],
1337                                   'warnings': [],
1338                                   'notices': []}
1339
1340                if self.env.base_url_for_redirect:
1341                        environ['trac.base_url'] = self.env.base_url
1342
1343                req = Request(environ, None)
1344                req.chrome = chrome
1345                req.tz = 'missing'
1346                req.authname = self.author
1347                req.perm = PermissionCache(self.env, self.author)
1348
1349                # Create and return context.
1350                context = Context.from_request(req)
1351                context.realm = 'discussion-email2trac'
1352                context.cursor = self.db.cursor()
1353                context.content = content
1354                context.subject = subject
1355
1356                # Read content parts from content.
1357                context.content_parts = self.get_message_parts(content)
1358                context.content_parts = self.unique_attachment_names(
1359                  context.content_parts)
1360
1361                return context
1362
1363        def _add_topic(self, api, context, topic):
1364                context.req.perm.assert_permission('DISCUSSION_APPEND')
1365
1366                # Filter topic.
1367                for discussion_filter in api.discussion_filters:
1368                        accept, topic_or_error = discussion_filter.filter_topic(
1369                          context, topic)
1370                        if accept:
1371                                topic = topic_or_error
1372                        else:
1373                                raise TracError(topic_or_error)
1374
1375                # Add a new topic.
1376                api.add_topic(context, topic)
1377
1378                # Get inserted topic with new ID.
1379                topic = api.get_topic_by_time(context, topic['time'])
1380
1381                # Attach attachments.
1382                self.id = topic['id']
1383                self.attach_attachments(context.content_parts, True)
1384
1385                # Notify change listeners.
1386                for listener in api.topic_change_listeners:
1387                        listener.topic_created(context, topic)
1388
1389        def _add_message(self, api, context, message):
1390                context.req.perm.assert_permission('DISCUSSION_APPEND')
1391
1392                # Filter message.
1393                for discussion_filter in api.discussion_filters:
1394                        accept, message_or_error = discussion_filter.filter_message(
1395                          context, message)
1396                        if accept:
1397                                message = message_or_error
1398                        else:
1399                                raise TracError(message_or_error)
1400
1401                # Add message.
1402                api.add_message(context, message)
1403
1404                # Get inserted message with new ID.
1405                message = api.get_message_by_time(context, message['time'])
1406
1407                # Attach attachments.
1408                self.id = message['topic']
1409                self.attach_attachments(context.content_parts, True)
1410
1411                # Notify change listeners.
1412                for listener in api.message_change_listeners:
1413                        listener.message_created(context, message)
1414
1415########## MAIN function  ######################################################
1416
1417        def parse(self, fp):
1418                """
1419                """
1420                self.logger.debug('Main function parse')
1421                global m
1422
1423                m = email.message_from_file(fp)
1424               
1425                if not m:
1426                        self.logger.debug('This is not a valid email message format')
1427                        return
1428                       
1429                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1430                try:
1431                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1432                except AttributeError, detail:
1433                        pass
1434
1435                if self.parameters.debug:         # save the entire e-mail message text
1436                        self.save_email_for_debug(m, True)
1437
1438                self.db = self.env.get_db_cnx()
1439                self.get_sender_info(m)
1440
1441                if not self.email_header_acl('white_list', self.email_addr, True):
1442                        self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1443                        return False
1444
1445                if self.email_header_acl('black_list', self.email_addr, False):
1446                        self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1447                        return False
1448
1449                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1450                        self.logger.info('Message rejected : %s not in recipient list' %(self.to_email_addr))
1451                        return False
1452
1453                # If spam drop the message
1454                #
1455                if self.spam(m) == 'drop':
1456                        return False
1457
1458                elif self.spam(m) == 'spam':
1459                        spam_msg = True
1460                else:
1461                        spam_msg = False
1462
1463                if not m['Subject']:
1464                        subject  = 'No Subject'
1465                else:
1466                        subject  = self.email_to_unicode(m['Subject'])
1467
1468                self.logger.debug('subject: %s' %subject)
1469
1470                #
1471                # [hic] #1529: Re: LRZ
1472                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1473                #
1474                ticket_regex = r'''
1475                        (?P<new_fields>[#][?].*)
1476                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1477                        '''
1478                # Check if  FullBlogPlugin is installed
1479                #
1480                blog_enabled = None
1481                blog_regex = ''
1482                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1483                        blog_enabled = True
1484                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1485
1486
1487                # Check if DiscussionPlugin is installed
1488                #
1489                discussion_enabled = None
1490                discussion_regex = ''
1491                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1492                        discussion_enabled = True
1493                        discussion_regex = r'''
1494                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1495                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1496                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1497                        '''
1498
1499
1500                regex_str = ticket_regex + blog_regex + discussion_regex
1501                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1502
1503                # Find out if this is a ticket, a blog or a discussion
1504                #
1505                result =  SYSTEM_RE.search(subject)
1506
1507                if result:
1508                        # update ticket + fields
1509                        #
1510                        #if result.group('reply') and self.parameters.ticket_update:
1511                        if result.group('reply'):
1512                                self.system = 'ticket'
1513
1514                                # Skip the last ':' character
1515                                #
1516                                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1517                                        self.new_ticket(m, subject, spam_msg)
1518
1519                        # New ticket + fields
1520                        #
1521                        elif result.group('new_fields'):
1522                                self.system = 'ticket'
1523                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1524
1525                        if blog_enabled:
1526                                if result.group('blog'):
1527                                        self.system = 'blog'
1528                                        self.blog(result.group('blog_id'))
1529
1530                        if discussion_enabled:
1531                                # New topic.
1532                                #
1533                                if result.group('forum'):
1534                                        self.system = 'discussion'
1535                                        self.id = int(result.group('forum_id'))
1536                                        self.discussion_topic(m, subject[result.end('forum'):])
1537
1538                                # Reply to topic.
1539                                #
1540                                elif result.group('topic'):
1541                                        self.system = 'discussion'
1542                                        self.id = int(result.group('topic_id'))
1543                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1544
1545                                # Reply to topic message.
1546                                #
1547                                elif result.group('message'):
1548                                        self.system = 'discussion'
1549                                        self.id = int(result.group('message_id'))
1550                                        self.discussion_message_reply(m, subject[result.end('message'):])
1551
1552                else:
1553                        self.system = 'ticket'
1554                        result = self.ticket_update_by_subject(subject)
1555                        if result:
1556                                if not self.ticket_update(m, result, spam_msg):
1557                                        self.new_ticket(m, subject, spam_msg)
1558                        else:
1559                                # No update by subject, so just create a new ticket
1560                                self.new_ticket(m, subject, spam_msg)
1561
1562
1563########## BODY TEXT functions  ###########################################################
1564
1565        def strip_signature(self, text):
1566                """
1567                Strip signature from message, inspired by Mailman software
1568                """
1569                self.logger.debug('function strip_signature')
1570
1571                body = []
1572                for line in text.splitlines():
1573                        if line == '-- ':
1574                                break
1575                        body.append(line)
1576
1577                return ('\n'.join(body))
1578
1579        def reflow(self, text, delsp = 0):
1580                """
1581                Reflow the message based on the format="flowed" specification (RFC 3676)
1582                """
1583                flowedlines = []
1584                quotelevel = 0
1585                prevflowed = 0
1586
1587                for line in text.splitlines():
1588                        from re import match
1589                       
1590                        # Figure out the quote level and the content of the current line
1591                        m = match('(>*)( ?)(.*)', line)
1592                        linequotelevel = len(m.group(1))
1593                        line = m.group(3)
1594
1595                        # Determine whether this line is flowed
1596                        if line and line != '-- ' and line[-1] == ' ':
1597                                flowed = 1
1598                        else:
1599                                flowed = 0
1600
1601                        if flowed and delsp and line and line[-1] == ' ':
1602                                line = line[:-1]
1603
1604                        # If the previous line is flowed, append this line to it
1605                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1606                                flowedlines[-1] += line
1607                        # Otherwise, start a new line
1608                        else:
1609                                flowedlines.append('>' * linequotelevel + line)
1610
1611                        prevflowed = flowed
1612                       
1613
1614                return '\n'.join(flowedlines)
1615
1616        def strip_quotes(self, text):
1617                """
1618                Strip quotes from message by Nicolas Mendoza
1619                """
1620                self.logger.debug('function strip_quotes')
1621
1622                body = []
1623                for line in text.splitlines():
1624                        try:
1625
1626                                if line.startswith(self.parameters.email_quote):
1627                                        continue
1628
1629                        except UnicodeDecodeError:
1630
1631                                tmp_line = self.email_to_unicode(line)
1632                                if tmp_line.startswith(self.parameters.email_quote):
1633                                        continue
1634                               
1635                        body.append(line)
1636
1637                return ('\n'.join(body))
1638
1639        def inline_properties(self, text):
1640                """
1641                Parse text if we use inline keywords to set ticket fields
1642                """
1643                self.logger.debug('function inline_properties')
1644
1645                properties = dict()
1646                body = list()
1647
1648                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1649
1650                for line in text.splitlines():
1651                        match = INLINE_EXP.match(line)
1652                        if match:
1653                                keyword, value = match.groups()
1654                                self.properties[keyword] = value.strip()
1655
1656                                self.logger.debug('inline properties: %s : %s' %(keyword,value))
1657
1658                        else:
1659                                body.append(line)
1660                               
1661                return '\n'.join(body)
1662
1663
1664        def wrap_text(self, text, replace_whitespace = False):
1665                """
1666                Will break a lines longer then given length into several small
1667                lines of size given length
1668                """
1669                import textwrap
1670
1671                LINESEPARATOR = '\n'
1672                reformat = ''
1673
1674                for s in text.split(LINESEPARATOR):
1675                        tmp = textwrap.fill(s, self.parameters.use_textwrap)
1676                        if tmp:
1677                                reformat = '%s\n%s' %(reformat,tmp)
1678                        else:
1679                                reformat = '%s\n' %reformat
1680
1681                return reformat
1682
1683                # Python2.4 and higher
1684                #
1685                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1686                #
1687
1688########## EMAIL attachements functions ###########################################################
1689
1690        def inline_part(self, part):
1691                """
1692                """
1693                self.logger.debug('function inline_part()')
1694
1695                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1696
1697        def get_message_parts(self, msg):
1698                """
1699                parses the email message and returns a list of body parts and attachments
1700                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1701                """
1702                self.logger.debug('function get_message_parts()')
1703
1704                message_parts = list()
1705       
1706                ALTERNATIVE_MULTIPART = False
1707
1708                for part in msg.walk():
1709                        self.logger.debug('Message part: Main-Type: %s' % part.get_content_maintype())
1710                        self.logger.debug('Message part: Content-Type: %s' % part.get_content_type())
1711
1712                        ## Check content type
1713                        #
1714                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1715                                self.logger.debug("A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename()))
1716                                continue
1717
1718                        ## Catch some mulitpart execptions
1719                        #
1720                        if part.get_content_type() == 'multipart/alternative':
1721                                ALTERNATIVE_MULTIPART = True
1722                                continue
1723
1724                        ## Skip multipart containers
1725                        #
1726                        if part.get_content_maintype() == 'multipart':
1727                                self.logger.debug("Skipping multipart container")
1728
1729                                continue
1730                       
1731                        ## 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"
1732                        #
1733                        inline = self.inline_part(part)
1734
1735                        ## Drop HTML message
1736                        #
1737                        if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
1738                                if part.get_content_type() == 'text/html':
1739                                        self.logger.debug('Skipping alternative HTML message')
1740                                        ALTERNATIVE_MULTIPART = False
1741                                        continue
1742
1743                        ## Inline text parts are where the body is
1744                        #
1745                        if part.get_content_type() == 'text/plain' and inline:
1746                                self.logger.debug('               Inline body part')
1747
1748                                # Try to decode, if fails then do not decode
1749                                #
1750                                body_text = part.get_payload(decode=1)
1751                                if not body_text:                       
1752                                        body_text = part.get_payload(decode=0)
1753
1754                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1755                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1756
1757                                if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
1758                                        body_text = self.reflow(body_text, delsp == 'yes')
1759       
1760                                if self.parameters.strip_signature:
1761                                        body_text = self.strip_signature(body_text)
1762
1763                                if self.parameters.strip_quotes:
1764                                        body_text = self.strip_quotes(body_text)
1765
1766                                if self.parameters.inline_properties:
1767                                        body_text = self.inline_properties(body_text)
1768
1769                                if self.parameters.use_textwrap:
1770                                        body_text = self.wrap_text(body_text)
1771
1772                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1773                                #
1774                                charset = part.get_content_charset()
1775                                if not charset:
1776                                        charset = 'iso-8859-15'
1777
1778                                try:
1779                                        ubody_text = unicode(body_text, charset)
1780
1781                                except UnicodeError, detail:
1782                                        ubody_text = unicode(body_text, 'iso-8859-15')
1783
1784                                except LookupError, detail:
1785                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1786
1787                                if self.parameters.verbatim_format:
1788                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1789                                else:
1790                                        message_parts.append('%s' %ubody_text)
1791                        else:
1792                                if self.parameters.debug:
1793                                        s = '              Filename: %s' % part.get_filename()
1794                                        self.print_unicode(s)
1795
1796                                ##
1797                                #  First try to use email header function to convert filename.
1798                                #  If this fails the use the plan filename
1799                                try:
1800                                        filename = self.email_to_unicode(part.get_filename())
1801                                except UnicodeEncodeError, detail:
1802                                        filename = part.get_filename()
1803
1804                                message_parts.append((filename, part))
1805
1806                return message_parts
1807               
1808        def unique_attachment_names(self, message_parts):
1809                """
1810                """
1811                renamed_parts = []
1812                attachment_names = set()
1813
1814                for item in message_parts:
1815                       
1816                        ## If not an attachment, leave it alone
1817                        #
1818                        if not isinstance(item, tuple):
1819                                renamed_parts.append(item)
1820                                continue
1821                               
1822                        (filename, part) = item
1823
1824                        ## If no filename, use a default one
1825                        #
1826                        if not filename:
1827                                filename = 'untitled-part'
1828
1829                                # Guess the extension from the content type, use non strict mode
1830                                # some additional non-standard but commonly used MIME types
1831                                # are also recognized
1832                                #
1833                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1834                                if not ext:
1835                                        ext = '.bin'
1836
1837                                filename = '%s%s' % (filename, ext)
1838
1839                        ## Discard relative paths for windows/unix in attachment names
1840                        #
1841                        #filename = filename.replace('\\', '/').replace(':', '/')
1842                        filename = filename.replace('\\', '_')
1843                        filename = filename.replace('/', '_')
1844
1845                        ## remove linefeed char
1846                        #
1847                        for forbidden_char in ['\r', '\n']:
1848                                filename = filename.replace(forbidden_char,'')
1849
1850                        #
1851                        # We try to normalize the filename to utf-8 NFC if we can.
1852                        # Files uploaded from OS X might be in NFD.
1853                        # Check python version and then try it
1854                        #
1855                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1856                        #       try:
1857                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1858                        #       except TypeError:
1859                        #               pass
1860
1861                        # Make the filename unique for this ticket
1862                        num = 0
1863                        unique_filename = filename
1864                        dummy_filename, ext = os.path.splitext(filename)
1865
1866                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1867                                num += 1
1868                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1869                               
1870                        if self.parameters.debug:
1871                                s = 'Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1872                                self.print_unicode(s)
1873
1874                        attachment_names.add(unique_filename)
1875
1876                        renamed_parts.append((filename, unique_filename, part))
1877       
1878                return renamed_parts
1879                       
1880                       
1881        def attachment_exists(self, filename):
1882
1883                if self.parameters.debug:
1884                        s = 'attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
1885                        self.print_unicode(s)
1886
1887                # We have no valid ticket id
1888                #
1889                if not self.id:
1890                        return False
1891
1892                try:
1893                        if self.system == 'discussion':
1894                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1895                                  % (self.id,), filename)
1896                        else:
1897                                att = attachment.Attachment(self.env, 'ticket', self.id,
1898                                  filename)
1899                        return True
1900                except attachment.ResourceNotFound:
1901                        return False
1902
1903########## TRAC Ticket Text ###########################################################
1904                       
1905        def body_text(self, message_parts):
1906                body_text = []
1907               
1908                for part in message_parts:
1909                        # Plain text part, append it
1910                        if not isinstance(part, tuple):
1911                                body_text.extend(part.strip().splitlines())
1912                                body_text.append("")
1913                                continue
1914                               
1915                        (original, filename, part) = part
1916                        inline = self.inline_part(part)
1917                       
1918                        if part.get_content_maintype() == 'image' and inline:
1919                                if self.system != 'discussion':
1920                                        body_text.append('[[Image(%s)]]' % filename)
1921                                body_text.append("")
1922                        else:
1923                                if self.system != 'discussion':
1924                                        body_text.append('[attachment:"%s"]' % filename)
1925                                body_text.append("")
1926                               
1927                body_text = '\r\n'.join(body_text)
1928                return body_text
1929
1930        def html_mailto_link(self, subject):
1931                """
1932                This function returns a HTML mailto tag with the ticket id and author email address
1933                """
1934                if not self.author:
1935                        author = self.email_addr
1936                else:   
1937                        author = self.author
1938
1939                if not self.parameters.mailto_cc:
1940                        self.parameters.mailto_cc = ''
1941
1942                # use urllib to escape the chars
1943                #
1944                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1945                       urllib.quote(self.email_addr),
1946                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1947                           urllib.quote(self.parameters.mailto_cc)
1948                           )
1949
1950                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)
1951                return s
1952
1953########## TRAC notify section ###########################################################
1954
1955        def notify(self, tkt, new=True, modtime=0):
1956                """
1957                A wrapper for the TRAC notify function. So we can use templates
1958                """
1959                self.logger.debug('function notify()')
1960
1961                if self.parameters.dry_run:
1962                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
1963                                return
1964                try:
1965
1966                        #from trac.ticket.web_ui import TicketModule
1967                        #from trac.ticket.notification import TicketNotificationSystem
1968                        #ticket_sys = TicketNotificationSystem(self.env)
1969                        #a = TicketModule(self.env)
1970                        #print a.__dict__
1971                        #tn_sys = TicketNotificationSystem(self.env)
1972                        #print tn_sys
1973                        #print tn_sys.__dict__
1974                        #sys.exit(0)
1975
1976                        # create false {abs_}href properties, to trick Notify()
1977                        #
1978                        if not (self.VERSION in [0.11, 0.12]):
1979                                self.env.abs_href = Href(self.get_config('project', 'url'))
1980                                self.env.href = Href(self.get_config('project', 'url'))
1981
1982
1983                        tn = TicketNotifyEmail(self.env)
1984
1985                        if self.parameters.alternate_notify_template:
1986
1987                                if self.VERSION >= 0.11:
1988
1989                                        from trac.web.chrome import Chrome
1990
1991                                        if  self.parameters.alternate_notify_template_update and not new:
1992                                                tn.template_name = self.parameters.alternate_notify_template_update
1993                                        else:
1994                                                tn.template_name = self.parameters.alternate_notify_template
1995
1996                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1997                                               
1998                                else:
1999
2000                                        tn.template_name = self.parameters.alternate_notify_template
2001
2002                        tn.notify(tkt, new, modtime)
2003
2004                except Exception, e:
2005                        self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2006
2007
2008
2009########## Parse Config File  ###########################################################
2010
2011def ReadConfig(file, name):
2012        """
2013        Parse the config file
2014        """
2015        if not os.path.isfile(file):
2016                print 'File %s does not exist' %file
2017                sys.exit(1)
2018
2019        config = trac_config.Configuration(file)
2020
2021        # Use given project name else use defaults
2022        #
2023        if name:
2024                sections = config.sections()
2025                if not name in sections:
2026                        print "Not a valid project name: %s" %name
2027                        print "Valid names: %s" %sections
2028                        sys.exit(1)
2029
2030                project =  SaraDict()
2031                for option, value in  config.options(name):
2032                        try:
2033                                project[option] = int(value)
2034                        except ValueError:
2035                                project[option] = value
2036
2037        else:
2038                # use some trac internals to get the defaults
2039                #
2040                tmp = config.parser.defaults()
2041                project =  SaraDict()
2042
2043                for option, value in tmp.items():
2044                        try:
2045                                project[option] = int(value)
2046                        except ValueError:
2047                                project[option] = value
2048
2049        return project
2050
2051########## Setup Logging ###############################################################
2052
2053def setup_log(parameters, project_name, interactive=None):
2054        """
2055        Setup loging
2056
2057        Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2058    would be interpreted by the ConfigParser itself.
2059        """
2060        logger = logging.getLogger('email2trac %s' %project_name)
2061
2062        if interactive:
2063                parameters.log_type = 'stderr'
2064
2065        if not parameters.log_type:
2066                parameters.log_type = 'syslog'
2067
2068        if parameters.log_type == 'file':
2069
2070                if not parameters.log_file:
2071                        parameters.log_file = 'email2trac.log'
2072
2073                if not os.path.isabs(parameters.log_file):
2074                        import tempfile
2075                        parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2076
2077                log_handler = logging.FileHandler(parameters.log_file)
2078
2079        elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2080                # Requires win32 extensions
2081                log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2082
2083        elif parameters.log_type in ('syslog', 'unix'):
2084                log_handler = logging.handlers.SysLogHandler('/dev/log')
2085
2086        elif parameters.log_type in ('stderr'):
2087                log_handler = logging.StreamHandler(sys.stderr)
2088
2089        else:
2090                log_handler = logging.handlers.BufferingHandler(0)
2091
2092        if parameters.log_format:
2093                parameters.log_format = parameters.log_format.replace('$(', '%(')
2094        else:
2095                parameters.log_format = '%(name)s: %(message)s'
2096
2097        log_formatter = logging.Formatter(parameters.log_format)
2098        log_handler.setFormatter(log_formatter)
2099        logger.addHandler(log_handler)
2100
2101        if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2102                logger.setLevel(logging.DEBUG)
2103
2104        elif parameters.log_level in ['INFO'] or parameters.verbose:
2105                logger.setLevel(logging.INFO)
2106
2107        elif parameters.log_level in ['WARNING']:
2108                logger.setLevel(logging.WARNING)
2109
2110        elif parameters.log_level in ['ERROR']:
2111                logger.setLevel(logging.ERROR)
2112
2113        elif parameters.log_level in ['CRITICAL']:
2114                logger.setLevel(logging.CRITICAL)
2115
2116        else:
2117                logger.setLevel(logging.INFO)
2118
2119        return logger
2120
2121
2122if __name__ == '__main__':
2123        # Default config file
2124        #
2125        configfile = '@email2trac_conf@'
2126        project = ''
2127        component = ''
2128        ticket_prefix = 'default'
2129        dry_run = None
2130        verbose = None
2131        debug_interactive = None
2132
2133        SHORT_OPT = 'cdhf:np:t:v'
2134        LONG_OPT  =  ['component=', 'debug', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2135
2136        try:
2137                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2138        except getopt.error,detail:
2139                print __doc__
2140                print detail
2141                sys.exit(1)
2142       
2143        project_name = None
2144        for opt,value in opts:
2145                if opt in [ '-h', '--help']:
2146                        print __doc__
2147                        sys.exit(0)
2148                elif opt in ['-c', '--component']:
2149                        component = value
2150                elif opt in ['-d', '--debug']:
2151                        debug_interactive = 1
2152                elif opt in ['-f', '--file']:
2153                        configfile = value
2154                elif opt in ['-n', '--dry-run']:
2155                        dry_run = True
2156                elif opt in ['-p', '--project']:
2157                        project_name = value
2158                elif opt in ['-t', '--ticket_prefix']:
2159                        ticket_prefix = value
2160                elif opt in ['-v', '--verbose']:
2161                        verbose = True
2162       
2163        settings = ReadConfig(configfile, project_name)
2164
2165        # The default prefix for ticket values in email2trac.conf
2166        #
2167        settings.ticket_prefix = ticket_prefix
2168        settings.dry_run = dry_run
2169        settings.verbose = verbose
2170
2171        if not settings.debug and debug_interactive:
2172                settings.debug = debug_interactive
2173
2174        if not settings.project:
2175                print __doc__
2176                print 'No Trac project is defined in the email2trac config file.'
2177                sys.exit(1)
2178
2179        logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2180       
2181        if component:
2182                settings['component'] = component
2183
2184        # Determine major trac version used to be in email2trac.conf
2185        # Quick hack for 0.12
2186        #
2187        version = '0.%s' %(trac_version.split('.')[1])
2188        if version.startswith('0.12'):
2189                version = '0.12'
2190
2191        logger.debug("Found trac version: %s" %(version))
2192       
2193        try:
2194                if version == '0.10':
2195                        from trac import attachment
2196                        from trac.env import Environment
2197                        from trac.ticket import Ticket
2198                        from trac.web.href import Href
2199                        from trac import util
2200                        #
2201                        # return  util.text.to_unicode(str)
2202                        #
2203                        # see http://projects.edgewall.com/trac/changeset/2799
2204                        from trac.ticket.notification import TicketNotifyEmail
2205                        from trac import config as trac_config
2206                        from trac.core import TracError
2207
2208                elif version == '0.11':
2209                        from trac import attachment
2210                        from trac.env import Environment
2211                        from trac.ticket import Ticket
2212                        from trac.web.href import Href
2213                        from trac import config as trac_config
2214                        from trac import util
2215                        from trac.core import TracError
2216                        from trac.perm import PermissionSystem
2217
2218                        #
2219                        # return  util.text.to_unicode(str)
2220                        #
2221                        # see http://projects.edgewall.com/trac/changeset/2799
2222                        from trac.ticket.notification import TicketNotifyEmail
2223
2224                elif version == '0.12':
2225                        from trac import attachment
2226                        from trac.env import Environment
2227                        from trac.ticket import Ticket
2228                        from trac.web.href import Href
2229                        from trac import config as trac_config
2230                        from trac import util
2231                        from trac.core import TracError
2232                        from trac.perm import PermissionSystem
2233
2234                        #
2235                        # return  util.text.to_unicode(str)
2236                        #
2237                        # see http://projects.edgewall.com/trac/changeset/2799
2238                        from trac.ticket.notification import TicketNotifyEmail
2239
2240
2241                else:
2242                        logger.error('TRAC version %s is not supported' %version)
2243                        sys.exit(1)
2244
2245                # Must be set before environment is created
2246                #
2247                if settings.has_key('python_egg_cache'):
2248                        python_egg_cache = str(settings['python_egg_cache'])
2249                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2250
2251                if settings.debug > 0:
2252                        logger.debug('Loading environment %s', settings.project)
2253
2254                env = Environment(settings['project'], create=0)
2255
2256                tktparser = TicketEmailParser(env, settings, logger, float(version))
2257                tktparser.parse(sys.stdin)
2258
2259        ## Catch all errors and use the logging module
2260        #
2261        except Exception, error:
2262
2263                etype, evalue, etb = sys.exc_info()
2264                for e in traceback.format_exception(etype, evalue, etb):
2265                        logger.critical(e)
2266
2267                if m:
2268                        tktparser.save_email_for_debug(m, True)
2269
2270                sys.exit(1)
2271# EOB
Note: See TracBrowser for help on using the repository browser.