source: trunk/email2trac.py.in @ 485

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

added parentdir functionality, see #217

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