source: trunk/email2trac.py.in @ 442

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

ported TICKET_UPDATE to UserDict?

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