source: trunk/email2trac.py.in @ 586

Last change on this file since 586 was 585, checked in by bas, 12 years ago

added blog patch from Thomas Moschny, closes #287,#235,#175

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