source: trunk/email2trac.py.in @ 666

Last change on this file since 666 was 666, checked in by bas, 9 years ago

added a new option: save_raw_message

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