source: trunk/email2trac.py.in @ 476

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

renamed variable new_ticket to new in update_ticket_fields

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