source: trunk/email2trac.py.in @ 653

Last change on this file since 653 was 653, checked in by bas, 10 years ago

added two patches:

1) applied lower to an object instead of string, closes #340
2) bug in custom field initialization, see #340

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 93.5 KB
Line 
1#!@PYTHON@
2#
3# This file is part of the email2trac utils
4#
5#       Copyright 2002 SURFsara
6#
7#       Licensed under the Apache License, Version 2.0 (the "License");
8#       you may not use this file except in compliance with the License.
9#       You may obtain a copy of the License at
10#
11#       http://www.apache.org/licenses/LICENSE-2.0
12#
13#       Unless required by applicable law or agreed to in writing, software
14#       distributed under the License is distributed on an "AS IS" BASIS,
15#       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16#       See the License for the specific language governing permissions and
17#       limitations under the License.
18#
19# For vi/emacs or other use tabstop=4 (vi: set ts=4)
20#
21"""
22email2trac.py -- Email -> TRAC tickets
23
24A MTA filter to create Trac tickets from inbound emails.
25
26first proof of concept from:
27 Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28 Copyright 2005, Edgewall Software
29
30Authors:
31  Bas van der Vlies <bas.vandervlies@surfsara.nl>
32  Walter de Jong <walter.dejong@surfsara.nl>
33
34How to use
35----------
36 * See https://oss.trac.surfsara.nl/email2trac/
37
38 * Commandline opions:
39                -h,--help
40                -A, --agilo
41                -B, --bh_product <name>
42                -d, --debug
43                -E, --virtualenv <path>
44                -f,--file  <configuration file>
45                -n,--dry-run
46                -p, --project <project name>
47                -t, --ticket_prefix <name>
48                -v, --verbose
49
50SVN Info:
51        $Id: email2trac.py.in 653 2014-04-08 08:26:59Z bas $
52"""
53import os
54import sys
55import string
56import getopt
57import time
58import email
59import email.Iterators
60import email.Header
61import re
62import urllib
63import unicodedata
64import mimetypes
65import traceback
66import logging
67import logging.handlers
68import UserDict
69import tempfile
70
71from datetime import timedelta, datetime
72from stat import *
73
74## Some global variables
75#
76m = None
77
78class SaraDict(UserDict.UserDict):
79    def __init__(self, dictin = None):
80        UserDict.UserDict.__init__(self)
81        self.name = None
82       
83        if dictin:
84            if dictin.has_key('name'):
85                self.name = dictin['name']
86                del dictin['name']
87            self.data = dictin
88           
89    def get_value(self, name):
90        if self.has_key(name):
91            return self[name]
92        else:
93            return None
94               
95    def __repr__(self):
96        return repr(self.data)
97
98    def __str__(self):
99        return str(self.data)
100           
101    def __getattr__(self, name):
102        """
103        override the class attribute get method. Return the value
104        from the dictionary
105        """
106        if self.data.has_key(name):
107            return self.data[name]
108        else:
109            return None
110           
111    def __setattr__(self, name, value):
112        """
113        override the class attribute set method only when the UserDict
114        has set its class attribute
115        """
116        if self.__dict__.has_key('data'):
117            self.data[name] = value
118        else:
119            self.__dict__[name] = value
120
121    def __iter__(self):
122        return iter(self.data.keys())
123
124
125
126class TicketEmailParser(object):
127    env = None
128    comment = '> '
129
130    def __init__(self, env, parameters, logger, version):
131        self.env = env
132
133        # Database connection
134        #
135        self.db = None
136
137        # Save parameters
138        #
139        self.parameters = parameters
140        self.logger = logger
141
142        # Some useful mail constants
143        #
144        self.email_name = None
145        self.email_addr = None
146        self.email_from = None
147        self.author     = None
148        self.id         = None
149
150        ## Will be set to True if the user has ben registered
151        #
152        self.allow_registered_user = False
153       
154        self.STRIP_CONTENT_TYPES = list()
155
156        ## fields properties via body_text
157        #
158        self.properties = dict()
159
160        self.VERSION = version
161
162        self.get_config = self.env.config.get
163
164        ## init function ##
165        #
166        self.setup_parameters()
167
168    def setup_parameters(self):
169        if self.parameters.umask:
170            os.umask(self.parameters.umask)
171
172        if not self.parameters.spam_level:
173            self.parameters.spam_level = 0
174
175        if not self.parameters.spam_header:
176            self.parameters.spam_header = 'X-Spam-Score'
177
178        if not self.parameters.email_quote:
179            self.parameters.email_quote = '^> .*'
180            self.parameters.skip_line_regex = '^> .*'
181
182        if not self.parameters.ticket_update_by_subject_lookback:
183            self.parameters.ticket_update_by_subject_lookback = 30
184
185        if self.parameters.verbatim_format == None:
186            self.parameters.verbatim_format = 1
187
188        if self.parameters.reflow == None:
189            self.parameters.reflow = 1
190
191        if self.parameters.binhex:
192            self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
193
194        if self.parameters.applesingle:
195            self.STRIP_CONTENT_TYPES.append('application/applefile')
196
197        if self.parameters.appledouble:
198            self.STRIP_CONTENT_TYPES.append('application/applefile')
199
200        if self.parameters.strip_content_types:
201            items = self.parameters.strip_content_types.split(',')
202            for item in items:
203                self.STRIP_CONTENT_TYPES.append(item.strip())
204
205        if self.parameters.tmpdir:
206            self.parameters.tmpdir = os.path.normcase(str(self.parameters['tmpdir']))
207        else:
208            self.parameters.tmpdir = os.path.normcase('/tmp')
209
210        if self.parameters.email_triggers_workflow == None:
211            self.parameters.email_triggers_workflow = 1
212
213        if not self.parameters.subject_field_separator:
214            self.parameters.subject_field_separator = '&'
215        else:
216            self.parameters.subject_field_separator = self.parameters.subject_field_separator.strip()
217
218        if not self.parameters.strip_signature_regex:
219            self.parameters.strip_signature_regex = '^-- $'
220            self.parameters.cut_line_regex = '^-- $'
221
222        self.trac_smtp_from = self.get_config('notification', 'smtp_from')
223        self.smtp_default_domain = self.get_config('notification', 'smtp_default_domain')
224        self.smtp_replyto = self.get_config('notification', 'smtp_replyto')
225        self.trac_smtp_always_cc = self.get_config('notification', 'smtp_always_cc')
226        self.trac_smtp_always_bcc = self.get_config('notification', 'smtp_always_bcc')
227
228        self.system = None
229
230########## Email Header Functions ###########################################################
231
232    def spam(self, message):
233        """
234        # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
235        # Note if Spam_level then '*' are included
236        """
237        self.logger.debug('function spam')
238
239        spam = False
240        if message.has_key(self.parameters.spam_header):
241            spam_l = string.split(message[self.parameters.spam_header])
242            #self.logger.info('Spam header: %s' %(message[self.parameters.spam_header]))
243
244            try:
245                number = spam_l[0].count('*')
246            except IndexError, detail:
247                number = 0
248               
249            if number >= self.parameters.spam_level:
250                spam = True
251               
252        ## treat virus mails as spam
253        #
254        elif message.has_key('X-Virus-found'):         
255            spam = True
256
257        ## Astaro firewall spam handling
258        #
259        elif message.get('X-Spam-Flag') == "YES" and message.get('X-Spam-Result') == "Spam":
260            spam = True
261
262        ## How to handle SPAM messages
263        #
264        if self.parameters.drop_spam and spam:
265
266            self.logger.info('Message is a SPAM. Automatic ticket insertion refused (SPAM level > %d)' %self.parameters.spam_level)
267            return 'drop'   
268
269        elif spam:
270
271            return 'Spam'   
272        else:
273
274            return False
275
276    def email_header_acl(self, keyword, message_field, default):
277        """
278        This function wil check if the email address is allowed or denied
279        to send mail to the ticket list
280        """
281        self.logger.debug('function email_header_acl: %s' %keyword)
282
283        try:
284            mail_addresses = self.parameters[keyword]
285
286            # Check if we have an empty string
287            #
288            if not mail_addresses:
289                return default
290
291        except KeyError, detail:
292            self.logger.debug('\t %s not defined, all messages are allowed.' %(keyword))
293
294            return default
295
296        mail_addresses = string.split(mail_addresses, ',')
297        message_addresses = string.split(message_field, ',')
298
299        for entry in mail_addresses:
300            entry = entry.strip()
301            TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
302
303            for addr in message_addresses:
304                addr = addr.strip()
305
306                if self.parameters.compare_function_list in [ 'matches', 'match']:
307                    s = '\t%s matches %s' %(addr, entry)
308                    result =  TO_RE.match(addr)
309                else:
310                    s = '\t%s contains %s' %(addr, entry)
311                    result =  TO_RE.search(addr)
312
313                if result:
314                    self.logger.debug(s)
315                    return True
316
317        return False
318
319    def email_header_txt(self, m):
320        """
321        Display To and CC addresses in description field
322        """
323        s = ''
324
325        if m['To'] and len(m['To']) > 0:
326            s = "'''To:''' %s\r\n" %(m['To'])
327        if m['Cc'] and len(m['Cc']) > 0:
328            s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
329
330        return  self.email_to_unicode(s)
331
332
333    def get_sender_info(self, message):
334        """
335        Get the default author name and email address from the message
336        """
337        self.logger.debug('function get_sender_info')
338
339        to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
340        self.email_to_addrs = list()
341
342        for n,e in to_addrs:
343            self.email_to_addrs.append(e)
344
345        self.email_to_addrs = ','.join(self.email_to_addrs)
346
347
348        self.email_name, self.email_addr  = email.Utils.parseaddr(message['from'])
349
350        ## decode email name can contain charset
351        #
352        self.email_name = self.email_to_unicode(self.email_name)
353
354        dstr = '\t email name: %s, email address: %s' %(self.email_name, self.email_addr) 
355        self.logger.debug(dstr)
356
357        ## Trac can not handle author's name that contains spaces
358        #
359        if self.email_addr.lower() == self.trac_smtp_from.lower():
360            if self.email_name:
361                self.author = self.email_name
362            else:
363                self.author = "email2trac"
364        else:
365            self.author = self.email_addr
366
367        if self.parameters.ignore_trac_user_settings:
368            return
369
370        # Is this a registered user, use email address as search key:
371        # result:
372        #   u : login name
373        #   n : Name that the user has set in the settings tab
374        #   e : email address that the user has set in the settings tab
375        #
376        users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
377            if (
378                (e and (e.lower() == self.email_addr.lower())) or
379                (u and (u.lower() + '@' + self.smtp_default_domain.lower() == self.email_addr.lower()))
380            )
381            ]
382
383        if len(users) >= 1:
384            self.email_from = users[0][2]
385            self.author = users[0][0]
386
387            self.logger.debug('\tget_sender_info: found registered user: %s' %(self.author) )
388
389            if self.parameters.white_list_registered_users:
390                self.logger.debug('\tget_sender_info: added registered user to white list')
391                self.allow_registered_user = True
392                   
393
394    def set_cc_fields(self, ticket, message, update=False):
395        """
396        Set all the right fields for a new ticket
397        """
398        self.logger.debug('function set_cc_fields')
399
400
401        # Put all CC-addresses in ticket CC field
402        #
403        if self.parameters.reply_all:
404
405            ticket_cc = ''
406
407            msg_cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
408
409            if not msg_cc_addrs:
410                return
411
412            if update:
413                self.logger.debug("\tupdate ticket cc-field")
414                ticket_cc = ticket['cc']
415                ticket_cc_list = ticket_cc.split(',')
416
417            for name,addr in msg_cc_addrs:
418       
419                ## Prevent mail loop
420                #
421                if addr == self.trac_smtp_from:
422                    self.logger.debug("\tSkipping %s email address for CC-field, same as smtp_from address in trac.ini " %(addr))
423                    continue
424
425                ## Alwyas remove reporter email address
426                #
427                elif addr == self.email_addr:
428                    self.logger.debug("\tSkipping reporter email address for CC-field")
429                    continue
430
431                ## Always remove the always_cc address
432                #
433                elif addr == self.trac_smtp_always_cc:
434                    self.logger.debug("\tSkipping smtp_always_cc email address for CC-field")
435                    continue
436
437                ## Always remove the always_bcc address
438                #
439                elif addr == self.trac_smtp_always_bcc:
440                    self.logger.debug("\tSkipping smtp_always_bcc email address for CC-field")
441                    continue
442
443                ## Skip also addr in cc_black_list (email2trac.conf)
444                #
445                if self.email_header_acl('cc_black_list', addr, False):
446                    self.logger.debug("\tSkipping address for CC-field due to cc_black_list")
447                    continue
448
449                else:
450                    ## On update, prevent adding duplicates
451                    #
452                    if update:
453                      if addr in ticket_cc_list:
454                          continue
455                   
456                    if ticket_cc:
457                        ticket_cc = '%s, %s' %(ticket_cc, addr)
458                    else:
459                        ticket_cc = addr
460
461            if ticket_cc:
462                self.logger.debug('\tCC fields: %s' %ticket_cc)
463                ticket['cc'] = self.email_to_unicode(ticket_cc)
464
465
466    def acl_list_from_file(self, f, keyword):
467        """
468        Read the email address from a file
469        """
470        self.logger.debug('function acl_list_from_file %s : %s' %(f, keyword))
471       
472        if  not os.path.isfile(f):
473            self.logger.error('%s_file: %s does not exists' %(keyword, f) )
474        else:
475            ## read whole file and replace '\n' with ''
476            #
477            addr_l = open(f, 'r').readlines()
478            s = ','.join(addr_l).replace('\n','')
479
480            try:
481                self.parameters[keyword] = "%s,%s" %(self.parameters[keyword], s)
482            except KeyError, detail:
483                self.parameters[keyword] = s
484
485########## DEBUG functions  ###########################################################
486
487    def debug_body(self, message_body, temp_file):
488        """
489        """
490        self.logger.debug('function debug_body:')
491
492        body_file = "%s.body" %(temp_file)
493        fx = open(body_file, 'wb')
494
495        if self.parameters.dry_run:
496            self.logger.info('DRY-RUN: not saving body to %s' %(body_file))
497            return
498
499        self.logger.debug('writing body to %s' %(body_file))
500        if not message_body:
501                message_body = '(None)'
502
503        message_body = message_body.encode('utf-8')
504
505        fx.write(message_body)
506        fx.close()
507        try:
508            os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
509        except OSError:
510            pass
511
512    def debug_attachments(self, message_parts, temp_file):
513        """
514        """
515        self.logger.debug('function debug_attachments')
516       
517        n = 0
518        for item in message_parts:
519
520            ## Skip inline text parts
521            #
522            if not isinstance(item, tuple):
523                continue
524               
525            (original, filename, part) = item
526            self.logger.debug('\t part%d: Content-Type: %s' % (n, part.get_content_type()) )
527
528            dummy_filename, ext = os.path.splitext(filename)
529
530            n = n + 1
531            part_name = 'part%d%s' %(n, ext)
532            part_file = "%s.%s" %(temp_file, part_name)
533
534            s = 'writing %s: filename: %s' %(part_file, filename)
535            self.print_unicode(s)
536
537            ## Forbidden chars, just write part files instead of names
538            #
539            #filename = filename.replace('\\', '_')
540            #filename = filename.replace('/', '_')
541            #filename = filename + '.att_email2trac'
542            # part_file = os.path.join(self.parameters.tmpdir, filename)
543            #part_file = util.text.unicode_quote(part_file)
544            #self.print_unicode(part_file)
545
546            if self.parameters.dry_run:
547                self.logger.info('DRY_RUN: NOT saving attachments')
548                continue
549
550            fx = open(part_file, 'wb')
551            text = part.get_payload(decode=1)
552
553            if not text:
554                text = '(None)'
555
556            fx.write(text)
557            fx.close()
558
559            try:
560                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
561            except OSError:
562                pass
563
564    def save_email_for_debug(self, message, project_name, create_tempfile=False):
565
566        if self.parameters.dry_run:
567            self.logger.debug('DRY_RUN: NOT saving email message')
568            return
569
570        if create_tempfile:
571            (fd, tmp_file) = tempfile.mkstemp('.%s.email2trac' %project_name)
572            fx = os.fdopen(fd, 'wb')
573        else:
574            tmp_file = os.path.join(self.parameters.tmpdir, 'msg.%s.email2trac' %project_name)
575            fx = open(tmp_file, 'wb')
576
577
578        self.logger.debug('saving email to %s' %(tmp_file))
579        fx.write('%s' % message)
580        fx.close()
581       
582        try:
583            os.chmod(tmp_file, S_IRWXU|S_IRWXG|S_IRWXO)
584        except OSError:
585            pass
586
587        message_parts = self.get_message_parts(message)
588        message_parts = self.unique_attachment_names(message_parts)
589        body_text = self.get_body_text(message_parts)
590        self.debug_body(body_text, tmp_file)
591        self.debug_attachments(message_parts, tmp_file)
592
593########## Conversion functions  ###########################################################
594
595    def email_to_unicode(self, message_str):
596        """
597        Email has 7 bit ASCII code, convert it to unicode with the charset
598        that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
599        understands it.
600        """
601        self.logger.debug("function email_to_unicode")
602
603        results =  email.Header.decode_header(message_str)
604
605        s = None
606        for text,format in results:
607            if format:
608                try:
609                    temp = unicode(text, format)
610                except UnicodeError, detail:
611                    # This always works
612                    #
613                    msg = 'ERROR: Could not find charset: %s, please install' %format
614                    self.logger.error(msg)
615                    temp = unicode(text, 'iso-8859-15')
616                except LookupError, detail:
617                    msg = 'ERROR: Could not find charset: %s, please install' %format
618                    self.logger.error(msg)
619                    #temp = unicode(text, 'iso-8859-15')
620                    temp = message_str
621                       
622            else:
623                temp = string.strip(text)
624                temp = unicode(text, 'iso-8859-15')
625
626            if s:
627                s = '%s %s' %(s, temp)
628            else:
629                s = '%s' %temp
630
631        return s
632
633    def str_to_dict(self, s):
634        """
635        Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
636        """
637        self.logger.debug("function str_to_dict")
638
639        fields = string.split(s, self.parameters.subject_field_separator)
640
641        result = dict()
642        for field in fields:
643            try:
644                index, value = string.split(field, '=')
645
646                # We can not change the description of a ticket via the subject
647                # line. The description is the body of the email
648                #
649                if index.lower() in ['description']:
650                    continue
651
652                if value:
653                    result[index.lower()] = value
654
655            except ValueError:
656                pass
657        return result
658
659    def print_unicode(self,s):
660        """
661        This function prints unicode strings if possible else it will quote it
662        """
663        try:
664            self.logger.debug(s)
665        except UnicodeEncodeError, detail:
666            self.logger.debug(util.text.unicode_quote(s))
667
668
669    def html_2_txt(self, data):
670        """
671        Various routines to convert html syntax to valid trac wiki syntax
672        """
673        self.logger.debug('function html_2_txt')
674
675        ## This routine make an safe html that can be include
676        #  in trac, but no further text processing can be done
677        #
678#       try:
679#           from lxml.html.clean import Cleaner
680#           tags_rm = list()
681#           tags_rm.append('body')
682#
683#           cleaner = Cleaner(remove_tags=tags_rm )
684#           parsed_data = cleaner.clean_html(data)
685#           parsed_data = '\n{{{\n#!html\n' + parsed_data + '\n}}}\n'
686#
687#           return parsed_data
688#           
689#       except ImportError::
690#           pass
691
692        parsed_data = None
693        if self.parameters.html2text_cmd:
694            (fd, tmp_file) = tempfile.mkstemp('email2trac.html')
695            f = os.fdopen(fd, 'w')
696
697            cmd = '%s %s' %(self.parameters.html2text_cmd, tmp_file)
698            self.logger.debug('\t html2text conversion %s'%(cmd))
699   
700            if self.parameters.dry_run:
701                self.logger.info('DRY_RUN: html2text conversion command: %s\n' %(cmd))
702
703            else:
704                f.write(data)
705                f.close()
706
707                lines = os.popen(cmd).readlines()
708                parsed_data =  ''.join(lines)
709
710                os.unlink(tmp_file)
711
712        else:
713            self.logger.debug('\t No html2text conversion tool specified in email2trac.conf')
714
715        return parsed_data
716
717    def check_filename_length(self, name):
718        """
719        To bypass a bug in Trac
720        check if the filename lenght is not larger then OS limit.
721          yes : return truncated filename
722          no  : return unmodified filename
723        """
724        self.logger.debug('function check_filename_length: ')
725
726        if not name:
727            return 'None'
728       
729        dummy_filename, ext = os.path.splitext(name)
730
731        ## Trac uses this format
732        #
733        try:
734            quote_format = util.text.unicode_quote(dummy_filename)
735
736        except UnicodeDecodeError, detail:
737            ## last resort convert to unicode
738            #
739            dummy_filename = util.text.to_unicode(dummy_filename)
740            quote_format = util.text.unicode_quote(dummy_filename)
741
742        ## Determine max filename lenght
743        #
744        try:
745            filemax_length = os.pathconf('/', 'PC_NAME_MAX')
746        except AttributeError, detail:
747            filemax_lenght = 240
748
749        if len(quote_format) <= filemax_length:
750            return name
751
752        else:
753            ## Truncate file to filemax_length and reserve room for extension
754            #  We must break on a boundry
755            #
756            length = filemax_length - 6
757
758            for i in range(0,10):
759
760                truncated = quote_format[ : (length - i)]
761
762                try:
763                    unqoute_truncated = util.text.unicode_unquote(truncated)
764                    unqoute_truncated = unqoute_truncated + ext
765
766                    self.print_unicode('\t ' + unqoute_truncated)
767
768                    break
769
770                except UnicodeDecodeError, detail:
771                    continue
772
773
774            return unqoute_truncated
775
776########## TRAC ticket functions  ###########################################################
777
778    def mail_workflow(self, tkt):
779        """
780        """
781        self.logger.debug('function mail_workflow: ')
782       
783        req = Mock(authname=self.author, perm=MockPerm(), args={})
784        ticket_system = TicketSystem(self.env)
785       
786        try:
787            workflow = self.parameters['workflow_%s' %tkt['status'].lower()]
788
789        except KeyError:
790            ## fallback for compability (Will be deprecated)
791            #  workflow can be none.
792            #
793            workflow = None
794            if tkt['status'] in ['closed']:
795                workflow = self.parameters.workflow
796
797        if workflow:
798
799            ## process all workflow implementations
800            #
801            tkt_module = TicketModule(self.env)
802            field_changes, problems = tkt_module.get_ticket_changes(req, tkt, workflow)
803
804            for field in field_changes.keys():
805
806                ## We have already processed these fields
807                #
808                if not field in ['summary', 'description']:
809                    s = 'workflow : %s, field %s : %s, by %s' \
810                       %(workflow, field, field_changes[field]['new'],field_changes[field]['by'] )
811                    self.logger.debug(s)
812
813                    tkt[field] = field_changes[field]['new']
814
815            return True
816
817        else:
818            return False
819
820    def check_permission_participants(self, tkt, action):
821        """
822        Check if the mailer is allowed to update the ticket
823        """
824        self.logger.debug('function check_permission_participants %s')
825
826        if tkt['reporter'].lower() in [self.author.lower(), self.email_addr.lower()]:
827            self.logger.debug('ALLOW, %s is the ticket reporter' %(self.email_addr))
828
829            return True
830
831        perm = PermissionSystem(self.env)
832        if perm.check_permission(action, self.author):
833            self.logger.debug('ALLOW, %s has trac permission to update the ticket' %(self.author))
834
835            return True
836       
837        # Is the updater in the CC?
838        try:
839            cc_list = tkt['cc'].split(',')
840            for cc in cc_list:
841                if self.email_addr.lower() in cc.lower().strip():
842                    self.logger.debug('ALLOW, %s is in the CC' %(self.email_addr))
843
844                    return True
845
846        except KeyError:
847            pass
848
849        return False
850
851    def check_permission(self, tkt, action):
852        """
853        check if the reporter has the right permission for the action:
854          - TICKET_CREATE
855          - TICKET_MODIFY
856          - TICKET_APPEND
857          - TICKET_CHGPROP
858
859        There are three models:
860            - None      : no checking at all
861            - trac      : check the permission via trac permission model
862            - email2trac: ....
863        """
864        self.logger.debug("function check_permission: %s" %(action))
865
866        if self.parameters.ticket_permission_system in ['trac']:
867
868            perm = PermissionCache(self.env, self.author)
869            if perm.has_permission(action):
870                return True
871            else:
872                return False
873
874        elif self.parameters.ticket_permission_system in ['update_restricted_to_participants']:
875            return (self.check_permission_participants(tkt, action))   
876
877        ## Default is to allow everybody ticket updates and ticket creation
878        #
879        else:
880                return True
881
882
883    def update_ticket_fields(self, ticket, user_dict, new=None):
884        """
885        This will update the ticket fields. It will check if the
886        given fields are known and if the right values are specified
887        It will only update the ticket field value:
888            - If the field is known
889            - If the value supplied is valid for the ticket field.
890              If not then there are two options:
891               1) Skip the value (new=None)
892               2) Set default value for field (new=1)
893        """
894        self.logger.debug("function update_ticket_fields")
895
896        if self.parameters.bh_product:
897            if 'product' in user_dict:
898                if user_dict['product'] != ticket.env.product.prefix:
899                    self.logging.warning("bloodhound products cannot be changed "
900                                         "- ignoring")
901                user_dict.pop('product')
902
903        ## Check only permission model on ticket updates
904        #
905        if not new:
906            if self.parameters.ticket_permission_system:
907                if not self.check_permission(ticket, 'TICKET_CHGPROP'):
908                    self.logger.info('Reporter: %s has no permission to change ticket properties' %self.author)
909                    return False
910
911        ## Build a system dictionary from the ticket fields
912        #  with field as index and option as value
913        #
914        sys_dict = dict()
915
916        for field in ticket.fields:
917
918            try:
919                sys_dict[field['name']] = field['options']
920
921            except KeyError:
922                sys_dict[field['name']] = None
923                pass
924
925        ## Check user supplied fields an compare them with the
926        #  system one's
927        #
928        for field,value in user_dict.items():
929       
930            s = 'user_field\t %s = %s' %(field,value)
931            self.print_unicode(s)
932
933            if not field in sys_dict.keys(): 
934                self.logger.debug('%s is not a valid field for tickets' %(field))
935                continue
936
937            ## To prevent mail loop
938            #
939            if field == 'cc':
940
941                cc_list = user_dict['cc'].split(',')
942
943                if self.trac_smtp_from in cc_list:
944                    self.logger.debug('MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from))
945
946                    cc_list.remove(self.trac_smtp_from)
947
948                value = ','.join(cc_list)
949               
950
951            ## Check if every value is allowed for this field
952            #
953            if sys_dict[field]:
954
955                if value in sys_dict[field]:
956                    ticket[field] = value
957                else:
958                    ## Must we set a default if value is not allowed
959                    #
960                    if new:
961                        value = self.get_config('ticket', 'default_%s' %(field) )
962
963            else:
964
965                ticket[field] = value
966
967            s = 'ticket_field\t %s = %s' %(field,  ticket[field])
968            self.print_unicode(s)
969
970    def ticket_update(self, m, id, spam):
971        """
972        If the current email is a reply to an existing ticket, this function
973        will append the contents of this email to that ticket, instead of
974        creating a new one.
975        """
976        self.logger.debug("function ticket_update")
977
978        if not self.parameters.ticket_update:
979            self.logger.debug("ticket_update disabled")
980            return False
981
982        ## Must we update ticket fields
983        #
984        update_fields = dict()
985        try:
986            id, keywords = string.split(id, '?')
987
988            update_fields = self.str_to_dict(keywords)
989
990            ## Strip '#'
991            #
992            self.id = int(id[1:])
993
994        except ValueError:
995
996            ## Strip '#'
997            #
998            self.id = int(id[1:])
999
1000        self.logger.debug("\tticket id: %s" %id)
1001
1002        ## When is the change committed
1003        #
1004        if self.VERSION < 0.11:
1005            when = int(time.time())
1006        else:
1007            when = datetime.now(util.datefmt.utc)
1008
1009        try:
1010            tkt = Ticket(self.env, self.id, self.db)
1011
1012        except util.TracError, detail:
1013
1014            ## Not a valid ticket
1015            #
1016            self.logger.info("\tCreating a new ticket, ticket id: %s does not exists" %id)
1017            self.id = None
1018            return False
1019
1020        ## Check the permission of the reporter
1021        #
1022        if self.parameters.ticket_permission_system:
1023            if not self.check_permission(tkt, 'TICKET_APPEND'):
1024                self.logger.info('Reporter: %s has no permission to add comments or attachments to tickets' %self.author)
1025                return False
1026
1027        ## How many changes has this ticket
1028        #
1029        grouped = TicketModule(self.env).grouped_changelog_entries(tkt, self.db)
1030        cnum = sum(1 for e in grouped) + 1
1031
1032
1033        ## reopen the ticket if it is was closed
1034        #  We must use the ticket workflow framework
1035        #
1036        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1037
1038            if not self.mail_workflow(tkt):
1039
1040                if tkt['status'] in ['closed']:
1041                    tkt['status'] = 'reopened'
1042                    tkt['resolution'] = ''
1043
1044        ## Old pre 0.11 situation
1045        #
1046        elif self.parameters.email_triggers_workflow:
1047
1048            self.logger.debug('\temail triggers workflow pre trac 0.11')
1049
1050            if tkt['status'] in ['closed']:
1051                tkt['status'] = 'reopened'
1052                tkt['resolution'] = ''
1053
1054        else:
1055            self.logger.debug('\temail triggers workflow disabled')
1056
1057        ## Must we update some ticket fields properties via subject line
1058        #
1059        if update_fields:
1060            self.update_ticket_fields(tkt, update_fields)
1061
1062
1063        message_parts = self.get_message_parts(m)
1064        message_parts = self.unique_attachment_names(message_parts)
1065
1066        ## Must we update some ticket fields properties via inline comments
1067        # in body_text
1068        #
1069        if self.properties:
1070                self.update_ticket_fields(tkt, self.properties)
1071
1072        ## Must we update the CC ticket field
1073        #
1074        self.set_cc_fields(tkt, m, update=True)
1075
1076        if self.parameters.email_header:
1077            message_parts.insert(0, self.email_header_txt(m))
1078
1079        body_text = self.get_body_text(message_parts)
1080
1081        error_with_attachments = self.attach_attachments(message_parts)
1082
1083        if body_text.strip() or update_fields or self.properties:
1084
1085            if self.parameters.dry_run:
1086                s = 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) %s %s' %(self.author, cnum)
1087                self.logger.info(s)
1088
1089            else:
1090                if error_with_attachments:
1091                    body_text = '%s\\%s' %(error_with_attachments, body_text)
1092
1093                self.logger.debug('\ttkt.save_changes(%s, %d)' %(self.author, cnum))
1094                tkt.save_changes(self.author, body_text, when, None, str(cnum))
1095           
1096        if not spam:
1097            self.notify(tkt, False, when)
1098
1099        return True
1100
1101    def set_ticket_fields(self, ticket):
1102        """
1103        set the ticket fields to value specified
1104            - /etc/email2trac.conf with <prefix>_<field>
1105            - trac default values, trac.ini
1106        """
1107        self.logger.debug('function set_ticket_fields')
1108
1109        user_dict = dict()
1110
1111        for field in ticket.fields:
1112
1113            name = field['name']
1114
1115            ## default trac value
1116            #
1117            CUSTOM_FIELD = False
1118            if not field.get('custom'):
1119                value = self.get_config('ticket', 'default_%s' %(name) )
1120
1121                ## skip this field can only be set by email2trac.conf
1122                #
1123                if name in ['resolution']:
1124                    value = None
1125
1126            else:
1127                ##  Else get the default value for custom fields
1128                #
1129                CUSTOM_FIELD = True
1130                value = field.get('value')
1131                options = field.get('options')
1132
1133                if value and options and (value not in options):
1134                     value = options[int(value)]
1135   
1136            s = 'trac[%s] = %s' %(name, value)
1137            self.print_unicode(s)
1138
1139            ## email2trac.conf settings
1140            #
1141            prefix = self.parameters.ticket_prefix
1142            try:
1143                value = self.parameters['%s_%s' %(prefix, name)]
1144
1145                s = 'email2trac[%s] = %s ' %(name, value)
1146                self.print_unicode(s)
1147
1148            except KeyError, detail:
1149                pass
1150       
1151            if value:
1152                user_dict[name] = value
1153
1154                s = 'used %s = %s' %(name, value)
1155                self.print_unicode(s)
1156
1157            else:
1158                ## custom fields need some initialisation
1159                #
1160                if CUSTOM_FIELD:
1161                    user_dict[name] = ''
1162                   
1163        self.update_ticket_fields(ticket, user_dict, new=1)
1164
1165        if 'status' not in user_dict.keys():
1166            ticket['status'] = 'new'
1167
1168    def ticket_update_by_subject(self, subject):
1169        """
1170        This list of Re: prefixes is probably incomplete. Taken from
1171        wikipedia. Here is how the subject is matched
1172          - Re: <subject>
1173          - Re: (<Mail list label>:)+ <subject>
1174
1175        So we must have the last column
1176        """
1177        self.logger.debug('function ticket_update_by_subject')
1178
1179        found_id = None
1180        if self.parameters.ticket_update and self.parameters.ticket_update_by_subject:
1181               
1182            SUBJECT_RE = re.compile(r'^(?:(?:RE|AW|VS|SV|FW|FWD):\s*)+(.*)', re.IGNORECASE)
1183            result = SUBJECT_RE.search(subject)
1184
1185            if result:
1186                ## This is a reply
1187                #
1188                orig_subject = result.group(1)
1189
1190                self.logger.debug('subject search string: %s' %(orig_subject))
1191
1192                cursor = self.db.cursor()
1193                summaries = [orig_subject, '%%: %s' % orig_subject]
1194
1195                ## Time resolution is in micoseconds
1196                #
1197                search_date = datetime.now(util.datefmt.utc) - timedelta(days=self.parameters.ticket_update_by_subject_lookback)
1198
1199                if self.VERSION < 0.12:
1200                    lookback = util.datefmt.to_timestamp(search_date)
1201                else:
1202                    lookback = util.datefmt.to_utimestamp(search_date)
1203
1204                for summary in summaries:
1205                    self.logger.debug('Looking for summary matching: "%s"' % summary)
1206
1207                    sql = """SELECT id, reporter FROM ticket
1208                            WHERE changetime >= %s AND summary LIKE %s
1209                            ORDER BY changetime DESC"""
1210
1211                    cursor.execute(sql, [lookback, summary.strip()])
1212
1213                    for row in cursor:
1214
1215                        (matched_id, sql_reporter) = row
1216
1217                        ## Save first entry.
1218                        #
1219                        if not found_id:
1220                            found_id = matched_id
1221                           
1222                        ## If subject and reporter are the same. The we certainly have found the right ticket
1223                        #
1224                        if sql_reporter == self.author:
1225                            self.logger.debug('Found matching reporter: %s with ticket id: %d' %(sql_reporter, matched_id))
1226                            found_id = matched_id
1227                            break
1228
1229                    if found_id:
1230                        self.logger.debug('Found matching ticket id: %d' % found_id)
1231                        found_id = '#%d' % found_id
1232                        return (found_id, orig_subject)
1233                   
1234        return (found_id, subject)
1235
1236
1237    def new_ticket(self, msg, subject, spam, set_fields = None):
1238        """
1239        Create a new ticket
1240        """
1241        self.logger.debug('function new_ticket')
1242
1243        tkt = Ticket(self.env)
1244
1245        ## self.author can be email address of an username
1246        #
1247        tkt['reporter'] = self.author
1248
1249        self.set_cc_fields(tkt, msg)
1250
1251        self.set_ticket_fields(tkt)
1252
1253        ## Check the permission of the reporter
1254        #
1255        if self.parameters.ticket_permission_system:
1256            if not self.check_permission(tkt, 'TICKET_CREATE'):
1257                self.logger.info('Reporter: %s has no permission to create tickets' %self.author)
1258                return False
1259
1260        ## Old style setting for component, will be removed
1261        #
1262        if spam:
1263            tkt['component'] = 'Spam'
1264
1265        elif self.parameters.has_key('component'):
1266            tkt['component'] = self.parameters['component']
1267
1268        if not msg['Subject']:
1269            tkt['summary'] = u'(No subject)'
1270        else:
1271            tkt['summary'] = subject
1272
1273
1274        if set_fields:
1275            rest, keywords = string.split(set_fields, '?')
1276
1277            if keywords:
1278                update_fields = self.str_to_dict(keywords)
1279                self.update_ticket_fields(tkt, update_fields)
1280
1281
1282        message_parts = self.get_message_parts(msg, True)
1283
1284        ## Must we update some ticket fields properties via body_text
1285        #
1286        if self.properties:
1287                self.update_ticket_fields(tkt, self.properties)
1288
1289        message_parts = self.unique_attachment_names(message_parts)
1290       
1291        ## produce e-mail like header
1292        #
1293        head = ''
1294        if self.parameters.email_header:
1295            head = self.email_header_txt(msg)
1296            message_parts.insert(0, head)
1297           
1298        body_text = self.get_body_text(message_parts)
1299
1300        tkt['description'] = body_text
1301
1302        ## When is the change committed
1303        #
1304        if self.VERSION < 0.11:
1305            when = int(time.time())
1306        else:
1307            when = datetime.now(util.datefmt.utc)
1308
1309        if self.parameters.dry_run:
1310            self.logger.info('DRY_RUN: tkt.insert()')
1311        else:
1312            self.id = tkt.insert()
1313   
1314        changed = False
1315        comment = ''
1316
1317        ## some routines in trac are dependend on ticket id
1318        #  like alternate notify template
1319        #
1320        if self.parameters.alternate_notify_template:
1321            tkt['id'] = self.id
1322            changed = True
1323
1324        ## Rewrite the description if we have mailto enabled
1325        #
1326        if self.parameters.mailto_link:
1327            changed = True
1328            comment = u'\nadded mailto line\n'
1329
1330            #mailto = self.html_mailto_link( m['Subject'])
1331            mailto = self.html_mailto_link(subject)
1332
1333            tkt['description'] = u'%s\r\n%s%s\r\n' \
1334                %(head, mailto, body_text)
1335   
1336        ## Save the attachments to the ticket   
1337        #
1338        error_with_attachments =  self.attach_attachments(message_parts)
1339
1340        if error_with_attachments:
1341            changed = True
1342            comment = '%s\n%s\n' %(comment, error_with_attachments)
1343
1344        if self.parameters.email_triggers_workflow and (self.VERSION >= 0.11):
1345            if self.mail_workflow(tkt):
1346                changed = True
1347
1348        if changed:
1349            if self.parameters.dry_run:
1350                s = 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1351                self.logger.info(s)
1352
1353            else:
1354                tkt.save_changes(tkt['reporter'], comment)
1355
1356        if not spam:
1357            self.notify(tkt, True)
1358
1359
1360    def attach_attachments(self, message_parts, update=False):
1361        '''
1362        save any attachments as files in the ticket's directory
1363        '''
1364        self.logger.debug('function attach_attachments()')
1365
1366        if self.parameters.dry_run:
1367            self.logger.debug("DRY_RUN: no attachments attached to tickets")
1368            return ''
1369
1370        count = 0
1371
1372        ## Get Maxium attachment size
1373        #
1374        max_size = int(self.get_config('attachment', 'max_size'))
1375        status   = None
1376       
1377        for item in message_parts:
1378            ## Skip body parts
1379            #
1380            if not isinstance(item, tuple):
1381                continue
1382               
1383            (original, filename, part) = item
1384
1385            ## We have to determine the size so we use this temporary solution.
1386            #
1387            path, fd =  util.create_unique_file(os.path.join(self.parameters.tmpdir, 'email2trac_tmp.att'))
1388            text = part.get_payload(decode=1)
1389            if not text:
1390                text = '(None)'
1391            fd.write(text)
1392            fd.close()
1393
1394            ## get the file_size
1395            #
1396            stats = os.lstat(path)
1397            file_size = stats[ST_SIZE]
1398
1399            ## Check if the attachment size is allowed
1400            #
1401            if (max_size != -1) and (file_size > max_size):
1402                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1403                    %(status, original, file_size, max_size)
1404
1405                os.unlink(path)
1406                continue
1407            else:
1408                count = count + 1
1409                   
1410            ## Insert the attachment
1411            #
1412            fd = open(path, 'rb')
1413            if self.system == 'discussion':
1414                att = attachment.Attachment(self.env, 'discussion', 'topic/%s' % (self.id,))
1415            elif self.system == 'blog':
1416                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,))
1417            else:
1418                s = 'Attach %s to ticket %d' %(filename, self.id)
1419                self.print_unicode(s)
1420                att = attachment.Attachment(self.env, 'ticket', self.id)
1421 
1422            ## This will break the ticket_update system, the body_text is vaporized
1423            #  ;-(
1424            #
1425            if not update:
1426                att.author = self.author
1427                att.description = self.email_to_unicode('Added by email2trac')
1428
1429            try:
1430
1431                self.logger.debug('Insert atachment')
1432                att.insert(filename, fd, file_size)
1433
1434            except OSError, detail:
1435
1436                self.logger.info('%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail))
1437                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1438
1439            ## Remove the created temporary filename
1440            #
1441            fd.close()
1442            os.unlink(path)
1443
1444        ## return error
1445        #
1446        return status
1447
1448########## Fullblog functions  #################################################
1449
1450    def blog(self, msg, subject, id, params):
1451        """
1452        The blog create/update function
1453        """
1454        ## import the modules
1455        #
1456        from tracfullblog.core import FullBlogCore
1457        from tracfullblog.model import BlogPost, BlogComment
1458
1459        ## instantiate blog core
1460        #
1461        blog = FullBlogCore(self.env)
1462        req = Mock(authname='anonymous', perm=MockPerm(), args={})
1463
1464        ## parameters from the subject
1465        #
1466        params = self.str_to_dict((params or '').lstrip('?'))
1467
1468        ## preferably get the time from the email date header, or else
1469        #  use current date and time
1470        date = email.Utils.parsedate_tz(msg.get('date'))
1471        if date:
1472            dt = util.datefmt.to_datetime(email.Utils.mktime_tz(date), util.datefmt.utc)
1473        else:
1474            self.logger.warn("No valid date header found")
1475            dt = util.datefmt.to_datetime(None, util.datefmt.utc)
1476
1477        ## blog entry affected
1478        #
1479        self.id = id or util.datefmt.format_datetime(dt, "%Y%m%d%H%M%S", util.datefmt.utc)
1480
1481        ## check wether a blog post exists
1482        #
1483        post = BlogPost(self.env, self.id)
1484        force_update = self.properties.get('update', params.get('update'))
1485
1486        ## message parts
1487        #
1488        message_parts = self.get_message_parts(msg)
1489        message_parts = self.unique_attachment_names(message_parts)
1490
1491        if post.get_versions() and not force_update:
1492
1493            ## add comment to blog entry
1494            #
1495            comment = BlogComment(self.env, self.id)
1496            comment.author = self.properties.get('author', params.get('author', self.author))
1497            comment.comment = self.get_body_text(message_parts)
1498            comment.time = dt
1499
1500            if self.parameters.dry_run:
1501                self.logger.info('DRY-RUN: not adding comment for blog entry "%s"' % id)
1502                return
1503            warnings = blog.create_comment(req, comment)
1504
1505        else:
1506            ## create or update blog entry
1507            #
1508            post.author = self.properties.get('author', params.get('author', self.author))
1509            post.categories = self.properties.get('categories', params.get('categories', ''))
1510            post.title = subject.strip()
1511            post.publish_time = dt
1512            post.body = self.get_body_text(message_parts)
1513           
1514            if self.parameters.dry_run:
1515                self.logger.info('DRY-RUN: not creating blog entry "%s"' % post.title)
1516                return
1517            warnings = blog.create_post(req, post, self.author, u'Created by email2trac', False)
1518
1519        ## check for problems
1520        #
1521        if warnings:
1522            raise TracError(', '.join('blog:%s:%s' % (w[0], w[1]) for w in warnings))
1523       
1524        ## all seems well, attach attachments
1525        #
1526        self.attach_attachments(message_parts)
1527
1528
1529########## Discussion functions  ##############################################
1530
1531    def discussion_topic(self, content, subject):
1532
1533        ## Import modules.
1534        #
1535        from tracdiscussion.api import DiscussionApi
1536        from trac.util.datefmt import to_timestamp, utc
1537
1538        self.logger.debug('Creating a new topic in forum:', self.id)
1539
1540        ## Get dissussion API component.
1541        #
1542        api = self.env[DiscussionApi]
1543        context = self._create_context(content, subject)
1544
1545        ## Get forum for new topic.
1546        #
1547        forum = api.get_forum(context, self.id)
1548
1549        if not forum:
1550            self.logger.error("ERROR: Replied forum doesn't exist")
1551
1552        ## Prepare topic.
1553        #
1554        topic = {'forum' : forum['id'],
1555                 'subject' : context.subject,
1556                 'time': to_timestamp(datetime.now(utc)),
1557                 'author' : self.author,
1558                 'subscribers' : [self.email_addr],
1559                 'body' : self.get_body_text(context.content_parts)}
1560
1561        ## Add topic to DB and commit it.
1562        #
1563        self._add_topic(api, context, topic)
1564        self.db.commit()
1565
1566    def discussion_topic_reply(self, content, subject):
1567
1568        ## Import modules.
1569        #
1570        from tracdiscussion.api import DiscussionApi
1571        from trac.util.datefmt import to_timestamp, utc
1572
1573        self.logger.debug('Replying to discussion topic', self.id)
1574
1575        ## Get dissussion API component.
1576        #
1577        api = self.env[DiscussionApi]
1578        context = self._create_context(content, subject)
1579
1580        ## Get replied topic.
1581        #
1582        topic = api.get_topic(context, self.id)
1583
1584        if not topic:
1585            self.logger.error("ERROR: Replied topic doesn't exist")
1586
1587        ## Prepare message.
1588        #
1589        message = {'forum' : topic['forum'],
1590                   'topic' : topic['id'],
1591                   'replyto' : -1,
1592                   'time' : to_timestamp(datetime.now(utc)),
1593                   'author' : self.author,
1594                   'body' : self.get_body_text(context.content_parts)}
1595
1596        ## Add message to DB and commit it.
1597        #
1598        self._add_message(api, context, message)
1599        self.db.commit()
1600
1601    def discussion_message_reply(self, content, subject):
1602
1603        ## Import modules.
1604        #
1605        from tracdiscussion.api import DiscussionApi
1606        from trac.util.datefmt import to_timestamp, utc
1607
1608        self.logger.debug('Replying to discussion message', self.id)
1609
1610        ## Get dissussion API component.
1611        #
1612        api = self.env[DiscussionApi]
1613        context = self._create_context(content, subject)
1614
1615        ## Get replied message.
1616        #
1617        message = api.get_message(context, self.id)
1618
1619        if not message:
1620            self.logger.error("ERROR: Replied message doesn't exist")
1621
1622        ## Prepare message.
1623        #
1624        message = {'forum' : message['forum'],
1625                   'topic' : message['topic'],
1626                   'replyto' : message['id'],
1627                   'time' : to_timestamp(datetime.now(utc)),
1628                   'author' : self.author,
1629                   'body' : self.get_body_text(context.content_parts)}
1630
1631        ## Add message to DB and commit it.
1632        #
1633        self._add_message(api, context, message)
1634        self.db.commit()
1635
1636    def _create_context(self, content, subject):
1637
1638        ## Import modules.
1639        #
1640        from trac.mimeview import Context
1641        from trac.web.api import Request
1642        from trac.perm import PermissionCache
1643
1644        ## TODO: Read server base URL from config.
1645        #  Create request object to mockup context creation.
1646        #
1647        environ = {'SERVER_PORT' : 80,
1648                   'SERVER_NAME' : 'test',
1649                   'REQUEST_METHOD' : 'POST',
1650                   'wsgi.url_scheme' : 'http',
1651                   'wsgi.input' : sys.stdin}
1652        chrome =  {'links': {},
1653                   'scripts': [],
1654                   'ctxtnav': [],
1655                   'warnings': [],
1656                   'notices': []}
1657
1658        if self.env.base_url_for_redirect:
1659            environ['trac.base_url'] = self.env.base_url
1660
1661        req = Request(environ, None)
1662        req.chrome = chrome
1663        req.tz = 'missing'
1664        req.authname = self.author
1665        req.perm = PermissionCache(self.env, self.author)
1666        req.locale = None
1667
1668        ## Create and return context.
1669        #
1670        context = Context.from_request(req)
1671        context.realm = 'discussion-email2trac'
1672        context.cursor = self.db.cursor()
1673        context.content = content
1674        context.subject = subject
1675
1676        ## Read content parts from content.
1677        #
1678        context.content_parts = self.get_message_parts(content)
1679        context.content_parts = self.unique_attachment_names(
1680          context.content_parts)
1681
1682        return context
1683
1684    def _add_topic(self, api, context, topic):
1685        context.req.perm.assert_permission('DISCUSSION_APPEND')
1686
1687        ## Filter topic.
1688        #
1689        for discussion_filter in api.discussion_filters:
1690            accept, topic_or_error = discussion_filter.filter_topic(
1691              context, topic)
1692            if accept:
1693                topic = topic_or_error
1694            else:
1695                raise TracError(topic_or_error)
1696
1697        ## Add a new topic.
1698        #
1699        api.add_topic(context, topic)
1700
1701        ## Get inserted topic with new ID.
1702        #
1703        topic = api.get_topic_by_time(context, topic['time'])
1704
1705        ## Attach attachments.
1706        #
1707        self.id = topic['id']
1708        self.attach_attachments(context.content_parts, True)
1709
1710        ## Notify change listeners.
1711        #
1712        for listener in api.topic_change_listeners:
1713            listener.topic_created(context, topic)
1714
1715    def _add_message(self, api, context, message):
1716        context.req.perm.assert_permission('DISCUSSION_APPEND')
1717
1718        ## Filter message.
1719        #
1720        for discussion_filter in api.discussion_filters:
1721            accept, message_or_error = discussion_filter.filter_message(
1722              context, message)
1723            if accept:
1724                message = message_or_error
1725            else:
1726                raise TracError(message_or_error)
1727
1728        ## Add message.
1729        #
1730        api.add_message(context, message)
1731
1732        ## Get inserted message with new ID.
1733        #
1734        message = api.get_message_by_time(context, message['time'])
1735
1736        ## Attach attachments.
1737        #
1738
1739        self.attach_attachments(context.content_parts, True)
1740
1741        ## Notify change listeners.
1742        #
1743        for listener in api.message_change_listeners:
1744            listener.message_created(context, message)
1745
1746########## MAIN function  ######################################################
1747
1748    def parse(self, fp):
1749        """
1750        """
1751        self.logger.debug('Main function parse')
1752        global m
1753
1754        m = email.message_from_file(fp)
1755       
1756        if not m:
1757            self.logger.debug('This is not a valid email message format')
1758            return
1759           
1760        ## Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1761        #
1762        try:
1763            m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1764        except AttributeError, detail:
1765            pass
1766
1767        if self.parameters.debug:     # save the entire e-mail message text
1768            self.save_email_for_debug(m, self.parameters.project_name, True)
1769
1770        if self.VERSION <= 0.11:
1771            self.db = self.env.get_db_cnx()
1772        else:
1773            self.db = self.env.get_read_db()
1774
1775        self.get_sender_info(m)
1776
1777
1778        if self.parameters.white_list_file:
1779            self.acl_list_from_file(self.parameters.white_list_file, 'white_list')
1780
1781        if not ( self.email_header_acl('white_list', self.email_addr, True) or self.allow_registered_user ) :
1782
1783            self.logger.info('Message rejected : %s not in white list' %(self.email_addr))
1784            return False
1785
1786        if self.email_header_acl('black_list', self.email_addr, False):
1787            self.logger.info('Message rejected : %s in black list' %(self.email_addr))
1788            return False
1789
1790        if not self.email_header_acl('recipient_list', self.email_to_addrs, True):
1791            self.logger.info('Message rejected : %s not in recipient list' %(self.email_to_addrs))
1792            return False
1793
1794        ## If spam drop the message
1795        #
1796        if self.spam(m) == 'drop':
1797            return False
1798
1799        elif self.spam(m) == 'spam':
1800            spam_msg = True
1801        else:
1802            spam_msg = False
1803
1804        if not m['Subject']:
1805            subject  = 'No Subject'
1806        else:
1807            subject  = self.email_to_unicode(m['Subject'])
1808
1809        ## Bug in python logging module <2.6
1810        #
1811        self.logger.info('subject: %s' %repr(subject))
1812
1813        ## First try the new method and then fall back to old method
1814        #
1815        if not self.parse_delivered_to_field(m, subject, spam_msg):
1816            self.parse_subject_field(m, subject, spam_msg)
1817
1818    def parse_delivered_to_field(self, m, subject, spam_msg):
1819        """
1820        """
1821        self.logger.debug('function parse_delivered_to_field')
1822
1823        ## Ticket id is in Delivered-To Field. Trac notify must send this
1824        #  eg: is+390@surfsara.nl
1825       
1826        ## Only run if this parameter is se
1827        #
1828        if not self.parameters.recipient_delimiter:
1829            return False
1830
1831        try:
1832
1833            #print self.parameters.project_name
1834            self.logger.debug('Delivered To: %s' %m['Delivered-To'])
1835
1836            id = m['Delivered-To']
1837            id = id.split(self.parameters.recipient_delimiter)[1]
1838            id = id.split('@')[0]
1839
1840            self.logger.debug('\t Found ticket id: %s' %id)
1841
1842            if not self.ticket_update(m, id, spam_msg):
1843                return False
1844
1845        except KeyError, detail:
1846            pass
1847        except IndexError, detail:
1848            pass
1849
1850        return False
1851
1852    def parse_subject_field(self, m, subject, spam_msg):
1853        """
1854        """
1855        self.logger.debug('function parse_subject_header')
1856
1857        ## [hic] #1529: Re: LRZ
1858        #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1859        #
1860        ticket_regex = r'''
1861            (?P<new_fields>[#][?].*)
1862            |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1863            '''
1864
1865        ## Check if  FullBlogPlugin is installed
1866        #
1867        blog_enabled = None
1868        blog_regex = ''
1869        if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1870            self.logger.debug('Trac BLOG support enabled')
1871            blog_enabled = True
1872            blog_regex = '''|(?P<blog>blog(?P<blog_params>[?][^:]*)?:(?P<blog_id>\S*))'''
1873
1874
1875        ## Check if DiscussionPlugin is installed
1876        #
1877        discussion_enabled = None
1878        discussion_regex = ''
1879        if self.get_config('components', 'tracdiscussion.api.discussionapi') in ['enabled']:
1880            self.logger.debug('Trac Discussion support enabled')
1881            discussion_enabled = True
1882            discussion_regex = r'''
1883            |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1884            |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1885            |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1886            '''
1887
1888
1889        regex_str = ticket_regex + blog_regex + discussion_regex
1890        SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1891
1892        ## Find out if this is a ticket, a blog or a discussion
1893        #
1894        result =  SYSTEM_RE.search(subject)
1895
1896        if result:
1897            ## update ticket + fields
1898            #
1899            if result.group('reply'):
1900                self.system = 'ticket'
1901
1902                ## Skip the last ':' character
1903                #
1904                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1905                    self.new_ticket(m, subject, spam_msg)
1906
1907            ## New ticket + fields
1908            #
1909            elif result.group('new_fields'):
1910                self.system = 'ticket'
1911                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1912
1913            if blog_enabled:
1914                if result.group('blog'):
1915                    self.system = 'blog'
1916                    self.blog(m, subject[result.end('blog'):], result.group('blog_id'), result.group('blog_params'))
1917
1918            if discussion_enabled:
1919                ## New topic.
1920                #
1921                if result.group('forum'):
1922                    self.system = 'discussion'
1923                    self.id = int(result.group('forum_id'))
1924                    self.discussion_topic(m, subject[result.end('forum'):])
1925
1926                ## Reply to topic.
1927                #
1928                elif result.group('topic'):
1929                    self.system = 'discussion'
1930                    self.id = int(result.group('topic_id'))
1931                    self.discussion_topic_reply(m, subject[result.end('topic'):])
1932
1933                ## Reply to topic message.
1934                #
1935                elif result.group('message'):
1936                    self.system = 'discussion'
1937                    self.id = int(result.group('message_id'))
1938                    self.discussion_message_reply(m, subject[result.end('message'):])
1939
1940        else:
1941
1942            self.system = 'ticket'
1943            (matched_id, subject) = self.ticket_update_by_subject(subject)
1944
1945            if matched_id:
1946
1947                if not self.ticket_update(m, matched_id, spam_msg):
1948                    self.new_ticket(m, subject, spam_msg)
1949
1950            else:
1951                ## No update by subject, so just create a new ticket
1952                #
1953                self.new_ticket(m, subject, spam_msg)
1954
1955
1956########## BODY TEXT functions  ###########################################################
1957
1958    def strip_signature(self, text):
1959        """
1960        Strip signature from message, inspired by Mailman software
1961        """
1962        self.logger.debug('function strip_signature: %s' %self.parameters.strip_signature_regex)
1963
1964        body = []
1965
1966        STRIP_RE = re.compile( self.parameters.strip_signature_regex )
1967        for line in text.splitlines():
1968
1969            match = STRIP_RE.match(line)
1970            if match:
1971                self.logger.debug('\t"%s "  matched, skiping rest of message' %line)
1972                break
1973
1974            body.append(line)
1975
1976        return ('\n'.join(body))
1977
1978    def reflow(self, text, delsp = 0):
1979        """
1980        Reflow the message based on the format="flowed" specification (RFC 3676)
1981        """
1982        flowedlines = []
1983        quotelevel = 0
1984        prevflowed = 0
1985
1986        for line in text.splitlines():
1987            from re import match
1988           
1989            ## Figure out the quote level and the content of the current line
1990            #
1991            m = match('(>*)( ?)(.*)', line)
1992            linequotelevel = len(m.group(1))
1993            line = m.group(3)
1994
1995            ## Determine whether this line is flowed
1996            #
1997            if line and line != '-- ' and line[-1] == ' ':
1998                flowed = 1
1999            else:
2000                flowed = 0
2001
2002            if flowed and delsp and line and line[-1] == ' ':
2003                line = line[:-1]
2004
2005            ## If the previous line is flowed, append this line to it
2006            #
2007            if prevflowed and line != '-- ' and linequotelevel == quotelevel:
2008                flowedlines[-1] += line
2009
2010            ## Otherwise, start a new line
2011            #
2012            else:
2013                flowedlines.append('>' * linequotelevel + line)
2014
2015            prevflowed = flowed
2016           
2017
2018        return '\n'.join(flowedlines)
2019
2020    def strip_quotes(self, text):
2021        """
2022        Strip quotes from message by Nicolas Mendoza
2023        """
2024        self.logger.debug('function strip_quotes: %s' %self.parameters.email_quote)
2025
2026        body = []
2027
2028        STRIP_RE = re.compile( self.parameters.email_quote )
2029
2030        for line in text.splitlines():
2031
2032            try:
2033
2034                match = STRIP_RE.match(line)
2035                if match:
2036                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2037                    continue
2038
2039            except UnicodeDecodeError:
2040
2041                tmp_line = self.email_to_unicode(line)
2042
2043                match = STRIP_RE.match(tmp_line)
2044                if match:
2045                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2046                    continue
2047               
2048            body.append(line)
2049
2050        return ('\n'.join(body))
2051
2052    def inline_properties(self, text):
2053        """
2054        Parse text if we use inline keywords to set ticket fields
2055        """
2056        self.logger.debug('function inline_properties')
2057
2058        properties = dict()
2059        body = list()
2060
2061        INLINE_EXP = re.compile('\s*[@]\s*(\w+)\s*:(.*)$')
2062
2063        for line in text.splitlines():
2064            match = INLINE_EXP.match(line)
2065            if match:
2066                keyword, value = match.groups()
2067
2068                if self.parameters.inline_properties_first_wins:
2069                    if keyword in self.properties.keys():
2070                        continue
2071
2072                self.properties[keyword] = value.strip()
2073                self.logger.debug('\tinline properties: %s : %s' %(keyword,value))
2074
2075            else:
2076                body.append(line)
2077               
2078        return '\n'.join(body)
2079
2080
2081    def wrap_text(self, text, replace_whitespace = False):
2082        """
2083        Will break a lines longer then given length into several small
2084        lines of size given length
2085        """
2086        import textwrap
2087
2088        LINESEPARATOR = '\n'
2089        reformat = ''
2090
2091        for s in text.split(LINESEPARATOR):
2092            tmp = textwrap.fill(s, self.parameters.use_textwrap)
2093            if tmp:
2094                reformat = '%s\n%s' %(reformat,tmp)
2095            else:
2096                reformat = '%s\n' %reformat
2097
2098        return reformat
2099
2100        # Python2.4 and higher
2101        #
2102        #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
2103        #
2104
2105########## EMAIL attachements functions ###########################################################
2106
2107    def inline_part(self, part):
2108        """
2109        """
2110        self.logger.debug('function inline_part()')
2111
2112        return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
2113
2114    def get_message_parts(self, msg, new_email=False):
2115        """
2116        parses the email message and returns a list of body parts and attachments
2117        body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
2118        """
2119        self.logger.debug('function get_message_parts()')
2120
2121        message_parts = list()
2122   
2123        ALTERNATIVE_MULTIPART = False
2124
2125        for part in msg.walk():
2126            content_maintype = part.get_content_maintype()
2127            content_type =  part.get_content_type()
2128
2129            self.logger.debug('\t Message part: Main-Type: %s' % content_maintype)
2130            self.logger.debug('\t Message part: Content-Type: %s' % content_type)
2131
2132            ## Check content type
2133            #
2134            if content_type in self.STRIP_CONTENT_TYPES:
2135                self.logger.debug("\t A %s attachment named '%s' was skipped" %(content_type, part.get_filename()))
2136                continue
2137
2138            ## Catch some mulitpart execptions
2139            #
2140            if content_type == 'multipart/alternative':
2141                ALTERNATIVE_MULTIPART = True
2142                continue
2143
2144            ## Skip multipart containers
2145            #
2146            if content_maintype == 'multipart':
2147                self.logger.debug("\t Skipping multipart container")
2148                continue
2149           
2150            ## Check if this is an inline part. It's inline if there is co Cont-Disp header,
2151            #  or if there is one and it says "inline"
2152            #
2153            inline = self.inline_part(part)
2154
2155            ## Drop HTML message
2156            #
2157            if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
2158
2159                if content_type == 'text/html':
2160                    self.logger.debug('\t Skipping alternative HTML message')
2161                    ALTERNATIVE_MULTIPART = False
2162                    continue
2163
2164
2165            #if self.VERSION < 1.0:
2166            #    filename = part.get_filename()
2167
2168            ## convert 7 bit filename to 8 bit unicode
2169            #
2170            raw_filename = part.get_filename()
2171            filename = self.email_to_unicode(raw_filename);
2172
2173            s = '\t unicode filename: %s' %(filename)
2174            self.print_unicode(s)
2175            self.logger.debug('\t raw filename: %s' %repr(raw_filename))
2176
2177            if self.VERSION < 1.0:
2178                filename = self.check_filename_length(filename)
2179
2180            ## Save all non plain text message as attachment
2181            #
2182            if not content_type in ['text/plain']:
2183
2184                message_parts.append( (filename, part) )
2185
2186                ## We only convert html messages
2187                #
2188                if not content_type == 'text/html':
2189                    self.logger.debug('\t Appending %s (%s)' %(repr(filename), content_type))
2190                    continue
2191
2192
2193            ## We have an text or html message
2194            #
2195            if not inline:
2196                    self.logger.debug('\t Appending %s (%s), not an inline messsage part' %(repr(filename), content_type))
2197                    message_parts.append( (filename, part) )
2198                    continue
2199               
2200            ## Try to decode message part. We have a html or plain text messafe
2201            #
2202            body_text = part.get_payload(decode=1)
2203            if not body_text:           
2204                body_text = part.get_payload(decode=0)
2205
2206            ## Try to convert html message
2207            #
2208            if content_type == 'text/html':
2209
2210                body_text = self.html_2_txt(body_text)
2211                if not body_text:
2212                    continue
2213
2214            format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
2215            delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
2216
2217            if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
2218                body_text = self.reflow(body_text, delsp == 'yes')
2219
2220            if new_email and self.parameters.only_strip_on_update:
2221                self.logger.debug('Skip signature/quote stripping for new messages')
2222            else:
2223                if self.parameters.strip_signature:
2224                    body_text = self.strip_signature(body_text)
2225
2226                if self.parameters.strip_quotes:
2227                    body_text = self.strip_quotes(body_text)
2228
2229            if self.parameters.inline_properties:
2230                body_text = self.inline_properties(body_text)
2231
2232            if self.parameters.use_textwrap:
2233                body_text = self.wrap_text(body_text)
2234
2235            ## Get contents charset (iso-8859-15 if not defined in mail headers)
2236            #
2237            charset = part.get_content_charset()
2238            if not charset:
2239                charset = 'iso-8859-15'
2240
2241            try:
2242                ubody_text = unicode(body_text, charset)
2243
2244            except UnicodeError, detail:
2245                ubody_text = unicode(body_text, 'iso-8859-15')
2246
2247            except LookupError, detail:
2248                ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
2249
2250            if self.parameters.verbatim_format:
2251                message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
2252            else:
2253                message_parts.append('%s' %ubody_text)
2254
2255        return message_parts
2256       
2257    def unique_attachment_names(self, message_parts):
2258        """
2259        Make sure we have unique names attachments:
2260          - check if it contains illegal characters
2261          - Rename "None" filenames to "untitled-part"
2262        """
2263        self.logger.debug('function unique_attachment_names()')
2264        renamed_parts = []
2265        attachment_names = set()
2266
2267        for item in message_parts:
2268           
2269            ## If not an attachment, leave it alone
2270            #
2271            if not isinstance(item, tuple):
2272                renamed_parts.append(item)
2273                continue
2274               
2275            (filename, part) = item
2276
2277            ## If filename = None, use a default one
2278            #
2279            if filename in [ 'None']:
2280                filename = 'untitled-part'
2281                self.logger.info('\t Rename filename "None" to: %s' %filename)
2282
2283                ## Guess the extension from the content type, use non strict mode
2284                #  some additional non-standard but commonly used MIME types
2285                #  are also recognized
2286                #
2287                ext = mimetypes.guess_extension(part.get_content_type(), False)
2288                if not ext:
2289                    ext = '.bin'
2290
2291                filename = '%s%s' % (filename, ext)
2292
2293            ## Discard relative paths for windows/unix in attachment names
2294            #
2295            filename = filename.replace('\\', '_')
2296            filename = filename.replace('/', '_')
2297
2298            ## remove linefeed char
2299            #
2300            for forbidden_char in ['\r', '\n']:
2301                filename = filename.replace(forbidden_char,'')
2302
2303            ## We try to normalize the filename to utf-8 NFC if we can.
2304            #  Files uploaded from OS X might be in NFD.
2305            #  Check python version and then try it
2306            #
2307            #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
2308            #   try:
2309            #       filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
2310            #   except TypeError:
2311            #       pass
2312
2313            ## Make the filename unique for this ticket
2314            #
2315            num = 0
2316            unique_filename = filename
2317            dummy_filename, ext = os.path.splitext(filename)
2318
2319            while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
2320                num += 1
2321                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
2322               
2323            s = '\t Attachment with filename %s will be saved as %s' % (filename, unique_filename)
2324            self.print_unicode(s)
2325
2326            attachment_names.add(unique_filename)
2327
2328            renamed_parts.append((filename, unique_filename, part))
2329   
2330        return renamed_parts
2331           
2332           
2333    def attachment_exists(self, filename):
2334
2335        self.logger.debug("function attachment_exists")
2336
2337        s = '\t check if attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
2338        self.print_unicode(s)
2339
2340        ## Do we have a valid ticket id
2341        #
2342        if not self.id:
2343            return False
2344
2345        try:
2346            if self.system == 'discussion':
2347
2348                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s' % (self.id,), filename)
2349
2350            elif self.system == 'blog':
2351
2352                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,), filename)
2353
2354            else:
2355
2356                att = attachment.Attachment(self.env, 'ticket', self.id, filename)
2357
2358            return True
2359
2360        except attachment.ResourceNotFound:
2361
2362            return False
2363
2364########## TRAC Ticket Text ###########################################################
2365           
2366    def get_body_text(self, message_parts):
2367        """
2368        """
2369        self.logger.debug('function get_body_text()')
2370
2371        body_text = []
2372       
2373        for part in message_parts:
2374       
2375            ## Plain text part, append it
2376            #
2377            if not isinstance(part, tuple):
2378                body_text.extend(part.strip().splitlines())
2379                body_text.append("")
2380                continue
2381
2382            (original, filename, part) = part
2383            inline = self.inline_part(part)
2384
2385            ## Skip generation of attachment link if html is converted to text
2386            #
2387            if part.get_content_type() == 'text/html' and self.parameters.html2text_cmd and inline:
2388                s = 'Skipping attachment link for html part: %s' %(filename)
2389                self.print_unicode(s)
2390                continue
2391           
2392            if part.get_content_maintype() == 'image' and inline:
2393
2394                if self.system != 'discussion':
2395                    s = 'wiki image link for: %s' %(filename)
2396                    self.print_unicode(s)
2397                    body_text.append('[[Image(%s)]]' % filename)
2398
2399                body_text.append("")
2400
2401            else:
2402
2403                if self.system != 'discussion':
2404
2405                    s = 'wiki attachment link for: %s' %(filename)
2406                    self.print_unicode(s)
2407                    body_text.append('[attachment:"%s"]' % filename)
2408
2409                body_text.append("")
2410
2411        ## Convert list body_texts to string
2412        #
2413        body_text = '\r\n'.join(body_text)
2414        return body_text
2415
2416    def html_mailto_link(self, subject):
2417        """
2418        This function returns a HTML mailto tag with the ticket id and author email address
2419        """
2420        self.logger.debug("function html_mailto_link")
2421
2422        if not self.author:
2423            author = self.email_addr
2424        else:   
2425            author = self.author
2426
2427        if not self.parameters.mailto_cc:
2428            self.parameters.mailto_cc = ''
2429           
2430        ## Bug in urllib.quote function
2431        #
2432        if isinstance(subject, unicode):
2433            subject = subject.encode('utf-8')
2434
2435        ## use urllib to escape the chars
2436        #
2437        s = '%s?Subject=%s&cc=%s' %(
2438               urllib.quote(self.email_addr),
2439               urllib.quote('Re: #%s: %s' %(self.id, subject)),
2440               urllib.quote(self.parameters.mailto_cc)
2441               )
2442
2443        if self.VERSION in [ 0.10 ]:
2444            s = '\r\n{{{\r\n#!html\r\n<a\r\n href="mailto:%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
2445        else:
2446            s = '[mailto:"%s" Reply to: %s]' %(s, author)
2447
2448        self.logger.debug("\tmailto link %s" %s)
2449        return s
2450
2451########## TRAC notify section ###########################################################
2452
2453    def notify(self, tkt, new=True, modtime=0):
2454        """
2455        A wrapper for the TRAC notify function. So we can use templates
2456        """
2457        self.logger.debug('function notify()')
2458
2459
2460        class Email2TracNotifyEmail(TicketNotifyEmail):
2461
2462            def __init__(self, env):
2463                TicketNotifyEmail.__init__(self, env)
2464                self.email2trac_notify_reporter = None
2465                self.email2trac_replyto = None
2466
2467            def send(self, torcpts, ccrcpts):
2468                #print 'Yes this works'
2469                dest = self.reporter or 'anonymous'
2470                hdrs = {}
2471                hdrs['Message-ID'] = self.get_message_id(dest, self.modtime)
2472                hdrs['X-Trac-Ticket-ID'] = str(self.ticket.id)
2473                hdrs['X-Trac-Ticket-URL'] = self.data['ticket']['link']
2474                if not self.newticket:
2475                    msgid = self.get_message_id(dest)
2476                    hdrs['In-Reply-To'] = msgid
2477                    hdrs['References'] = msgid
2478
2479
2480                if self.email2trac_notify_reporter:
2481                    if not self.email2trac_notify_reporter in torcpts:
2482                        torcpts.append(sender_email)
2483
2484                if self.email2trac_replyto:
2485                    # use to rewrite reply to
2486                    # hdrs does not work, multiple reply addresses
2487                    #hdrs['Reply-To'] = 'bas.van.der.vlies@gmail.com'
2488                    self.replyto_email = self.email2trac_replyto
2489       
2490                NotifyEmail.send(self, torcpts, ccrcpts, hdrs)
2491
2492        if self.parameters.dry_run  :
2493                self.logger.info('DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter'])
2494                return
2495
2496        try:
2497
2498
2499            ## create false {abs_}href properties, to trick Notify()
2500            #
2501            if self.VERSION in [0.10]:
2502                self.env.abs_href = Href(self.get_config('project', 'url'))
2503                self.env.href = Href(self.get_config('project', 'url'))
2504
2505            tn = Email2TracNotifyEmail(self.env)
2506
2507            ## additionally append sender (regardeless of settings in trac.ini)
2508            #
2509            if self.parameters.notify_reporter:
2510
2511                self.logger.debug('\t Notify reporter set')
2512
2513                if not self.email_header_acl('notify_reporter_black_list', self.email_addr, False):
2514                    tn.email2trac_notify_reporter = self.email_addr
2515
2516            if self.parameters.notify_replyto_rewrite:
2517
2518                self.logger.debug('\t Notify replyto rewrite set')
2519
2520                action, value = self.parameters.notify_replyto_rewrite.split(':')
2521
2522                if action in ['use_mail_domain']:
2523                    self.logger.debug('\t\t use_mail_domain:%s' %value)
2524                    tn.email2trac_replyto = '%s@%s' %(self.id, value)
2525
2526                elif action in ['use_trac_smtp_replyto']:
2527                    self.logger.debug('\t\t use_trac_smtp_replyto delimiter:%s' %value)
2528                    dummy = self.smtp_replyto.split('@')
2529                    if len(dummy) > 1:
2530                        tn.email2trac_replyto = '%s%s%s@%s' %(dummy[0], value, self.id, dummy[1])
2531                    else:
2532                        tn.email2trac_replyto = '%s%s%s' %(dummy[0], value, self.id)
2533
2534            if self.parameters.alternate_notify_template:
2535
2536                if self.VERSION >= 0.11:
2537
2538                    from trac.web.chrome import Chrome
2539
2540                    if  self.parameters.alternate_notify_template_update and not new:
2541                        tn.template_name = self.parameters.alternate_notify_template_update
2542                    else:
2543                        tn.template_name = self.parameters.alternate_notify_template
2544
2545                    tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2546                       
2547                else:
2548
2549                    tn.template_name = self.parameters.alternate_notify_template
2550
2551            tn.notify(tkt, new, modtime)
2552
2553        except Exception, e:
2554            self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2555
2556########## END Class Definition  ########################################################
2557
2558
2559########## Parse Config File  ###########################################################
2560
2561def ReadConfig(file, name):
2562    """
2563    Parse the config file
2564    """
2565    if not os.path.isfile(file):
2566        print 'File %s does not exist' %file
2567        sys.exit(1)
2568
2569    config = trac_config.Configuration(file)
2570   
2571    parentdir = config.get('DEFAULT', 'parentdir')
2572    sections = config.sections()
2573
2574    ## use some trac internals to get the defaults
2575    #
2576    tmp = config.parser.defaults()
2577    project =  SaraDict()
2578
2579    for option, value in tmp.items():
2580        try:
2581            project[option] = int(value)
2582        except ValueError:
2583            project[option] = value
2584
2585    if name:
2586        if name in sections:
2587            project =  SaraDict()
2588            for option, value in  config.options(name):
2589                try:
2590                    project[option] = int(value)
2591                except ValueError:
2592                    project[option] = value
2593
2594        elif not parentdir:
2595            print "Not a valid project name: %s, valid names are: %s" %(name, sections)
2596            print "or set parentdir in the [DEFAULT] section"
2597            sys.exit(1)
2598
2599    ## If parentdir then set project dir to parentdir + name
2600    #
2601    if not project.has_key('project'):
2602        if not parentdir:
2603            print "You must set project or parentdir in your configuration file"
2604            sys.exit(1)
2605        elif not name:
2606            print "You must configure a  project section in your configuration file"
2607        else:
2608            project['project'] = os.path.join(parentdir, name)
2609
2610    ##
2611    # Save the project name
2612    #
2613    project['project_name'] = os.path.basename(project['project'])
2614
2615    return project
2616
2617########## Setup Logging ###############################################################
2618
2619def setup_log(parameters, project_name, interactive=None):
2620    """
2621    Setup logging
2622
2623    Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2624    would be interpreted by the ConfigParser itself.
2625    """
2626    logger = logging.getLogger('email2trac %s' %project_name)
2627
2628    if interactive:
2629        parameters.log_type = 'stderr'
2630
2631    if not parameters.log_type:
2632        if sys.platform in ['win32', 'cygwin']:
2633            parameters.log_type = 'eventlog'
2634        else:
2635            parameters.log_type = 'syslog'
2636
2637    if parameters.log_type == 'file':
2638
2639        if not parameters.log_file:
2640            parameters.log_file = 'email2trac.log'
2641
2642        if not os.path.isabs(parameters.log_file):
2643            parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2644
2645        log_handler = logging.FileHandler(parameters.log_file)
2646
2647    elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2648        ## Requires win32 extensions
2649        #
2650        logid = "email2trac"
2651        log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2652
2653    elif parameters.log_type in ('syslog', 'unix'):
2654        log_handler = logging.handlers.SysLogHandler('/dev/log')
2655
2656    elif parameters.log_type in ('stderr'):
2657        log_handler = logging.StreamHandler(sys.stderr)
2658
2659    else:
2660        log_handler = logging.handlers.BufferingHandler(0)
2661
2662    if parameters.log_format:
2663        parameters.log_format = parameters.log_format.replace('$(', '%(')
2664    else:
2665        parameters.log_format = '%(name)s: %(message)s'
2666        if parameters.log_type in ('file', 'stderr'):
2667            parameters.log_format = '%(asctime)s ' + parameters.log_format
2668
2669    log_formatter = logging.Formatter(parameters.log_format)
2670    log_handler.setFormatter(log_formatter)
2671    logger.addHandler(log_handler)
2672
2673    if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2674        logger.setLevel(logging.DEBUG)
2675        parameters.debug = 1
2676
2677    elif parameters.log_level in ['INFO'] or parameters.verbose:
2678        logger.setLevel(logging.INFO)
2679
2680    elif parameters.log_level in ['WARNING']:
2681        logger.setLevel(logging.WARNING)
2682
2683    elif parameters.log_level in ['ERROR']:
2684        logger.setLevel(logging.ERROR)
2685
2686    elif parameters.log_level in ['CRITICAL']:
2687        logger.setLevel(logging.CRITICAL)
2688
2689    else:
2690        logger.setLevel(logging.INFO)
2691
2692    return logger
2693
2694########## Own TicketNotifyEmail class ###############################################################
2695
2696if __name__ == '__main__':
2697    ## Default config file
2698    #
2699    agilo = False
2700    bh_product = None
2701    configfile = '@email2trac_conf@'
2702    project = ''
2703    component = ''
2704    ticket_prefix = 'default'
2705    dry_run = None
2706    verbose = None
2707    debug_interactive = None
2708    virtualenv = '@virtualenv@'
2709
2710    SHORT_OPT = 'AB:cdE:hf:np:t:v'
2711    LONG_OPT  =  ['agilo', 'bh_product=', 'component=', 'debug',
2712                  'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=',
2713                  'virtualenv=', 'verbose']
2714
2715    try:
2716        opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2717    except getopt.error,detail:
2718        print __doc__
2719        print detail
2720        sys.exit(1)
2721   
2722    project_name = None
2723    for opt,value in opts:
2724        if opt in [ '-h', '--help']:
2725            print __doc__
2726            sys.exit(0)
2727        elif opt in ['-A', '--agilo']:
2728            agilo = True
2729        elif opt in ['-B', '--bh_product']:
2730            bh_product = value
2731        elif opt in ['-c', '--component']:
2732            component = value
2733        elif opt in ['-d', '--debug']:
2734            debug_interactive = 1
2735        elif opt in ['-E', '--virtualenv']:
2736            virtualenv = value
2737        elif opt in ['-f', '--file']:
2738            configfile = value
2739        elif opt in ['-n', '--dry-run']:
2740            dry_run = True
2741        elif opt in ['-p', '--project']:
2742            project_name = value
2743        elif opt in ['-t', '--ticket_prefix']:
2744            ticket_prefix = value
2745        elif opt in ['-v', '--verbose']:
2746            verbose = True
2747
2748    if virtualenv and os.path.exists(virtualenv):
2749        activate_this = os.path.join(virtualenv, 'bin/activate_this.py')
2750        if os.path.exists(activate_this):
2751            execfile(activate_this, dict(__file__=activate_this))
2752
2753    try:
2754        from trac import __version__ as trac_version
2755        from trac import config as trac_config
2756
2757    except ImportError, detail:
2758        print "Can not find a a valid trac installation, solutions could be:"
2759        print "\tset PYTHONPATH"
2760        print "\tuse the --virtualenv <dir> option"
2761        sys.exit(1)
2762   
2763    settings = ReadConfig(configfile, project_name)
2764
2765    ## The default prefix for ticket values in email2trac.conf
2766    #
2767    settings.ticket_prefix = ticket_prefix
2768    settings.dry_run = dry_run
2769    settings.verbose = verbose
2770
2771    if bh_product:
2772        settings.bh_product = bh_product
2773
2774    if not settings.debug and debug_interactive:
2775        settings.debug = debug_interactive
2776
2777    if not settings.project:
2778        print __doc__
2779        print 'No Trac project is defined in the email2trac config file.'
2780        sys.exit(1)
2781
2782    logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2783   
2784    if component:
2785        settings['component'] = component
2786
2787    ## We are only interested in the major versions
2788    # 0.12.3 --> 0.12
2789    # 1.0.2  --> 1.0
2790    #
2791    l = trac_version.split('.')
2792    version = '.'.join(l[0:2])
2793
2794    logger.debug("Found trac version: %s" %(version))
2795   
2796    try:
2797        if version == '0.10':
2798            from trac import attachment
2799            from trac.env import Environment
2800            from trac.ticket import Ticket
2801            from trac.web.href import Href
2802            from trac import util
2803            from trac.ticket.web_ui import TicketModule
2804
2805            #
2806            # return  util.text.to_unicode(str)
2807            #
2808            # see http://projects.edgewall.com/trac/changeset/2799
2809            from trac.ticket.notification import TicketNotifyEmail
2810            from trac import config as trac_config
2811            from trac.core import TracError
2812
2813        elif version in ['0.11', '0.12', '0.13', '1.0', '1.1']:
2814            from trac import attachment
2815            from trac import config as trac_config
2816            from trac import util
2817            from trac.core import TracError
2818            from trac.env import Environment
2819            from trac.perm import PermissionSystem
2820            from trac.perm import PermissionCache
2821            from trac.test import Mock, MockPerm
2822            from trac.ticket.api import TicketSystem
2823            from trac.ticket.web_ui import TicketModule
2824            from trac.web.href import Href
2825
2826            try:
2827                import pkg_resources
2828                pkg = pkg_resources.get_distribution('BloodhoundMultiProduct')
2829                bloodhound = pkg.version.split()[:2]
2830            except pkg_resources.DistributionNotFound:
2831                # assume no bloodhound
2832                bloodhound = None
2833
2834            if bloodhound:
2835                from multiproduct.env import Environment, ProductEnvironment
2836                from multiproduct.ticket.web_ui import (ProductTicketModule
2837                                                        as TicketModule)
2838                logger.debug("Found Bloodhound Distribution")
2839
2840                if not settings.bh_product:
2841                    print __doc__
2842                    print 'No Bloodhound project defined (bh_project) in section:%s email2trac config file.' %(settings.project)
2843                    sys.exit(1)
2844
2845            if agilo:
2846
2847                try:
2848
2849                    from agilo.ticket.model import AgiloTicket as Ticket
2850
2851                except ImportError, detail:
2852
2853                    try:
2854                        from agilo.ticket.model import Ticket
2855                    except ImportError, detail:
2856                        logger.error('Could not find Trac  Agilo environemnt')
2857                        sys.exit(0)
2858
2859            else:
2860
2861                from trac.ticket import Ticket
2862
2863            #
2864            # return  util.text.to_unicode(str)
2865            #
2866            # see http://projects.edgewall.com/trac/changeset/2799
2867            from trac.ticket.notification import TicketNotifyEmail
2868            from trac.notification import NotifyEmail
2869
2870        else:
2871            logger.error('TRAC version %s is not supported' %version)
2872            sys.exit(0)
2873
2874        ## Must be set before environment is created
2875        #
2876        if settings.has_key('python_egg_cache'):
2877            python_egg_cache = str(settings['python_egg_cache'])
2878            os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2879
2880        if settings.debug > 0:
2881            logger.debug('Loading environment %s', settings.project)
2882
2883        try:
2884            env = Environment(settings['project'], create=0)
2885
2886            if bloodhound:
2887                ## possibly overkill testing if the multiproduct schema is a
2888                # new enough version
2889                #
2890                from multiproduct.env import MultiProductSystem
2891                mps = MultiProductSystem(env)
2892                if mps.get_version() > 4:
2893                    try:
2894                        env = ProductEnvironment(env, settings.bh_product, create=0)
2895                    except LookupError:
2896                        logger.error('%s is not a valid Bloodhound' %settings.bh_product)
2897                        sys.exit(0)
2898               
2899        except IOError, detail:
2900            logger.error("trac error: %s" %detail)
2901            sys.exit(0)
2902        except TracError, detail:
2903            logger.error("trac error: %s" %detail)
2904            sys.exit(0)
2905
2906        tktparser = TicketEmailParser(env, settings, logger, float(version))
2907        tktparser.parse(sys.stdin)
2908
2909    ## Catch all errors and use the logging module
2910    #
2911    except Exception, error:
2912
2913        etype, evalue, etb = sys.exc_info()
2914        for e in traceback.format_exception(etype, evalue, etb):
2915            logger.critical(e)
2916
2917        if m:
2918            tktparser.save_email_for_debug(m, settings.project_name, True)
2919
2920        sys.exit(1)
2921
2922# EOB
Note: See TracBrowser for help on using the repository browser.