source: trunk/email2trac.py.in @ 468

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

appllied patch from kroseneg at schmidham dot net to define a workflow for each ticket status. see #198

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