source: trunk/email2trac.py.in @ 644

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

fixed a bug in get_sender_info function, closes #333

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 91.2 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 644 2014-01-09 11:57:10Z 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 and (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       
1811        ## Only run if this parameter is se
1812        #
1813        if not self.parameters.recipient_delimiter:
1814            return False
1815
1816        try:
1817
1818            #print self.parameters.project_name
1819            self.logger.debug('Delivered To: %s' %m['Delivered-To'])
1820
1821            id = m['Delivered-To']
1822            id = id.split(self.parameters.recipient_delimiter)[1]
1823            id = id.split('@')[0]
1824
1825            self.logger.debug('\t Found ticket id: %s' %id)
1826
1827            if not self.ticket_update(m, id, spam_msg):
1828                return False
1829
1830        except KeyError, detail:
1831            pass
1832        except IndexError, detail:
1833            pass
1834
1835        return False
1836
1837    def parse_subject_field(self, m, subject, spam_msg):
1838        """
1839        """
1840        self.logger.debug('function parse_subject_header')
1841
1842        ## [hic] #1529: Re: LRZ
1843        #  [hic] #1529?owner=bas,priority=medium: Re: LRZ
1844        #
1845        ticket_regex = r'''
1846            (?P<new_fields>[#][?].*)
1847            |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1848            '''
1849
1850        ## Check if  FullBlogPlugin is installed
1851        #
1852        blog_enabled = None
1853        blog_regex = ''
1854        if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1855            self.logger.debug('Trac BLOG support enabled')
1856            blog_enabled = True
1857            blog_regex = '''|(?P<blog>blog(?P<blog_params>[?][^:]*)?:(?P<blog_id>\S*))'''
1858
1859
1860        ## Check if DiscussionPlugin is installed
1861        #
1862        discussion_enabled = None
1863        discussion_regex = ''
1864        if self.get_config('components', 'tracdiscussion.api.discussionapi') in ['enabled']:
1865            self.logger.debug('Trac Discussion support enabled')
1866            discussion_enabled = True
1867            discussion_regex = r'''
1868            |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1869            |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1870            |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1871            '''
1872
1873
1874        regex_str = ticket_regex + blog_regex + discussion_regex
1875        SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1876
1877        ## Find out if this is a ticket, a blog or a discussion
1878        #
1879        result =  SYSTEM_RE.search(subject)
1880
1881        if result:
1882            ## update ticket + fields
1883            #
1884            if result.group('reply'):
1885                self.system = 'ticket'
1886
1887                ## Skip the last ':' character
1888                #
1889                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1890                    self.new_ticket(m, subject, spam_msg)
1891
1892            ## New ticket + fields
1893            #
1894            elif result.group('new_fields'):
1895                self.system = 'ticket'
1896                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1897
1898            if blog_enabled:
1899                if result.group('blog'):
1900                    self.system = 'blog'
1901                    self.blog(m, subject[result.end('blog'):], result.group('blog_id'), result.group('blog_params'))
1902
1903            if discussion_enabled:
1904                ## New topic.
1905                #
1906                if result.group('forum'):
1907                    self.system = 'discussion'
1908                    self.id = int(result.group('forum_id'))
1909                    self.discussion_topic(m, subject[result.end('forum'):])
1910
1911                ## Reply to topic.
1912                #
1913                elif result.group('topic'):
1914                    self.system = 'discussion'
1915                    self.id = int(result.group('topic_id'))
1916                    self.discussion_topic_reply(m, subject[result.end('topic'):])
1917
1918                ## Reply to topic message.
1919                #
1920                elif result.group('message'):
1921                    self.system = 'discussion'
1922                    self.id = int(result.group('message_id'))
1923                    self.discussion_message_reply(m, subject[result.end('message'):])
1924
1925        else:
1926
1927            self.system = 'ticket'
1928            (matched_id, subject) = self.ticket_update_by_subject(subject)
1929
1930            if matched_id:
1931
1932                if not self.ticket_update(m, matched_id, spam_msg):
1933                    self.new_ticket(m, subject, spam_msg)
1934
1935            else:
1936                ## No update by subject, so just create a new ticket
1937                #
1938                self.new_ticket(m, subject, spam_msg)
1939
1940
1941########## BODY TEXT functions  ###########################################################
1942
1943    def strip_signature(self, text):
1944        """
1945        Strip signature from message, inspired by Mailman software
1946        """
1947        self.logger.debug('function strip_signature: %s' %self.parameters.strip_signature_regex)
1948
1949        body = []
1950
1951        STRIP_RE = re.compile( self.parameters.strip_signature_regex )
1952        for line in text.splitlines():
1953
1954            match = STRIP_RE.match(line)
1955            if match:
1956                self.logger.debug('\t"%s "  matched, skiping rest of message' %line)
1957                break
1958
1959            body.append(line)
1960
1961        return ('\n'.join(body))
1962
1963    def reflow(self, text, delsp = 0):
1964        """
1965        Reflow the message based on the format="flowed" specification (RFC 3676)
1966        """
1967        flowedlines = []
1968        quotelevel = 0
1969        prevflowed = 0
1970
1971        for line in text.splitlines():
1972            from re import match
1973           
1974            ## Figure out the quote level and the content of the current line
1975            #
1976            m = match('(>*)( ?)(.*)', line)
1977            linequotelevel = len(m.group(1))
1978            line = m.group(3)
1979
1980            ## Determine whether this line is flowed
1981            #
1982            if line and line != '-- ' and line[-1] == ' ':
1983                flowed = 1
1984            else:
1985                flowed = 0
1986
1987            if flowed and delsp and line and line[-1] == ' ':
1988                line = line[:-1]
1989
1990            ## If the previous line is flowed, append this line to it
1991            #
1992            if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1993                flowedlines[-1] += line
1994
1995            ## Otherwise, start a new line
1996            #
1997            else:
1998                flowedlines.append('>' * linequotelevel + line)
1999
2000            prevflowed = flowed
2001           
2002
2003        return '\n'.join(flowedlines)
2004
2005    def strip_quotes(self, text):
2006        """
2007        Strip quotes from message by Nicolas Mendoza
2008        """
2009        self.logger.debug('function strip_quotes: %s' %self.parameters.email_quote)
2010
2011        body = []
2012
2013        STRIP_RE = re.compile( self.parameters.email_quote )
2014
2015        for line in text.splitlines():
2016
2017            try:
2018
2019                match = STRIP_RE.match(line)
2020                if match:
2021                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2022                    continue
2023
2024            except UnicodeDecodeError:
2025
2026                tmp_line = self.email_to_unicode(line)
2027
2028                match = STRIP_RE.match(tmp_line)
2029                if match:
2030                    self.logger.debug('\t"%s "  matched, skipping rest of message' %line)
2031                    continue
2032               
2033            body.append(line)
2034
2035        return ('\n'.join(body))
2036
2037    def inline_properties(self, text):
2038        """
2039        Parse text if we use inline keywords to set ticket fields
2040        """
2041        self.logger.debug('function inline_properties')
2042
2043        properties = dict()
2044        body = list()
2045
2046        INLINE_EXP = re.compile('\s*[@]\s*(\w+)\s*:(.*)$')
2047
2048        for line in text.splitlines():
2049            match = INLINE_EXP.match(line)
2050            if match:
2051                keyword, value = match.groups()
2052
2053                if self.parameters.inline_properties_first_wins:
2054                    if keyword in self.properties.keys():
2055                        continue
2056
2057                self.properties[keyword] = value.strip()
2058                self.logger.debug('\tinline properties: %s : %s' %(keyword,value))
2059
2060            else:
2061                body.append(line)
2062               
2063        return '\n'.join(body)
2064
2065
2066    def wrap_text(self, text, replace_whitespace = False):
2067        """
2068        Will break a lines longer then given length into several small
2069        lines of size given length
2070        """
2071        import textwrap
2072
2073        LINESEPARATOR = '\n'
2074        reformat = ''
2075
2076        for s in text.split(LINESEPARATOR):
2077            tmp = textwrap.fill(s, self.parameters.use_textwrap)
2078            if tmp:
2079                reformat = '%s\n%s' %(reformat,tmp)
2080            else:
2081                reformat = '%s\n' %reformat
2082
2083        return reformat
2084
2085        # Python2.4 and higher
2086        #
2087        #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
2088        #
2089
2090########## EMAIL attachements functions ###########################################################
2091
2092    def inline_part(self, part):
2093        """
2094        """
2095        self.logger.debug('function inline_part()')
2096
2097        return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
2098
2099    def get_message_parts(self, msg, new_email=False):
2100        """
2101        parses the email message and returns a list of body parts and attachments
2102        body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
2103        """
2104        self.logger.debug('function get_message_parts()')
2105
2106        message_parts = list()
2107   
2108        ALTERNATIVE_MULTIPART = False
2109
2110        for part in msg.walk():
2111            content_maintype = part.get_content_maintype()
2112            content_type =  part.get_content_type()
2113
2114            self.logger.debug('\t Message part: Main-Type: %s' % content_maintype)
2115            self.logger.debug('\t Message part: Content-Type: %s' % content_type)
2116
2117            ## Check content type
2118            #
2119            if content_type in self.STRIP_CONTENT_TYPES:
2120                self.logger.debug("\t A %s attachment named '%s' was skipped" %(content_type, part.get_filename()))
2121                continue
2122
2123            ## Catch some mulitpart execptions
2124            #
2125            if content_type == 'multipart/alternative':
2126                ALTERNATIVE_MULTIPART = True
2127                continue
2128
2129            ## Skip multipart containers
2130            #
2131            if content_maintype == 'multipart':
2132                self.logger.debug("\t Skipping multipart container")
2133                continue
2134           
2135            ## Check if this is an inline part. It's inline if there is co Cont-Disp header,
2136            #  or if there is one and it says "inline"
2137            #
2138            inline = self.inline_part(part)
2139
2140            ## Drop HTML message
2141            #
2142            if ALTERNATIVE_MULTIPART and self.parameters.drop_alternative_html_version:
2143
2144                if content_type == 'text/html':
2145                    self.logger.debug('\t Skipping alternative HTML message')
2146                    ALTERNATIVE_MULTIPART = False
2147                    continue
2148
2149
2150            #if self.VERSION < 1.0:
2151            #    filename = part.get_filename()
2152
2153            ## convert 7 bit filename to 8 bit unicode
2154            #
2155            raw_filename = part.get_filename()
2156            filename = self.email_to_unicode(raw_filename);
2157
2158            s = '\t unicode filename: %s' %(filename)
2159            self.print_unicode(s)
2160            self.logger.debug('\t raw filename: %s' %repr(raw_filename))
2161
2162            if self.VERSION < 1.0:
2163                filename = self.check_filename_length(filename)
2164
2165            ## Save all non plain text message as attachment
2166            #
2167            if not content_type in ['text/plain']:
2168
2169                message_parts.append( (filename, part) )
2170
2171                ## We only convert html messages
2172                #
2173                if not content_type == 'text/html':
2174                    self.logger.debug('\t Appending %s (%s)' %(repr(filename), content_type))
2175                    continue
2176
2177
2178            ## We have an text or html message
2179            #
2180            if not inline:
2181                    self.logger.debug('\t Appending %s (%s), not an inline messsage part' %(repr(filename), content_type))
2182                    message_parts.append( (filename, part) )
2183                    continue
2184               
2185            ## Try to decode message part. We have a html or plain text messafe
2186            #
2187            body_text = part.get_payload(decode=1)
2188            if not body_text:           
2189                body_text = part.get_payload(decode=0)
2190
2191            ## Try to convert html message
2192            #
2193            if content_type == 'text/html':
2194
2195                body_text = self.html_2_txt(body_text)
2196                if not body_text:
2197                    continue
2198
2199            format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
2200            delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
2201
2202            if self.parameters.reflow and not self.parameters.verbatim_format and format == 'flowed':
2203                body_text = self.reflow(body_text, delsp == 'yes')
2204
2205            if new_email and self.parameters.only_strip_on_update:
2206                self.logger.debug('Skip signature/quote stripping for new messages')
2207            else:
2208                if self.parameters.strip_signature:
2209                    body_text = self.strip_signature(body_text)
2210
2211                if self.parameters.strip_quotes:
2212                    body_text = self.strip_quotes(body_text)
2213
2214            if self.parameters.inline_properties:
2215                body_text = self.inline_properties(body_text)
2216
2217            if self.parameters.use_textwrap:
2218                body_text = self.wrap_text(body_text)
2219
2220            ## Get contents charset (iso-8859-15 if not defined in mail headers)
2221            #
2222            charset = part.get_content_charset()
2223            if not charset:
2224                charset = 'iso-8859-15'
2225
2226            try:
2227                ubody_text = unicode(body_text, charset)
2228
2229            except UnicodeError, detail:
2230                ubody_text = unicode(body_text, 'iso-8859-15')
2231
2232            except LookupError, detail:
2233                ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
2234
2235            if self.parameters.verbatim_format:
2236                message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
2237            else:
2238                message_parts.append('%s' %ubody_text)
2239
2240        return message_parts
2241       
2242    def unique_attachment_names(self, message_parts):
2243        """
2244        Make sure we have unique names attachments:
2245          - check if it contains illegal characters
2246          - Rename "None" filenames to "untitled-part"
2247        """
2248        self.logger.debug('function unique_attachment_names()')
2249        renamed_parts = []
2250        attachment_names = set()
2251
2252        for item in message_parts:
2253           
2254            ## If not an attachment, leave it alone
2255            #
2256            if not isinstance(item, tuple):
2257                renamed_parts.append(item)
2258                continue
2259               
2260            (filename, part) = item
2261
2262            ## If filename = None, use a default one
2263            #
2264            if filename in [ 'None']:
2265                filename = 'untitled-part'
2266                self.logger.info('\t Rename filename "None" to: %s' %filename)
2267
2268                ## Guess the extension from the content type, use non strict mode
2269                #  some additional non-standard but commonly used MIME types
2270                #  are also recognized
2271                #
2272                ext = mimetypes.guess_extension(part.get_content_type(), False)
2273                if not ext:
2274                    ext = '.bin'
2275
2276                filename = '%s%s' % (filename, ext)
2277
2278            ## Discard relative paths for windows/unix in attachment names
2279            #
2280            filename = filename.replace('\\', '_')
2281            filename = filename.replace('/', '_')
2282
2283            ## remove linefeed char
2284            #
2285            for forbidden_char in ['\r', '\n']:
2286                filename = filename.replace(forbidden_char,'')
2287
2288            ## We try to normalize the filename to utf-8 NFC if we can.
2289            #  Files uploaded from OS X might be in NFD.
2290            #  Check python version and then try it
2291            #
2292            #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
2293            #   try:
2294            #       filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
2295            #   except TypeError:
2296            #       pass
2297
2298            ## Make the filename unique for this ticket
2299            #
2300            num = 0
2301            unique_filename = filename
2302            dummy_filename, ext = os.path.splitext(filename)
2303
2304            while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
2305                num += 1
2306                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
2307               
2308            s = '\t Attachment with filename %s will be saved as %s' % (filename, unique_filename)
2309            self.print_unicode(s)
2310
2311            attachment_names.add(unique_filename)
2312
2313            renamed_parts.append((filename, unique_filename, part))
2314   
2315        return renamed_parts
2316           
2317           
2318    def attachment_exists(self, filename):
2319
2320        self.logger.debug("function attachment_exists")
2321
2322        s = '\t check if attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
2323        self.print_unicode(s)
2324
2325        ## Do we have a valid ticket id
2326        #
2327        if not self.id:
2328            return False
2329
2330        try:
2331            if self.system == 'discussion':
2332
2333                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s' % (self.id,), filename)
2334
2335            elif self.system == 'blog':
2336
2337                att = attachment.Attachment(self.env, 'blog', '%s' % (self.id,), filename)
2338
2339            else:
2340
2341                att = attachment.Attachment(self.env, 'ticket', self.id, filename)
2342
2343            return True
2344
2345        except attachment.ResourceNotFound:
2346
2347            return False
2348
2349########## TRAC Ticket Text ###########################################################
2350           
2351    def get_body_text(self, message_parts):
2352        """
2353        """
2354        self.logger.debug('function get_body_text()')
2355
2356        body_text = []
2357       
2358        for part in message_parts:
2359       
2360            ## Plain text part, append it
2361            #
2362            if not isinstance(part, tuple):
2363                body_text.extend(part.strip().splitlines())
2364                body_text.append("")
2365                continue
2366
2367            (original, filename, part) = part
2368            inline = self.inline_part(part)
2369
2370            ## Skip generation of attachment link if html is converted to text
2371            #
2372            if part.get_content_type() == 'text/html' and self.parameters.html2text_cmd and inline:
2373                s = 'Skipping attachment link for html part: %s' %(filename)
2374                self.print_unicode(s)
2375                continue
2376           
2377            if part.get_content_maintype() == 'image' and inline:
2378
2379                if self.system != 'discussion':
2380                    s = 'wiki image link for: %s' %(filename)
2381                    self.print_unicode(s)
2382                    body_text.append('[[Image(%s)]]' % filename)
2383
2384                body_text.append("")
2385
2386            else:
2387
2388                if self.system != 'discussion':
2389
2390                    s = 'wiki attachment link for: %s' %(filename)
2391                    self.print_unicode(s)
2392                    body_text.append('[attachment:"%s"]' % filename)
2393
2394                body_text.append("")
2395
2396        ## Convert list body_texts to string
2397        #
2398        body_text = '\r\n'.join(body_text)
2399        return body_text
2400
2401    def html_mailto_link(self, subject):
2402        """
2403        This function returns a HTML mailto tag with the ticket id and author email address
2404        """
2405        self.logger.debug("function html_mailto_link")
2406
2407        if not self.author:
2408            author = self.email_addr
2409        else:   
2410            author = self.author
2411
2412        if not self.parameters.mailto_cc:
2413            self.parameters.mailto_cc = ''
2414           
2415        ## Bug in urllib.quote function
2416        #
2417        if isinstance(subject, unicode):
2418            subject = subject.encode('utf-8')
2419
2420        ## use urllib to escape the chars
2421        #
2422        s = '%s?Subject=%s&cc=%s' %(
2423               urllib.quote(self.email_addr),
2424               urllib.quote('Re: #%s: %s' %(self.id, subject)),
2425               urllib.quote(self.parameters.mailto_cc)
2426               )
2427
2428        if self.VERSION in [ 0.10 ]:
2429            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)
2430        else:
2431            s = '[mailto:"%s" Reply to: %s]' %(s, author)
2432
2433        self.logger.debug("\tmailto link %s" %s)
2434        return s
2435
2436########## TRAC notify section ###########################################################
2437
2438    def notify(self, tkt, new=True, modtime=0):
2439        """
2440        A wrapper for the TRAC notify function. So we can use templates
2441        """
2442        self.logger.debug('function notify()')
2443
2444
2445        class Email2TracNotifyEmail(TicketNotifyEmail):
2446
2447            def __init__(self, env):
2448                TicketNotifyEmail.__init__(self, env)
2449                self.email2trac_notify_reporter = None
2450                self.email2trac_replyto = None
2451
2452            def send(self, torcpts, ccrcpts):
2453                #print 'Yes this works'
2454                dest = self.reporter or 'anonymous'
2455                hdrs = {}
2456                hdrs['Message-ID'] = self.get_message_id(dest, self.modtime)
2457                hdrs['X-Trac-Ticket-ID'] = str(self.ticket.id)
2458                hdrs['X-Trac-Ticket-URL'] = self.data['ticket']['link']
2459                if not self.newticket:
2460                    msgid = self.get_message_id(dest)
2461                    hdrs['In-Reply-To'] = msgid
2462                    hdrs['References'] = msgid
2463
2464
2465                if self.email2trac_notify_reporter:
2466                    if not self.email2trac_notify_reporter in torcpts:
2467                        torcpts.append(sender_email)
2468
2469                if self.email2trac_replyto:
2470                    # use to rewrite reply to
2471                    # hdrs does not work, multiple reply addresses
2472                    #hdrs['Reply-To'] = 'bas.van.der.vlies@gmail.com'
2473                    self.replyto_email = self.email2trac_replyto
2474       
2475                NotifyEmail.send(self, torcpts, ccrcpts, hdrs)
2476
2477        if self.parameters.dry_run  :
2478                self.logger.info('DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter'])
2479                return
2480
2481        try:
2482
2483
2484            ## create false {abs_}href properties, to trick Notify()
2485            #
2486            if self.VERSION in [0.10]:
2487                self.env.abs_href = Href(self.get_config('project', 'url'))
2488                self.env.href = Href(self.get_config('project', 'url'))
2489
2490            tn = Email2TracNotifyEmail(self.env)
2491
2492            ## additionally append sender (regardeless of settings in trac.ini)
2493            #
2494            if self.parameters.notify_reporter:
2495
2496                self.logger.debug('\t Notify reporter set')
2497
2498                if not self.email_header_acl('notify_reporter_black_list', self.email_addr, False):
2499                    tn.email2trac_notify_reporter = self.email_addr
2500
2501            if self.parameters.notify_replyto_rewrite:
2502
2503                self.logger.debug('\t Notify replyto rewrite set')
2504
2505                action, value = self.parameters.notify_replyto_rewrite.split(':')
2506
2507                if action in ['use_mail_domain']:
2508                    self.logger.debug('\t\t use_mail_domain:%s' %value)
2509                    tn.email2trac_replyto = '%s@%s' %(self.id, value)
2510
2511                elif action in ['use_trac_smtp_replyto']:
2512                    self.logger.debug('\t\t use_trac_smtp_replyto delimiter:%s' %value)
2513                    dummy = self.smtp_replyto.split('@')
2514                    if len(dummy) > 1:
2515                        tn.email2trac_replyto = '%s%s%s@%s' %(dummy[0], value, self.id, dummy[1])
2516                    else:
2517                        tn.email2trac_replyto = '%s%s%s' %(dummy[0], value, self.id)
2518
2519            if self.parameters.alternate_notify_template:
2520
2521                if self.VERSION >= 0.11:
2522
2523                    from trac.web.chrome import Chrome
2524
2525                    if  self.parameters.alternate_notify_template_update and not new:
2526                        tn.template_name = self.parameters.alternate_notify_template_update
2527                    else:
2528                        tn.template_name = self.parameters.alternate_notify_template
2529
2530                    tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2531                       
2532                else:
2533
2534                    tn.template_name = self.parameters.alternate_notify_template
2535
2536            tn.notify(tkt, new, modtime)
2537
2538        except Exception, e:
2539            self.logger.error('Failure sending notification on creation of ticket #%s: %s' %(self.id, e))
2540
2541########## END Class Definition  ########################################################
2542
2543
2544########## Parse Config File  ###########################################################
2545
2546def ReadConfig(file, name):
2547    """
2548    Parse the config file
2549    """
2550    if not os.path.isfile(file):
2551        print 'File %s does not exist' %file
2552        sys.exit(1)
2553
2554    config = trac_config.Configuration(file)
2555   
2556    parentdir = config.get('DEFAULT', 'parentdir')
2557    sections = config.sections()
2558
2559    ## use some trac internals to get the defaults
2560    #
2561    tmp = config.parser.defaults()
2562    project =  SaraDict()
2563
2564    for option, value in tmp.items():
2565        try:
2566            project[option] = int(value)
2567        except ValueError:
2568            project[option] = value
2569
2570    if name:
2571        if name in sections:
2572            project =  SaraDict()
2573            for option, value in  config.options(name):
2574                try:
2575                    project[option] = int(value)
2576                except ValueError:
2577                    project[option] = value
2578
2579        elif not parentdir:
2580            print "Not a valid project name: %s, valid names are: %s" %(name, sections)
2581            print "or set parentdir in the [DEFAULT] section"
2582            sys.exit(1)
2583
2584    ## If parentdir then set project dir to parentdir + name
2585    #
2586    if not project.has_key('project'):
2587        if not parentdir:
2588            print "You must set project or parentdir in your configuration file"
2589            sys.exit(1)
2590        elif not name:
2591            print "You must configure a  project section in your configuration file"
2592        else:
2593            project['project'] = os.path.join(parentdir, name)
2594
2595    ##
2596    # Save the project name
2597    #
2598    project['project_name'] = os.path.basename(project['project'])
2599
2600    return project
2601
2602########## Setup Logging ###############################################################
2603
2604def setup_log(parameters, project_name, interactive=None):
2605    """
2606    Setup logging
2607
2608    Note for log format the usage of `$(...)s` instead of `%(...)s` as the latter form
2609    would be interpreted by the ConfigParser itself.
2610    """
2611    logger = logging.getLogger('email2trac %s' %project_name)
2612
2613    if interactive:
2614        parameters.log_type = 'stderr'
2615
2616    if not parameters.log_type:
2617        if sys.platform in ['win32', 'cygwin']:
2618            parameters.log_type = 'eventlog'
2619        else:
2620            parameters.log_type = 'syslog'
2621
2622    if parameters.log_type == 'file':
2623
2624        if not parameters.log_file:
2625            parameters.log_file = 'email2trac.log'
2626
2627        if not os.path.isabs(parameters.log_file):
2628            parameters.log_file = os.path.join(tempfile.gettempdir(), parameters.log_file)
2629
2630        log_handler = logging.FileHandler(parameters.log_file)
2631
2632    elif parameters.log_type in ('winlog', 'eventlog', 'nteventlog'):
2633        ## Requires win32 extensions
2634        #
2635        logid = "email2trac"
2636        log_handler = logging.handlers.NTEventLogHandler(logid, logtype='Application')
2637
2638    elif parameters.log_type in ('syslog', 'unix'):
2639        log_handler = logging.handlers.SysLogHandler('/dev/log')
2640
2641    elif parameters.log_type in ('stderr'):
2642        log_handler = logging.StreamHandler(sys.stderr)
2643
2644    else:
2645        log_handler = logging.handlers.BufferingHandler(0)
2646
2647    if parameters.log_format:
2648        parameters.log_format = parameters.log_format.replace('$(', '%(')
2649    else:
2650        parameters.log_format = '%(name)s: %(message)s'
2651        if parameters.log_type in ('file', 'stderr'):
2652            parameters.log_format = '%(asctime)s ' + parameters.log_format
2653
2654    log_formatter = logging.Formatter(parameters.log_format)
2655    log_handler.setFormatter(log_formatter)
2656    logger.addHandler(log_handler)
2657
2658    if (parameters.log_level in ['DEBUG', 'ALL']) or (parameters.debug > 0):
2659        logger.setLevel(logging.DEBUG)
2660        parameters.debug = 1
2661
2662    elif parameters.log_level in ['INFO'] or parameters.verbose:
2663        logger.setLevel(logging.INFO)
2664
2665    elif parameters.log_level in ['WARNING']:
2666        logger.setLevel(logging.WARNING)
2667
2668    elif parameters.log_level in ['ERROR']:
2669        logger.setLevel(logging.ERROR)
2670
2671    elif parameters.log_level in ['CRITICAL']:
2672        logger.setLevel(logging.CRITICAL)
2673
2674    else:
2675        logger.setLevel(logging.INFO)
2676
2677    return logger
2678
2679########## Own TicketNotifyEmail class ###############################################################
2680
2681if __name__ == '__main__':
2682    ## Default config file
2683    #
2684    agilo = False
2685    configfile = '@email2trac_conf@'
2686    project = ''
2687    component = ''
2688    ticket_prefix = 'default'
2689    dry_run = None
2690    verbose = None
2691    debug_interactive = None
2692    virtualenv = '@virtualenv@'
2693
2694    SHORT_OPT = 'AcdE:hf:np:t:v'
2695    LONG_OPT  =  ['agilo', 'component=', 'debug', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'virtualenv=', 'verbose']
2696
2697    try:
2698        opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2699    except getopt.error,detail:
2700        print __doc__
2701        print detail
2702        sys.exit(1)
2703   
2704    project_name = None
2705    for opt,value in opts:
2706        if opt in [ '-h', '--help']:
2707            print __doc__
2708            sys.exit(0)
2709        elif opt in ['-A', '--agilo']:
2710            agilo = True
2711        elif opt in ['-c', '--component']:
2712            component = value
2713        elif opt in ['-d', '--debug']:
2714            debug_interactive = 1
2715        elif opt in ['-E', '--virtualenv']:
2716            virtualenv = value
2717        elif opt in ['-f', '--file']:
2718            configfile = value
2719        elif opt in ['-n', '--dry-run']:
2720            dry_run = True
2721        elif opt in ['-p', '--project']:
2722            project_name = value
2723        elif opt in ['-t', '--ticket_prefix']:
2724            ticket_prefix = value
2725        elif opt in ['-v', '--verbose']:
2726            verbose = True
2727
2728    if virtualenv and os.path.exists(virtualenv):
2729        activate_this = os.path.join(virtualenv, 'bin/activate_this.py')
2730        if os.path.exists(activate_this):
2731            execfile(activate_this, dict(__file__=activate_this))
2732
2733    try:
2734        from trac import __version__ as trac_version
2735        from trac import config as trac_config
2736
2737    except ImportError, detail:
2738        print "Can not find a a valid trac installation, solutions could be:"
2739        print "\tset PYTHONPATH"
2740        print "\tuse the --virtualenv <dir> option"
2741        sys.exit(1)
2742   
2743    settings = ReadConfig(configfile, project_name)
2744
2745    ## The default prefix for ticket values in email2trac.conf
2746    #
2747    settings.ticket_prefix = ticket_prefix
2748    settings.dry_run = dry_run
2749    settings.verbose = verbose
2750
2751    if not settings.debug and debug_interactive:
2752        settings.debug = debug_interactive
2753
2754    if not settings.project:
2755        print __doc__
2756        print 'No Trac project is defined in the email2trac config file.'
2757        sys.exit(1)
2758
2759    logger = setup_log(settings, os.path.basename(settings.project), debug_interactive)
2760   
2761    if component:
2762        settings['component'] = component
2763
2764    ## We are only interested in the major versions
2765    # 0.12.3 --> 0.12
2766    # 1.0.2  --> 1.0
2767    #
2768    l = trac_version.split('.')
2769    version = '.'.join(l[0:2])
2770
2771    logger.debug("Found trac version: %s" %(version))
2772   
2773    try:
2774        if version == '0.10':
2775            from trac import attachment
2776            from trac.env import Environment
2777            from trac.ticket import Ticket
2778            from trac.web.href import Href
2779            from trac import util
2780            from trac.ticket.web_ui import TicketModule
2781
2782            #
2783            # return  util.text.to_unicode(str)
2784            #
2785            # see http://projects.edgewall.com/trac/changeset/2799
2786            from trac.ticket.notification import TicketNotifyEmail
2787            from trac import config as trac_config
2788            from trac.core import TracError
2789
2790        elif version in ['0.11', '0.12', '0.13', '1.0', '1.1']:
2791            from trac import attachment
2792            from trac import config as trac_config
2793            from trac import util
2794            from trac.core import TracError
2795            from trac.env import Environment
2796            from trac.perm import PermissionSystem
2797            from trac.perm import PermissionCache
2798            from trac.test import Mock, MockPerm
2799            from trac.ticket.api import TicketSystem
2800            from trac.ticket.web_ui import TicketModule
2801            from trac.web.href import Href
2802
2803            if agilo:
2804
2805                try:
2806                    #from agilo.utils.config import AgiloConfig
2807                    #if AgiloConfig(self.env).is_agilo_enabled:
2808                    from agilo.ticket.model import Ticket
2809                except ImportError, detail:
2810                    logger.error('Could not find Trac  Agilo environemnt')
2811                    sys.exit(0)
2812
2813            else:
2814
2815                from trac.ticket import Ticket
2816
2817            #
2818            # return  util.text.to_unicode(str)
2819            #
2820            # see http://projects.edgewall.com/trac/changeset/2799
2821            from trac.ticket.notification import TicketNotifyEmail
2822            from trac.notification import NotifyEmail
2823
2824        else:
2825            logger.error('TRAC version %s is not supported' %version)
2826            sys.exit(0)
2827
2828        ## Must be set before environment is created
2829        #
2830        if settings.has_key('python_egg_cache'):
2831            python_egg_cache = str(settings['python_egg_cache'])
2832            os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2833
2834        if settings.debug > 0:
2835            logger.debug('Loading environment %s', settings.project)
2836
2837        try:
2838            env = Environment(settings['project'], create=0)
2839        except IOError, detail:
2840            logger.error("trac error: %s" %detail)
2841            sys.exit(0)
2842        except TracError, detail:
2843            logger.error("trac error: %s" %detail)
2844            sys.exit(0)
2845
2846        tktparser = TicketEmailParser(env, settings, logger, float(version))
2847        tktparser.parse(sys.stdin)
2848
2849    ## Catch all errors and use the logging module
2850    #
2851    except Exception, error:
2852
2853        etype, evalue, etb = sys.exc_info()
2854        for e in traceback.format_exception(etype, evalue, etb):
2855            logger.critical(e)
2856
2857        if m:
2858            tktparser.save_email_for_debug(m, settings.project_name, True)
2859
2860        sys.exit(1)
2861
2862# EOB
Note: See TracBrowser for help on using the repository browser.