source: trunk/email2trac.py.in @ 641

Last change on this file since 641 was 641, checked in by bas, 11 years ago

added a new option cc_black_list

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