source: trunk/email2trac.py.in @ 446

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

converted VERBATIM_FORMAT to UserDict?

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