source: trunk/email2trac.py.in @ 469

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

improved workflow patch, see #198

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