source: trunk/email2trac.py.in @ 487

Last change on this file since 487 was 487, checked in by bas, 11 years ago

Enhancement to update-by-subject routines, closes #188

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