source: trunk/email2trac.py.in @ 483

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

fixed a type error, '>=' and not '=>', Thanks to Konstantin Ryabitsev

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 58.8 KB
Line 
1#!@PYTHON@
2# Copyright (C) 2002
3#
4# This file is part of the email2trac utils
5#
6# This program is free software; you can redistribute it and/or modify it
7# under the terms of the GNU General Public License as published by the
8# Free Software Foundation; either version 2, or (at your option) any
9# later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program; if not, write to the Free Software
18# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA
19#
20# For vi/emacs or other use tabstop=4 (vi: set ts=4)
21#
22"""
23email2trac.py -- Email tickets to Trac.
24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
33
34The scripts reads emails from stdin and inserts directly into a Trac database.
35
36How to use
37----------
38 * See https://subtrac.sara.nl/oss/email2trac/
39
40 * Create an config file:
41    [DEFAULT]                        # REQUIRED
42    project      : /data/trac/test   # REQUIRED
43    debug        : 1                 # OPTIONAL, if set print some DEBUG info
44
45    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
46    project      : /data/trac/jouvin # use -p|--project jouvin. 
47       
48 * default config file is : /etc/email2trac.conf
49
50 * Commandline opions:
51                -h,--help
52                                -d, --debug
53                -f,--file  <configuration file>
54                -n,--dry-run
55                -p, --project <project name>
56                -t, --ticket_prefix <name>
57
58SVN Info:
59        $Id: email2trac.py.in 483 2010-08-16 15:02:50Z 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                #
322                if self.email_addr == self.trac_smtp_from:
323                        if self.email_name:
324                                self.author = self.email_name
325                        else:
326                                self.author = "email2trac"
327                else:
328                        self.author = self.email_addr
329
330                if self.parameters.ignore_trac_user_settings:
331                        return
332
333                # Is this a registered user, use email address as search key:
334                # result:
335                #   u : login name
336                #   n : Name that the user has set in the settings tab
337                #   e : email address that the user has set in the settings tab
338                #
339                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
340                        if e and (e.lower() == self.email_addr.lower()) ]
341
342                if len(users) >= 1:
343                        self.email_from = users[0][0]
344                        self.author = users[0][0]
345
346        def set_reply_fields(self, ticket, message):
347                """
348                Set all the right fields for a new ticket
349                """
350                self.logger.debug('function set_reply_fields')
351
352                ## Only use name or email adress
353                #ticket['reporter'] = self.email_from
354                ticket['reporter'] = self.author
355
356
357                # Put all CC-addresses in ticket CC field
358                #
359                if self.parameters.reply_all:
360
361                        email_cc = ''
362
363                        cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
364
365                        if not cc_addrs:
366                                return
367
368                        ## Build a list of forbidden CC addresses
369                        #
370                        #to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
371                        #to_list = list()
372                        #for n,e in to_addrs:
373                        #       to_list.append(e)
374                               
375                        # Always Remove reporter email address from cc-list
376                        #
377                        try:
378                                cc_addrs.remove((self.author, self.email_addr))
379                        except ValueError, detail:
380                                pass
381
382                        for name,addr in cc_addrs:
383               
384                                ## Prevent mail loop
385                                #
386                                #if addr in to_list:
387
388                                if addr == self.trac_smtp_from:
389                                        self.logger.debug("Skipping %s mail address for CC-field" %(addr))
390                                        continue
391
392                                if email_cc:
393                                        email_cc = '%s, %s' %(email_cc, addr)
394                                else:
395                                        email_cc = addr
396
397                        if email_cc:
398                                self.logger.debug('set_reply_fields: %s' %email_cc)
399
400                                ticket['cc'] = self.email_to_unicode(email_cc)
401
402
403########## DEBUG functions  ###########################################################
404
405        def debug_body(self, message_body, tempfile=False):
406                if tempfile:
407                        import tempfile
408                        body_file = tempfile.mktemp('.email2trac')
409                else:
410                        body_file = os.path.join(self.parameters.tmpdir, 'body.txt')
411
412                if self.parameters.dry_run:
413                        print 'DRY-RUN: not saving body to %s' %(body_file)
414                        return
415
416                print 'writing body to %s' %(body_file)
417                fx = open(body_file, 'wb')
418                if not message_body:
419                                message_body = '(None)'
420
421                message_body = message_body.encode('utf-8')
422                #message_body = unicode(message_body, 'iso-8859-15')
423
424                fx.write(message_body)
425                fx.close()
426                try:
427                        os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
428                except OSError:
429                        pass
430
431        def debug_attachments(self, message_parts):
432                """
433                """
434                self.logger.debug('function debug_attachments')
435               
436                n = 0
437                for item in message_parts:
438                        # Skip inline text parts
439                        if not isinstance(item, tuple):
440                                continue
441                               
442                        (original, filename, part) = item
443
444                        n = n + 1
445                        print 'part%d: Content-Type: %s' % (n, part.get_content_type())
446               
447                        s = 'part%d: filename: %s' %(n, filename)
448                        self.print_unicode(s)
449       
450                        ## Forbidden chars
451                        #
452                        filename = filename.replace('\\', '_')
453                        filename = filename.replace('/', '_')
454       
455
456                        part_file = os.path.join(self.parameters.tmpdir, filename)
457                        s = 'writing part%d (%s)' % (n,part_file)
458                        self.print_unicode(s)
459
460                        if self.parameters.dry_run:
461                                print 'DRY_RUN: NOT saving attachments'
462                                continue
463
464                        part_file = util.text.unicode_quote(part_file)
465
466                        fx = open(part_file, 'wb')
467                        text = part.get_payload(decode=1)
468
469                        if not text:
470                                text = '(None)'
471
472                        fx.write(text)
473                        fx.close()
474
475                        try:
476                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
477                        except OSError:
478                                pass
479
480        def save_email_for_debug(self, message, create_tempfile=False):
481
482                if create_tempfile:
483                        import tempfile
484                        msg_file = tempfile.mktemp('.email2trac')
485                else:
486                        #msg_file = '/var/tmp/msg.txt'
487                        msg_file = os.path.join(self.parameters.tmpdir, 'msg.txt')
488
489                if self.parameters.dry_run:
490                        print 'DRY_RUN: NOT saving email message to %s' %(msg_file)
491                else:
492                        print 'saving email to %s' %(msg_file)
493
494                        fx = open(msg_file, 'wb')
495                        fx.write('%s' % message)
496                        fx.close()
497                       
498                        try:
499                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
500                        except OSError:
501                                pass
502
503                message_parts = self.get_message_parts(message)
504                message_parts = self.unique_attachment_names(message_parts)
505                body_text = self.body_text(message_parts)
506                self.debug_body(body_text, True)
507                self.debug_attachments(message_parts)
508
509########## Conversion functions  ###########################################################
510
511        def email_to_unicode(self, message_str):
512                """
513                Email has 7 bit ASCII code, convert it to unicode with the charset
514                that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
515                understands it.
516                """
517                self.logger.debug("function email_to_unicode")
518
519                results =  email.Header.decode_header(message_str)
520
521                s = None
522                for text,format in results:
523                        if format:
524                                try:
525                                        temp = unicode(text, format)
526                                except UnicodeError, detail:
527                                        # This always works
528                                        #
529                                        temp = unicode(text, 'iso-8859-15')
530                                except LookupError, detail:
531                                        #text = 'ERROR: Could not find charset: %s, please install' %format
532                                        #temp = unicode(text, 'iso-8859-15')
533                                        temp = message_str
534                                       
535                        else:
536                                temp = string.strip(text)
537                                temp = unicode(text, 'iso-8859-15')
538
539                        if s:
540                                s = '%s %s' %(s, temp)
541                        else:
542                                s = '%s' %temp
543
544                #s = s.encode('utf-8')
545                return s
546
547        def str_to_dict(self, s):
548                """
549                Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
550                """
551                self.logger.debug("function str_to_dict")
552
553                fields = string.split(s, self.parameters.subject_field_separator)
554
555                result = dict()
556                for field in fields:
557                        try:
558                                index, value = string.split(field, '=')
559
560                                # We can not change the description of a ticket via the subject
561                                # line. The description is the body of the email
562                                #
563                                if index.lower() in ['description']:
564                                        continue
565
566                                if value:
567                                        result[index.lower()] = value
568
569                        except ValueError:
570                                pass
571                return result
572
573        def print_unicode(self,s):
574                """
575                This function prints unicode strings uif possible else it will quote it
576                """
577                try:
578                        self.logger.debug(s)
579                except UnicodeEncodeError, detail:
580                        self.logger.debug(util.text.unicode_quote(s))
581
582########## TRAC ticket functions  ###########################################################
583
584        def check_permission_participants(self, tkt, action):
585                """
586                Check if the mailer is allowed to update the ticket
587                """
588                self.logger.debug('function check_permission_participants')
589
590                if tkt['reporter'].lower() in [self.author, self.email_addr]:
591                        self.logger.debug('ALLOW, %s is the ticket reporter' %(self.email_addr))
592
593                        return True
594
595                perm = PermissionSystem(self.env)
596                if perm.check_permission(action, self.author):
597                        self.logger.debug('ALLOW, %s has trac permission to update the ticket' %(self.author))
598
599                        return True
600               
601                # Is the updater in the CC?
602                try:
603                        cc_list = tkt['cc'].split(',')
604                        for cc in cc_list:
605                                if self.email_addr.lower() in cc.strip():
606                                        self.logger.debug('ALLOW, %s is in the CC' %(self.email_addr))
607
608                                        return True
609
610                except KeyError:
611                        pass
612
613                return False
614
615        def check_permission(self, tkt, action):
616                """
617                check if the reporter has the right permission for the action:
618          - TICKET_CREATE
619          - TICKET_MODIFY
620          - TICKET_APPEND
621          - TICKET_CHGPROP
622
623                There are three models:
624                        - None      : no checking at all
625                        - trac      : check the permission via trac permission model
626                        - email2trac: ....
627                """
628                self.logger.debug("function check_permission")
629
630                if self.parameters.ticket_permission_system in ['trac']:
631
632                        perm = PermissionSystem(self.env)
633                        if perm.check_permission(action, self.author):
634                                return True
635                        else:
636                                return False
637
638                elif self.parameters.ticket_permission_system in ['update_restricted_to_participants']:
639                        return (self.check_permission_participants(tkt, action))       
640
641                ## Default is to allow everybody ticket updates and ticket creation
642                #
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                                #
958                                orig_subject = result.group(3)
959
960                                self.logger.debug('subject search string: %s' %(orig_subject))
961
962                                cursor = self.db.cursor()
963                                summaries = [orig_subject, '%%: %s' % orig_subject]
964
965                                ## Convert days to seconds
966                                #
967                                lookback = int(time.mktime(time.gmtime())) - \
968                                                self.parameters.ticket_update_by_subject_lookback * 24 * 3600
969
970
971                                for summary in summaries:
972                                        self.logger.debug('Looking for summary matching: "%s"' % summary)
973
974                                        sql = """SELECT id FROM ticket
975                                                        WHERE changetime >= %s AND summary LIKE %s
976                                                        ORDER BY changetime DESC"""
977                                        cursor.execute(sql, [lookback, summary.strip()])
978
979                                        for row in cursor:
980                                                (matched_id,) = row
981
982                                                self.logger.debug('Found matching ticket id: %d' % matched_id)
983
984                                                break
985
986                                        if matched_id:
987                                                matched_id = '#%d' % matched_id
988                                                return matched_id
989
990                return matched_id
991
992
993        def new_ticket(self, msg, subject, spam, set_fields = None):
994                """
995                Create a new ticket
996                """
997                self.logger.debug('function new_ticket')
998
999                tkt = Ticket(self.env)
1000
1001                self.set_reply_fields(tkt, msg)
1002
1003                self.set_ticket_fields(tkt)
1004
1005                ## Check the permission of the reporter
1006                #
1007                if self.parameters.ticket_permission_system:
1008                        if not self.check_permission(tkt, 'TICKET_CREATE'):
1009                                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1010                                return False
1011
1012                ## Old style setting for component, will be removed
1013                #
1014                if spam:
1015                        tkt['component'] = 'Spam'
1016
1017                elif self.parameters.has_key('component'):
1018                        tkt['component'] = self.parameters['component']
1019
1020                if not msg['Subject']:
1021                        tkt['summary'] = u'(No subject)'
1022                else:
1023                        tkt['summary'] = subject
1024
1025
1026                if set_fields:
1027                        rest, keywords = string.split(set_fields, '?')
1028
1029                        if keywords:
1030                                update_fields = self.str_to_dict(keywords)
1031                                self.update_ticket_fields(tkt, update_fields)
1032
1033
1034                message_parts = self.get_message_parts(msg)
1035
1036                ## Must we update some ticket fields properties via body_text
1037                #
1038                if self.properties:
1039                                self.update_ticket_fields(tkt, self.properties)
1040
1041                message_parts = self.unique_attachment_names(message_parts)
1042               
1043                ## produce e-mail like header
1044                #
1045                head = ''
1046                if self.parameters.email_header:
1047                        head = self.email_header_txt(msg)
1048                        message_parts.insert(0, head)
1049                       
1050                body_text = self.body_text(message_parts)
1051
1052                tkt['description'] = body_text
1053
1054                ## When is the change committed
1055                #
1056                if self.VERSION < 0.11:
1057                        when = int(time.time())
1058                else:
1059                        when = datetime.now(util.datefmt.utc)
1060
1061                if self.parameters.dry_run:
1062                        print 'DRY_RUN: tkt.insert()'
1063                else:
1064                        self.id = tkt.insert()
1065       
1066                changed = False
1067                comment = ''
1068
1069                ## some routines in trac are dependend on ticket id     
1070                #  like alternate notify template
1071                #
1072                if self.parameters.alternate_notify_template:
1073                        tkt['id'] = self.id
1074                        changed = True
1075
1076                ## Rewrite the description if we have mailto enabled
1077                #
1078                if self.parameters.mailto_link:
1079                        changed = True
1080                        comment = u'\nadded mailto line\n'
1081                        mailto = self.html_mailto_link( m['Subject'])
1082
1083                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1084                                %(head, mailto, body_text)
1085       
1086                ## Save the attachments to the ticket   
1087                #
1088                error_with_attachments =  self.attach_attachments(message_parts)
1089
1090                if error_with_attachments:
1091                        changed = True
1092                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1093
1094                if changed:
1095                        if self.parameters.dry_run:
1096                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1097                        else:
1098                                tkt.save_changes(tkt['reporter'], comment)
1099                                #print tkt.get_changelog(self.db, when)
1100
1101                if not spam:
1102                        self.notify(tkt, True)
1103
1104
1105        def attach_attachments(self, message_parts, update=False):
1106                '''
1107                save any attachments as files in the ticket's directory
1108                '''
1109                self.logger.debug('function attach_attachments()')
1110
1111                if self.parameters.dry_run:
1112                        print "DRY_RUN: no attachments attached to tickets"
1113                        return ''
1114
1115                count = 0
1116
1117                ## Get Maxium attachment size
1118                #
1119                max_size = int(self.get_config('attachment', 'max_size'))
1120                status   = None
1121               
1122                for item in message_parts:
1123                        ## Skip body parts
1124                        #
1125                        if not isinstance(item, tuple):
1126                                continue
1127                               
1128                        (original, filename, part) = item
1129
1130                        ## We have to determine the size so we use this temporary solution. we must escape it
1131                        #  else we get UnicodeErrors.
1132                        #
1133                        path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, util.text.unicode_quote(filename)))
1134                        text = part.get_payload(decode=1)
1135                        if not text:
1136                                text = '(None)'
1137                        fd.write(text)
1138                        fd.close()
1139
1140                        ## get the file_size
1141                        #
1142                        stats = os.lstat(path)
1143                        file_size = stats[ST_SIZE]
1144
1145                        ## Check if the attachment size is allowed
1146                        #
1147                        if (max_size != -1) and (file_size > max_size):
1148                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1149                                        %(status, original, file_size, max_size)
1150
1151                                os.unlink(path)
1152                                continue
1153                        else:
1154                                count = count + 1
1155                                       
1156                        ## Insert the attachment
1157                        #
1158                        fd = open(path, 'rb')
1159                        if self.system == 'discussion':
1160                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1161                                  % (self.id,))
1162                        else:
1163                                self.logger.debug('Attach %s to ticket %d' %(util.text.unicode_quote(filename), self.id))
1164                                att = attachment.Attachment(self.env, 'ticket', self.id)
1165 
1166                        ## This will break the ticket_update system, the body_text is vaporized
1167                        #  ;-(
1168                        #
1169                        if not update:
1170                                att.author = self.author
1171                                att.description = self.email_to_unicode('Added by email2trac')
1172
1173                        try:
1174                                self.logger.debug('Insert atachment')
1175                                att.insert(filename, fd, file_size)
1176                        except OSError, detail:
1177                                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1178                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1179
1180                        ## Remove the created temporary filename
1181                        #
1182                        fd.close()
1183                        os.unlink(path)
1184
1185                ## return error
1186                #
1187                return status
1188
1189########## Fullblog functions  #################################################
1190
1191        def blog(self, id):
1192                """
1193                The blog create/update function
1194                """
1195                ## import the modules
1196                #
1197                from tracfullblog.core import FullBlogCore
1198                from tracfullblog.model import BlogPost, BlogComment
1199                from trac.test import Mock, MockPerm
1200
1201                ## instantiate blog core
1202                #
1203                blog = FullBlogCore(self.env)
1204                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1205
1206                if id:
1207
1208                        ## update blog
1209                        #
1210                        comment = BlogComment(self.env, id)
1211                        comment.author = self.author
1212
1213                        message_parts = self.get_message_parts(m)
1214                        comment.comment = self.body_text(message_parts)
1215
1216                        blog.create_comment(req, comment)
1217
1218                else:
1219                        ## create blog
1220                        #
1221                        import time
1222                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1223
1224                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1225                       
1226                        post.author = self.author
1227                        post.title = self.email_to_unicode(m['Subject'])
1228
1229                        message_parts = self.get_message_parts(m)
1230                        post.body = self.body_text(message_parts)
1231                       
1232                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1233
1234
1235########## Discussion functions  ##############################################
1236
1237        def discussion_topic(self, content, subject):
1238
1239                ## Import modules.
1240                #
1241                from tracdiscussion.api import DiscussionApi
1242                from trac.util.datefmt import to_timestamp, utc
1243
1244                self.logger.debug('Creating a new topic in forum:', self.id)
1245
1246                ## Get dissussion API component.
1247                #
1248                api = self.env[DiscussionApi]
1249                context = self._create_context(content, subject)
1250
1251                ## Get forum for new topic.
1252                #
1253                forum = api.get_forum(context, self.id)
1254
1255                if not forum:
1256                        self.logger.error("ERROR: Replied forum doesn't exist")
1257
1258                ## Prepare topic.
1259                #
1260                topic = {'forum' : forum['id'],
1261                                 'subject' : context.subject,
1262                                 'time': to_timestamp(datetime.now(utc)),
1263                                 'author' : self.author,
1264                                 'subscribers' : [self.email_addr],
1265                                 'body' : self.body_text(context.content_parts)}
1266
1267                ## Add topic to DB and commit it.
1268                #
1269                self._add_topic(api, context, topic)
1270                self.db.commit()
1271
1272        def discussion_topic_reply(self, content, subject):
1273
1274                ## Import modules.
1275                #
1276                from tracdiscussion.api import DiscussionApi
1277                from trac.util.datefmt import to_timestamp, utc
1278
1279                self.logger.debug('Replying to discussion topic', self.id)
1280
1281                ## Get dissussion API component.
1282                #
1283                api = self.env[DiscussionApi]
1284                context = self._create_context(content, subject)
1285
1286                ## Get replied topic.
1287                #
1288                topic = api.get_topic(context, self.id)
1289
1290                if not topic:
1291                        self.logger.error("ERROR: Replied topic doesn't exist")
1292
1293                ## Prepare message.
1294                #
1295                message = {'forum' : topic['forum'],
1296                                   'topic' : topic['id'],
1297                                   'replyto' : -1,
1298                                   'time' : to_timestamp(datetime.now(utc)),
1299                                   'author' : self.author,
1300                                   'body' : self.body_text(context.content_parts)}
1301
1302                ## Add message to DB and commit it.
1303                #
1304                self._add_message(api, context, message)
1305                self.db.commit()
1306
1307        def discussion_message_reply(self, content, subject):
1308
1309                ## Import modules.
1310                #
1311                from tracdiscussion.api import DiscussionApi
1312                from trac.util.datefmt import to_timestamp, utc
1313
1314                self.loggger.debug('Replying to discussion message', self.id)
1315
1316                ## Get dissussion API component.
1317                #
1318                api = self.env[DiscussionApi]
1319                context = self._create_context(content, subject)
1320
1321                ## Get replied message.
1322                #
1323                message = api.get_message(context, self.id)
1324
1325                if not message:
1326                        self.logger.error("ERROR: Replied message doesn't exist")
1327
1328                ## Prepare message.
1329                #
1330                message = {'forum' : message['forum'],
1331                                   'topic' : message['topic'],
1332                                   'replyto' : message['id'],
1333                                   'time' : to_timestamp(datetime.now(utc)),
1334                                   'author' : self.author,
1335                                   'body' : self.body_text(context.content_parts)}
1336
1337                ## Add message to DB and commit it.
1338                #
1339                self._add_message(api, context, message)
1340                self.db.commit()
1341
1342        def _create_context(self, content, subject):
1343
1344                ## Import modules.
1345                #
1346                from trac.mimeview import Context
1347                from trac.web.api import Request
1348                from trac.perm import PermissionCache
1349
1350                ## TODO: Read server base URL from config.
1351                #  Create request object to mockup context creation.
1352                #
1353                environ = {'SERVER_PORT' : 80,
1354                                   'SERVER_NAME' : 'test',
1355                                   'REQUEST_METHOD' : 'POST',
1356                                   'wsgi.url_scheme' : 'http',
1357                                   'wsgi.input' : sys.stdin}
1358                chrome =  {'links': {},
1359                                   'scripts': [],
1360                                   'ctxtnav': [],
1361                                   'warnings': [],
1362                                   'notices': []}
1363
1364                if self.env.base_url_for_redirect:
1365                        environ['trac.base_url'] = self.env.base_url
1366
1367                req = Request(environ, None)
1368                req.chrome = chrome
1369                req.tz = 'missing'
1370                req.authname = self.author
1371                req.perm = PermissionCache(self.env, self.author)
1372
1373                ## Create and return context.
1374                #
1375                context = Context.from_request(req)
1376                context.realm = 'discussion-email2trac'
1377                context.cursor = self.db.cursor()
1378                context.content = content
1379                context.subject = subject
1380
1381                ## Read content parts from content.
1382                #
1383                context.content_parts = self.get_message_parts(content)
1384                context.content_parts = self.unique_attachment_names(
1385                  context.content_parts)
1386
1387                return context
1388
1389        def _add_topic(self, api, context, topic):
1390                context.req.perm.assert_permission('DISCUSSION_APPEND')
1391
1392                ## Filter topic.
1393                #
1394                for discussion_filter in api.discussion_filters:
1395                        accept, topic_or_error = discussion_filter.filter_topic(
1396                          context, topic)
1397                        if accept:
1398                                topic = topic_or_error
1399                        else:
1400                                raise TracError(topic_or_error)
1401
1402                ## Add a new topic.
1403                #
1404                api.add_topic(context, topic)
1405
1406                ## Get inserted topic with new ID.
1407                #
1408                topic = api.get_topic_by_time(context, topic['time'])
1409
1410                ## Attach attachments.
1411                #
1412                self.id = topic['id']
1413                self.attach_attachments(context.content_parts, True)
1414
1415                ## Notify change listeners.
1416                #
1417                for listener in api.topic_change_listeners:
1418                        listener.topic_created(context, topic)
1419
1420        def _add_message(self, api, context, message):
1421                context.req.perm.assert_permission('DISCUSSION_APPEND')
1422
1423                ## Filter message.
1424                #
1425                for discussion_filter in api.discussion_filters:
1426                        accept, message_or_error = discussion_filter.filter_message(
1427                          context, message)
1428                        if accept:
1429                                message = message_or_error
1430                        else:
1431                                raise TracError(message_or_error)
1432
1433                ## Add message.
1434                #
1435                api.add_message(context, message)
1436
1437                ## Get inserted message with new ID.
1438                #
1439                message = api.get_message_by_time(context, message['time'])
1440
1441                ## Attach attachments.
1442                #
1443                self.id = message['topic']
1444                self.attach_attachments(context.content_parts, True)
1445
1446                ## Notify change listeners.
1447                #
1448                for listener in api.message_change_listeners:
1449                        listener.message_created(context, message)
1450
1451########## MAIN function  ######################################################
1452
1453        def parse(self, fp):
1454                """
1455                """
1456                self.logger.debug('Main function parse')
1457                global m
1458
1459                m = email.message_from_file(fp)
1460               
1461                if not m:
1462                        self.logger.debug('This is not a valid email message format')
1463                        return
1464                       
1465                ## Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1466                #
1467                try:
1468                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1469                except AttributeError, detail:
1470                        pass
1471
1472                if self.parameters.debug:         # save the entire e-mail message text
1473                        self.save_email_for_debug(m, True)
1474
1475                self.db = self.env.get_db_cnx()
1476                self.get_sender_info(m)
1477
1478                if not self.email_header_acl('white_list', self.email_addr, True):
1479                        self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1480                        return False
1481
1482                if self.email_header_acl('black_list', self.email_addr, False):
1483                        self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1484                        return False
1485
1486                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1487                        self.logger.info('Message rejected : %s not in recipient list' %(self.to_email_addr))
1488                        return False
1489
1490                ## If spam drop the message
1491                #
1492                if self.spam(m) == 'drop':
1493                        return False
1494
1495                elif self.spam(m) == 'spam':
1496                        spam_msg = True
1497                else:
1498                        spam_msg = False
1499
1500                if not m['Subject']:
1501                        subject  = 'No Subject'
1502                else:
1503                        subject  = self.email_to_unicode(m['Subject'])
1504
1505                self.logger.debug('subject: %s' %subject)
1506
1507                ## [hic] #1529: Re: LRZ
1508                #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1509                #
1510                ticket_regex = r'''
1511                        (?P<new_fields>[#][?].*)
1512                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1513                        '''
1514                ## Check if  FullBlogPlugin is installed
1515                #
1516                blog_enabled = None
1517                blog_regex = ''
1518                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1519                        blog_enabled = True
1520                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1521
1522
1523                ## Check if DiscussionPlugin is installed
1524                #
1525                discussion_enabled = None
1526                discussion_regex = ''
1527                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1528                        discussion_enabled = True
1529                        discussion_regex = r'''
1530                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1531                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1532                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1533                        '''
1534
1535
1536                regex_str = ticket_regex + blog_regex + discussion_regex
1537                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1538
1539                ## Find out if this is a ticket, a blog or a discussion
1540                #
1541                result =  SYSTEM_RE.search(subject)
1542
1543                if result:
1544                        ## update ticket + fields
1545                        #
1546                        if result.group('reply'):
1547                                self.system = 'ticket'
1548
1549                                ## Skip the last ':' character
1550                                #
1551                                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1552                                        self.new_ticket(m, subject, spam_msg)
1553
1554                        ## New ticket + fields
1555                        #
1556                        elif result.group('new_fields'):
1557                                self.system = 'ticket'
1558                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1559
1560                        if blog_enabled:
1561                                if result.group('blog'):
1562                                        self.system = 'blog'
1563                                        self.blog(result.group('blog_id'))
1564
1565                        if discussion_enabled:
1566                                ## New topic.
1567                                #
1568                                if result.group('forum'):
1569                                        self.system = 'discussion'
1570                                        self.id = int(result.group('forum_id'))
1571                                        self.discussion_topic(m, subject[result.end('forum'):])
1572
1573                                ## Reply to topic.
1574                                #
1575                                elif result.group('topic'):
1576                                        self.system = 'discussion'
1577                                        self.id = int(result.group('topic_id'))
1578                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1579
1580                                ## Reply to topic message.
1581                                #
1582                                elif result.group('message'):
1583                                        self.system = 'discussion'
1584                                        self.id = int(result.group('message_id'))
1585                                        self.discussion_message_reply(m, subject[result.end('message'):])
1586
1587                else:
1588                        self.system = 'ticket'
1589                        result = self.ticket_update_by_subject(subject)
1590                        if result:
1591                                if not self.ticket_update(m, result, spam_msg):
1592                                        self.new_ticket(m, subject, spam_msg)
1593                        else:
1594                                ## No update by subject, so just create a new ticket
1595                                #
1596                                self.new_ticket(m, subject, spam_msg)
1597
1598
1599########## BODY TEXT functions  ###########################################################
1600
1601        def strip_signature(self, text):
1602                """
1603                Strip signature from message, inspired by Mailman software
1604                """
1605                self.logger.debug('function strip_signature')
1606
1607                body = []
1608                for line in text.splitlines():
1609                        if line == '-- ':
1610                                break
1611                        body.append(line)
1612
1613                return ('\n'.join(body))
1614
1615        def reflow(self, text, delsp = 0):
1616                """
1617                Reflow the message based on the format="flowed" specification (RFC 3676)
1618                """
1619                flowedlines = []
1620                quotelevel = 0
1621                prevflowed = 0
1622
1623                for line in text.splitlines():
1624                        from re import match
1625                       
1626                        ## Figure out the quote level and the content of the current line
1627                        #
1628                        m = match('(>*)( ?)(.*)', line)
1629                        linequotelevel = len(m.group(1))
1630                        line = m.group(3)
1631
1632                        ## Determine whether this line is flowed
1633                        #
1634                        if line and line != '-- ' and line[-1] == ' ':
1635                                flowed = 1
1636                        else:
1637                                flowed = 0
1638
1639                        if flowed and delsp and line and line[-1] == ' ':
1640                                line = line[:-1]
1641
1642                        ## If the previous line is flowed, append this line to it
1643                        #
1644                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1645                                flowedlines[-1] += line
1646
1647                        ## Otherwise, start a new line
1648                        #
1649                        else:
1650                                flowedlines.append('>' * linequotelevel + line)
1651
1652                        prevflowed = flowed
1653                       
1654
1655                return '\n'.join(flowedlines)
1656
1657        def strip_quotes(self, text):
1658                """
1659                Strip quotes from message by Nicolas Mendoza
1660                """
1661                self.logger.debug('function strip_quotes')
1662
1663                body = []
1664                for line in text.splitlines():
1665                        try:
1666
1667                                if line.startswith(self.parameters.email_quote):
1668                                        continue
1669
1670                        except UnicodeDecodeError:
1671
1672                                tmp_line = self.email_to_unicode(line)
1673                                if tmp_line.startswith(self.parameters.email_quote):
1674                                        continue
1675                               
1676                        body.append(line)
1677
1678                return ('\n'.join(body))
1679
1680        def inline_properties(self, text):
1681                """
1682                Parse text if we use inline keywords to set ticket fields
1683                """
1684                self.logger.debug('function inline_properties')
1685
1686                properties = dict()
1687                body = list()
1688
1689                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1690
1691                for line in text.splitlines():
1692                        match = INLINE_EXP.match(line)
1693                        if match:
1694                                keyword, value = match.groups()
1695                                self.properties[keyword] = value.strip()
1696
1697                                self.logger.debug('inline properties: %s : %s' %(keyword,value))
1698
1699                        else:
1700                                body.append(line)
1701                               
1702                return '\n'.join(body)
1703
1704
1705        def wrap_text(self, text, replace_whitespace = False):
1706                """
1707                Will break a lines longer then given length into several small
1708                lines of size given length
1709                """
1710                import textwrap
1711
1712                LINESEPARATOR = '\n'
1713                reformat = ''
1714
1715                for s in text.split(LINESEPARATOR):
1716                        tmp = textwrap.fill(s, self.parameters.use_textwrap)
1717                        if tmp:
1718                                reformat = '%s\n%s' %(reformat,tmp)
1719                        else:
1720                                reformat = '%s\n' %reformat
1721
1722                return reformat
1723
1724                # Python2.4 and higher
1725                #
1726                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1727                #
1728
1729########## EMAIL attachements functions ###########################################################
1730
1731        def inline_part(self, part):
1732                """
1733                """
1734                self.logger.debug('function inline_part()')
1735
1736                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1737
1738        def get_message_parts(self, msg):
1739                """
1740                parses the email message and returns a list of body parts and attachments
1741                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1742                """
1743                self.logger.debug('function get_message_parts()')
1744
1745                message_parts = list()
1746       
1747                ALTERNATIVE_MULTIPART = False
1748
1749                for part in msg.walk():
1750                        self.logger.debug('Message part: Main-Type: %s' % part.get_content_maintype())
1751                        self.logger.debug('Message part: Content-Type: %s' % part.get_content_type())
1752
1753                        ## Check content type
1754                        #
1755                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1756                                self.logger.debug("A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename()))
1757                                continue
1758
1759                        ## Catch some mulitpart execptions
1760                        #
1761                        if part.get_content_type() == 'multipart/alternative':
1762                                ALTERNATIVE_MULTIPART = True
1763                                continue
1764
1765                        ## Skip multipart containers
1766                        #
1767                        if part.get_content_maintype() == 'multipart':
1768                                self.logger.debug("Skipping multipart container")
1769
1770                                continue
1771                       
1772                        ## 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"
1773                        #
1774                        inline = self.inline_part(part)
1775
1776                        ## Drop HTML message
1777                        #
1778                        if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
1779                                if part.get_content_type() == 'text/html':
1780                                        self.logger.debug('Skipping alternative HTML message')
1781                                        ALTERNATIVE_MULTIPART = False
1782                                        continue
1783
1784                        ## Inline text parts are where the body is
1785                        #
1786                        if part.get_content_type() == 'text/plain' and inline:
1787                                self.logger.debug('               Inline body part')
1788
1789                                ## Try to decode, if fails then do not decode
1790                                #
1791                                body_text = part.get_payload(decode=1)
1792                                if not body_text:                       
1793                                        body_text = part.get_payload(decode=0)
1794
1795                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1796                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1797
1798                                if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
1799                                        body_text = self.reflow(body_text, delsp == 'yes')
1800       
1801                                if self.parameters.strip_signature:
1802                                        body_text = self.strip_signature(body_text)
1803
1804                                if self.parameters.strip_quotes:
1805                                        body_text = self.strip_quotes(body_text)
1806
1807                                if self.parameters.inline_properties:
1808                                        body_text = self.inline_properties(body_text)
1809
1810                                if self.parameters.use_textwrap:
1811                                        body_text = self.wrap_text(body_text)
1812
1813                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1814                                #
1815                                charset = part.get_content_charset()
1816                                if not charset:
1817                                        charset = 'iso-8859-15'
1818
1819                                try:
1820                                        ubody_text = unicode(body_text, charset)
1821
1822                                except UnicodeError, detail:
1823                                        ubody_text = unicode(body_text, 'iso-8859-15')
1824
1825                                except LookupError, detail:
1826                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1827
1828                                if self.parameters.verbatim_format:
1829                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1830                                else:
1831                                        message_parts.append('%s' %ubody_text)
1832                        else:
1833                                if self.parameters.debug:
1834                                        s = '              Filename: %s' % part.get_filename()
1835                                        self.print_unicode(s)
1836
1837                                ## First try to use email header function to convert filename.
1838                                #  If this fails the use the plan filename
1839                                #
1840                                try:
1841                                        filename = self.email_to_unicode(part.get_filename())
1842                                except UnicodeEncodeError, detail:
1843                                        filename = part.get_filename()
1844
1845                                message_parts.append((filename, part))
1846
1847                return message_parts
1848               
1849        def unique_attachment_names(self, message_parts):
1850                """
1851                """
1852                renamed_parts = []
1853                attachment_names = set()
1854
1855                for item in message_parts:
1856                       
1857                        ## If not an attachment, leave it alone
1858                        #
1859                        if not isinstance(item, tuple):
1860                                renamed_parts.append(item)
1861                                continue
1862                               
1863                        (filename, part) = item
1864
1865                        ## If no filename, use a default one
1866                        #
1867                        if not filename:
1868                                filename = 'untitled-part'
1869
1870                                ## Guess the extension from the content type, use non strict mode
1871                                #  some additional non-standard but commonly used MIME types
1872                                #  are also recognized
1873                                #
1874                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1875                                if not ext:
1876                                        ext = '.bin'
1877
1878                                filename = '%s%s' % (filename, ext)
1879
1880                        ## Discard relative paths for windows/unix in attachment names
1881                        #
1882                        #filename = filename.replace('\\', '/').replace(':', '/')
1883                        filename = filename.replace('\\', '_')
1884                        filename = filename.replace('/', '_')
1885
1886                        ## remove linefeed char
1887                        #
1888                        for forbidden_char in ['\r', '\n']:
1889                                filename = filename.replace(forbidden_char,'')
1890
1891                        ## We try to normalize the filename to utf-8 NFC if we can.
1892                        #  Files uploaded from OS X might be in NFD.
1893                        #  Check python version and then try it
1894                        #
1895                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1896                        #       try:
1897                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1898                        #       except TypeError:
1899                        #               pass
1900
1901                        ## Make the filename unique for this ticket
1902                        #
1903                        num = 0
1904                        unique_filename = filename
1905                        dummy_filename, ext = os.path.splitext(filename)
1906
1907                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1908                                num += 1
1909                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1910                               
1911                        if self.parameters.debug:
1912                                s = 'Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1913                                self.print_unicode(s)
1914
1915                        attachment_names.add(unique_filename)
1916
1917                        renamed_parts.append((filename, unique_filename, part))
1918       
1919                return renamed_parts
1920                       
1921                       
1922        def attachment_exists(self, filename):
1923
1924                if self.parameters.debug:
1925                        s = 'attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
1926                        self.print_unicode(s)
1927
1928                ## We have no valid ticket id
1929                #
1930                if not self.id:
1931                        return False
1932
1933                try:
1934                        if self.system == 'discussion':
1935                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1936                                  % (self.id,), filename)
1937                        else:
1938                                att = attachment.Attachment(self.env, 'ticket', self.id,
1939                                  filename)
1940                        return True
1941                except attachment.ResourceNotFound:
1942                        return False
1943
1944########## TRAC Ticket Text ###########################################################
1945                       
1946        def body_text(self, message_parts):
1947                body_text = []
1948               
1949                for part in message_parts:
1950
1951                        ## Plain text part, append it
1952                        #
1953                        if not isinstance(part, tuple):
1954                                body_text.extend(part.strip().splitlines())
1955                                body_text.append("")
1956                                continue
1957                               
1958                        (original, filename, part) = part
1959                        inline = self.inline_part(part)
1960                       
1961                        if part.get_content_maintype() == 'image' and inline:
1962                                if self.system != 'discussion':
1963                                        body_text.append('[[Image(%s)]]' % filename)
1964                                body_text.append("")
1965                        else:
1966                                if self.system != 'discussion':
1967                                        body_text.append('[attachment:"%s"]' % filename)
1968                                body_text.append("")
1969                               
1970                body_text = '\r\n'.join(body_text)
1971                return body_text
1972
1973        def html_mailto_link(self, subject):
1974                """
1975                This function returns a HTML mailto tag with the ticket id and author email address
1976                """
1977                if not self.author:
1978                        author = self.email_addr
1979                else:   
1980                        author = self.author
1981
1982                if not self.parameters.mailto_cc:
1983                        self.parameters.mailto_cc = ''
1984
1985                ## use urllib to escape the chars
1986                #
1987                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1988                       urllib.quote(self.email_addr),
1989                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1990                           urllib.quote(self.parameters.mailto_cc)
1991                           )
1992
1993                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)
1994                return s
1995
1996########## TRAC notify section ###########################################################
1997
1998        def notify(self, tkt, new=True, modtime=0):
1999                """
2000                A wrapper for the TRAC notify function. So we can use templates
2001                """
2002                self.logger.debug('function notify()')
2003
2004                if self.parameters.dry_run:
2005                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
2006                                return
2007                try:
2008
2009                        #from trac.ticket.web_ui import TicketModule
2010                        #from trac.ticket.notification import TicketNotificationSystem
2011                        #ticket_sys = TicketNotificationSystem(self.env)
2012                        #a = TicketModule(self.env)
2013                        #print a.__dict__
2014                        #tn_sys = TicketNotificationSystem(self.env)
2015                        #print tn_sys
2016                        #print tn_sys.__dict__
2017                        #sys.exit(0)
2018
2019                        ## create false {abs_}href properties, to trick Notify()
2020                        #
2021                        if not (self.VERSION in [0.11, 0.12]):
2022                                self.env.abs_href = Href(self.get_config('project', 'url'))
2023                                self.env.href = Href(self.get_config('project', 'url'))
2024
2025
2026                        tn = TicketNotifyEmail(self.env)
2027
2028                        if self.parameters.alternate_notify_template:
2029
2030                                if self.VERSION >= 0.11:
2031
2032                                        from trac.web.chrome import Chrome
2033
2034                                        if  self.parameters.alternate_notify_template_update and not new:
2035                                                tn.template_name = self.parameters.alternate_notify_template_update
2036                                        else:
2037                                                tn.template_name = self.parameters.alternate_notify_template
2038
2039                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2040                                               
2041                                else:
2042
2043                                        tn.template_name = self.parameters.alternate_notify_template
2044
2045                        tn.notify(tkt, new, modtime)
2046
2047                except Exception, e:
2048                        self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2049
2050
2051
2052########## Parse Config File  ###########################################################
2053
2054def ReadConfig(file, name):
2055        """
2056        Parse the config file
2057        """
2058        if not os.path.isfile(file):
2059                print 'File %s does not exist' %file
2060                sys.exit(1)
2061
2062        config = trac_config.Configuration(file)
2063
2064        ## Use given project name else use defaults
2065        #
2066        if name:
2067                sections = config.sections()
2068                if not name in sections:
2069                        print "Not a valid project name: %s" %name
2070                        print "Valid names: %s" %sections
2071                        sys.exit(1)
2072
2073                project =  SaraDict()
2074                for option, value in  config.options(name):
2075                        try:
2076                                project[option] = int(value)
2077                        except ValueError:
2078                                project[option] = value
2079
2080        else:
2081                ## use some trac internals to get the defaults
2082                #
2083                tmp = config.parser.defaults()
2084                project =  SaraDict()
2085
2086                for option, value in tmp.items():
2087                        try:
2088                                project[option] = int(value)
2089                        except ValueError:
2090                                project[option] = value
2091
2092        return project
2093
2094########## Setup Logging ###############################################################
2095
2096def setup_log(parameters, project_name, interactive=None):
2097        """
2098        Setup loging
2099
2100        Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2101    would be interpreted by the ConfigParser itself.
2102        """
2103        logger = logging.getLogger('email2trac %s' %project_name)
2104
2105        if interactive:
2106                parameters.log_type = 'stderr'
2107
2108        if not parameters.log_type:
2109                parameters.log_type = 'syslog'
2110
2111        if parameters.log_type == 'file':
2112
2113                if not parameters.log_file:
2114                        parameters.log_file = 'email2trac.log'
2115
2116                if not os.path.isabs(parameters.log_file):
2117                        import tempfile
2118                        parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2119
2120                log_handler = logging.FileHandler(parameters.log_file)
2121
2122        elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2123                ## Requires win32 extensions
2124                #
2125                log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2126
2127        elif parameters.log_type in ('syslog', 'unix'):
2128                log_handler = logging.handlers.SysLogHandler('/dev/log')
2129
2130        elif parameters.log_type in ('stderr'):
2131                log_handler = logging.StreamHandler(sys.stderr)
2132
2133        else:
2134                log_handler = logging.handlers.BufferingHandler(0)
2135
2136        if parameters.log_format:
2137                parameters.log_format = parameters.log_format.replace('$(', '%(')
2138        else:
2139                parameters.log_format = '%(name)s: %(message)s'
2140
2141        log_formatter = logging.Formatter(parameters.log_format)
2142        log_handler.setFormatter(log_formatter)
2143        logger.addHandler(log_handler)
2144
2145        if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2146                logger.setLevel(logging.DEBUG)
2147
2148        elif parameters.log_level in ['INFO'] or parameters.verbose:
2149                logger.setLevel(logging.INFO)
2150
2151        elif parameters.log_level in ['WARNING']:
2152                logger.setLevel(logging.WARNING)
2153
2154        elif parameters.log_level in ['ERROR']:
2155                logger.setLevel(logging.ERROR)
2156
2157        elif parameters.log_level in ['CRITICAL']:
2158                logger.setLevel(logging.CRITICAL)
2159
2160        else:
2161                logger.setLevel(logging.INFO)
2162
2163        return logger
2164
2165
2166if __name__ == '__main__':
2167        ## Default config file
2168        #
2169        configfile = '@email2trac_conf@'
2170        project = ''
2171        component = ''
2172        ticket_prefix = 'default'
2173        dry_run = None
2174        verbose = None
2175        debug_interactive = None
2176
2177        SHORT_OPT = 'cdhf:np:t:v'
2178        LONG_OPT  =  ['component=', 'debug', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2179
2180        try:
2181                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2182        except getopt.error,detail:
2183                print __doc__
2184                print detail
2185                sys.exit(1)
2186       
2187        project_name = None
2188        for opt,value in opts:
2189                if opt in [ '-h', '--help']:
2190                        print __doc__
2191                        sys.exit(0)
2192                elif opt in ['-c', '--component']:
2193                        component = value
2194                elif opt in ['-d', '--debug']:
2195                        debug_interactive = 1
2196                elif opt in ['-f', '--file']:
2197                        configfile = value
2198                elif opt in ['-n', '--dry-run']:
2199                        dry_run = True
2200                elif opt in ['-p', '--project']:
2201                        project_name = value
2202                elif opt in ['-t', '--ticket_prefix']:
2203                        ticket_prefix = value
2204                elif opt in ['-v', '--verbose']:
2205                        verbose = True
2206       
2207        settings = ReadConfig(configfile, project_name)
2208
2209        ## The default prefix for ticket values in email2trac.conf
2210        #
2211        settings.ticket_prefix = ticket_prefix
2212        settings.dry_run = dry_run
2213        settings.verbose = verbose
2214
2215        if not settings.debug and debug_interactive:
2216                settings.debug = debug_interactive
2217
2218        if not settings.project:
2219                print __doc__
2220                print 'No Trac project is defined in the email2trac config file.'
2221                sys.exit(1)
2222
2223        logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2224       
2225        if component:
2226                settings['component'] = component
2227
2228        ## Determine major trac version used to be in email2trac.conf
2229        # Quick hack for 0.12
2230        #
2231        version = '0.%s' %(trac_version.split('.')[1])
2232        if version.startswith('0.12'):
2233                version = '0.12'
2234
2235        logger.debug("Found trac version: %s" %(version))
2236       
2237        try:
2238                if version == '0.10':
2239                        from trac import attachment
2240                        from trac.env import Environment
2241                        from trac.ticket import Ticket
2242                        from trac.web.href import Href
2243                        from trac import util
2244                        #
2245                        # return  util.text.to_unicode(str)
2246                        #
2247                        # see http://projects.edgewall.com/trac/changeset/2799
2248                        from trac.ticket.notification import TicketNotifyEmail
2249                        from trac import config as trac_config
2250                        from trac.core import TracError
2251
2252                elif version == '0.11':
2253                        from trac import attachment
2254                        from trac.env import Environment
2255                        from trac.ticket import Ticket
2256                        from trac.web.href import Href
2257                        from trac import config as trac_config
2258                        from trac import util
2259                        from trac.core import TracError
2260                        from trac.perm import PermissionSystem
2261
2262                        #
2263                        # return  util.text.to_unicode(str)
2264                        #
2265                        # see http://projects.edgewall.com/trac/changeset/2799
2266                        from trac.ticket.notification import TicketNotifyEmail
2267
2268                elif version == '0.12':
2269                        from trac import attachment
2270                        from trac.env import Environment
2271                        from trac.ticket import Ticket
2272                        from trac.web.href import Href
2273                        from trac import config as trac_config
2274                        from trac import util
2275                        from trac.core import TracError
2276                        from trac.perm import PermissionSystem
2277
2278                        #
2279                        # return  util.text.to_unicode(str)
2280                        #
2281                        # see http://projects.edgewall.com/trac/changeset/2799
2282                        from trac.ticket.notification import TicketNotifyEmail
2283
2284
2285                else:
2286                        logger.error('TRAC version %s is not supported' %version)
2287                        sys.exit(1)
2288
2289                ## Must be set before environment is created
2290                #
2291                if settings.has_key('python_egg_cache'):
2292                        python_egg_cache = str(settings['python_egg_cache'])
2293                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2294
2295                if settings.debug > 0:
2296                        logger.debug('Loading environment %s', settings.project)
2297
2298                env = Environment(settings['project'], create=0)
2299
2300                tktparser = TicketEmailParser(env, settings, logger, float(version))
2301                tktparser.parse(sys.stdin)
2302
2303        ## Catch all errors and use the logging module
2304        #
2305        except Exception, error:
2306
2307                etype, evalue, etb = sys.exc_info()
2308                for e in traceback.format_exception(etype, evalue, etb):
2309                        logger.critical(e)
2310
2311                if m:
2312                        tktparser.save_email_for_debug(m, True)
2313
2314                sys.exit(1)
2315# EOB
Note: See TracBrowser for help on using the repository browser.