source: trunk/email2trac.py.in @ 441

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

ported TICKET_PERMISSION_SYSTEM to UserDict?

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