source: trunk/email2trac.py.in @ 447

Last change on this file since 447 was 447, checked in by bas, 12 years ago

ported DROP_ALTERNATIVE_HTML_VERSION to UserDict?

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