source: trunk/email2trac.py.in @ 359

Last change on this file since 359 was 359, checked in by bas, 14 years ago

email2trac.py.in:

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 53.5 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"""
[54]23email2trac.py -- Email tickets to Trac.
[22]24
25A simple MTA filter to create Trac tickets from inbound emails.
26
27Copyright 2005, Daniel Lundin <daniel@edgewall.com>
28Copyright 2005, Edgewall Software
29
[282]30Authors:
31  Bas van der Vlies <basv@sara.nl>
32  Walter de Jong <walter@sara.nl>
[22]33
34The scripts reads emails from stdin and inserts directly into a Trac database.
35
36How to use
37----------
[282]38 * See https://subtrac.sara.nl/oss/email2trac/
39
[22]40 * Create an config file:
[282]41    [DEFAULT]                        # REQUIRED
42    project      : /data/trac/test   # REQUIRED
43    debug        : 1                 # OPTIONAL, if set print some DEBUG info
[297]44    trac_version : 0.10              # OPTIONAL, default is 0.11
[22]45
[282]46    [jouvin]                         # OPTIONAL project declaration, if set both fields necessary
47    project      : /data/trac/jouvin # use -p|--project jouvin. 
[22]48       
49 * default config file is : /etc/email2trac.conf
50
51 * Commandline opions:
[205]52                -h,--help
53                -f,--file  <configuration file>
54                -n,--dry-run
55                -p, --project <project name>
56                -t, --ticket_prefix <name>
[22]57
58SVN Info:
59        $Id: email2trac.py.in 359 2010-05-19 13:30:25Z Blackhex $
60"""
61import os
62import sys
63import string
64import getopt
65import stat
66import time
67import email
[136]68import email.Iterators
69import email.Header
[22]70import re
71import urllib
72import unicodedata
73from stat import *
74import mimetypes
[96]75import traceback
[22]76
[190]77
78# Will fail where unavailable, e.g. Windows
79#
80try:
81    import syslog
82    SYSLOG_AVAILABLE = True
83except ImportError:
84    SYSLOG_AVAILABLE = False
85
[182]86from datetime import tzinfo, timedelta, datetime
[199]87from trac import config as trac_config
[91]88
[96]89# Some global variables
90#
[276]91trac_default_version = '0.11'
[96]92m = None
[22]93
[182]94# A UTC class needed for trac version 0.11, added by
95# tbaschak at ktc dot mb dot ca
96#
97class UTC(tzinfo):
98        """UTC"""
99        ZERO = timedelta(0)
100        HOUR = timedelta(hours=1)
101       
102        def utcoffset(self, dt):
103                return self.ZERO
104               
105        def tzname(self, dt):
106                return "UTC"
107               
108        def dst(self, dt):
109                return self.ZERO
110
111
[22]112class TicketEmailParser(object):
113        env = None
114        comment = '> '
[359]115
[206]116        def __init__(self, env, parameters, version):
[22]117                self.env = env
118
119                # Database connection
120                #
121                self.db = None
122
[206]123                # Save parameters
124                #
125                self.parameters = parameters
126
[72]127                # Some useful mail constants
128                #
[287]129                self.email_name = None
[72]130                self.email_addr = None
[183]131                self.email_from = None
[288]132                self.author     = None
[287]133                self.id         = None
[294]134               
135                self.STRIP_CONTENT_TYPES = list()
[72]136
[22]137                self.VERSION = version
[206]138                self.DRY_RUN = parameters['dry_run']
[317]139                self.VERBOSE = parameters['verbose']
[204]140
[172]141                self.get_config = self.env.config.get
[22]142
143                if parameters.has_key('umask'):
144                        os.umask(int(parameters['umask'], 8))
145
146                if parameters.has_key('debug'):
147                        self.DEBUG = int(parameters['debug'])
148                else:
149                        self.DEBUG = 0
150
151                if parameters.has_key('mailto_link'):
152                        self.MAILTO = int(parameters['mailto_link'])
[74]153                        if parameters.has_key('mailto_cc'):
154                                self.MAILTO_CC = parameters['mailto_cc']
155                        else:
156                                self.MAILTO_CC = ''
[22]157                else:
158                        self.MAILTO = 0
159
160                if parameters.has_key('spam_level'):
161                        self.SPAM_LEVEL = int(parameters['spam_level'])
162                else:
163                        self.SPAM_LEVEL = 0
164
[207]165                if parameters.has_key('spam_header'):
166                        self.SPAM_HEADER = parameters['spam_header']
167                else:
168                        self.SPAM_HEADER = 'X-Spam-Score'
169
[191]170                if parameters.has_key('email_quote'):
171                        self.EMAIL_QUOTE = str(parameters['email_quote'])
172                else:   
173                        self.EMAIL_QUOTE = '> '
[22]174
175                if parameters.has_key('email_header'):
176                        self.EMAIL_HEADER = int(parameters['email_header'])
177                else:
178                        self.EMAIL_HEADER = 0
179
[42]180                if parameters.has_key('alternate_notify_template'):
181                        self.notify_template = str(parameters['alternate_notify_template'])
182                else:
183                        self.notify_template = None
[22]184
[222]185                if parameters.has_key('alternate_notify_template_update'):
186                        self.notify_template_update = str(parameters['alternate_notify_template_update'])
187                else:
188                        self.notify_template_update = None
189
[43]190                if parameters.has_key('reply_all'):
191                        self.REPLY_ALL = int(parameters['reply_all'])
192                else:
193                        self.REPLY_ALL = 0
[42]194
[74]195                if parameters.has_key('ticket_update'):
196                        self.TICKET_UPDATE = int(parameters['ticket_update'])
197                else:
198                        self.TICKET_UPDATE = 0
[43]199
[353]200                if parameters.has_key('ticket_update_by_subject'):
201                        self.TICKET_UPDATE_BY_SUBJECT = int(parameters['ticket_update_by_subject'])
202                else:
203                        self.TICKET_UPDATE_BY_SUBJECT = 0
204
[118]205                if parameters.has_key('drop_spam'):
206                        self.DROP_SPAM = int(parameters['drop_spam'])
207                else:
208                        self.DROP_SPAM = 0
[74]209
[134]210                if parameters.has_key('verbatim_format'):
211                        self.VERBATIM_FORMAT = int(parameters['verbatim_format'])
212                else:
213                        self.VERBATIM_FORMAT = 1
[118]214
[231]215                if parameters.has_key('reflow'):
[256]216                        self.REFLOW = int(parameters['reflow'])
[231]217                else:
218                        self.REFLOW = 1
219
[278]220                if parameters.has_key('drop_alternative_html_version'):
221                        self.DROP_ALTERNATIVE_HTML_VERSION = int(parameters['drop_alternative_html_version'])
222                else:
223                        self.DROP_ALTERNATIVE_HTML_VERSION = 0
224
[136]225                if parameters.has_key('strip_signature'):
226                        self.STRIP_SIGNATURE = int(parameters['strip_signature'])
227                else:
228                        self.STRIP_SIGNATURE = 0
[134]229
[191]230                if parameters.has_key('strip_quotes'):
231                        self.STRIP_QUOTES = int(parameters['strip_quotes'])
232                else:
233                        self.STRIP_QUOTES = 0
234
[309]235                self.properties = dict()
236                if parameters.has_key('inline_properties'):
237                        self.INLINE_PROPERTIES = int(parameters['inline_properties'])
238                else:
239                        self.INLINE_PROPERTIES = 0
240
[148]241                if parameters.has_key('use_textwrap'):
242                        self.USE_TEXTWRAP = int(parameters['use_textwrap'])
243                else:
244                        self.USE_TEXTWRAP = 0
245
[238]246                if parameters.has_key('binhex'):
[294]247                        self.STRIP_CONTENT_TYPES.append('application/mac-binhex40')
[238]248
249                if parameters.has_key('applesingle'):
[294]250                        self.STRIP_CONTENT_TYPES.append('application/applefile')
[238]251
252                if parameters.has_key('appledouble'):
[294]253                        self.STRIP_CONTENT_TYPES.append('application/applefile')
[238]254
[294]255                if parameters.has_key('strip_content_types'):
256                        items = parameters['strip_content_types'].split(',')
257                        for item in items:
258                                self.STRIP_CONTENT_TYPES.append(item.strip())
259
[257]260                self.WORKFLOW = None
261                if parameters.has_key('workflow'):
262                        self.WORKFLOW = parameters['workflow']
263
[173]264                # Use OS independend functions
265                #
266                self.TMPDIR = os.path.normcase('/tmp')
267                if parameters.has_key('tmpdir'):
268                        self.TMPDIR = os.path.normcase(str(parameters['tmpdir']))
269
[194]270                if parameters.has_key('ignore_trac_user_settings'):
271                        self.IGNORE_TRAC_USER_SETTINGS = int(parameters['ignore_trac_user_settings'])
272                else:
273                        self.IGNORE_TRAC_USER_SETTINGS = 0
[191]274
[320]275                if parameters.has_key('email_triggers_workflow'):
276                        self.EMAIL_TRIGGERS_WORKFLOW = int(parameters['email_triggers_workflow'])
277                else:
278                        self.EMAIL_TRIGGERS_WORKFLOW = 1
279
[297]280                if parameters.has_key('subject_field_separator'):
281                        self.SUBJECT_FIELD_SEPARATOR = parameters['subject_field_separator'].strip()
282                else:
283                        self.SUBJECT_FIELD_SEPARATOR = '&'
284
[305]285                self.trac_smtp_from = self.get_config('notification', 'smtp_from')
286
[359]287                self.system = None
288
[341]289########## Email Header Functions ###########################################################
[339]290
[22]291        def spam(self, message):
[191]292                """
293                # X-Spam-Score: *** (3.255) BAYES_50,DNS_FROM_AHBL_RHSBL,HTML_
294                # Note if Spam_level then '*' are included
295                """
[194]296                spam = False
[207]297                if message.has_key(self.SPAM_HEADER):
298                        spam_l = string.split(message[self.SPAM_HEADER])
[22]299
[207]300                        try:
301                                number = spam_l[0].count('*')
302                        except IndexError, detail:
303                                number = 0
304                               
[22]305                        if number >= self.SPAM_LEVEL:
[194]306                                spam = True
307                               
[191]308                # treat virus mails as spam
309                #
310                elif message.has_key('X-Virus-found'):                 
[194]311                        spam = True
312
313                # How to handle SPAM messages
314                #
315                if self.DROP_SPAM and spam:
316                        if self.DEBUG > 2 :
317                                print 'This message is a SPAM. Automatic ticket insertion refused (SPAM level > %d' % self.SPAM_LEVEL
318
[204]319                        return 'drop'   
[194]320
321                elif spam:
322
[204]323                        return 'Spam'   
[67]324
[194]325                else:
[22]326
[204]327                        return False
[191]328
[221]329        def email_header_acl(self, keyword, header_field, default):
[206]330                """
[221]331                This function wil check if the email address is allowed or denied
332                to send mail to the ticket list
333            """
[206]334                try:
[221]335                        mail_addresses = self.parameters[keyword]
336
337                        # Check if we have an empty string
338                        #
339                        if not mail_addresses:
340                                return default
341
[206]342                except KeyError, detail:
[221]343                        if self.DEBUG > 2 :
[250]344                                print 'TD: %s not defined, all messages are allowed.' %(keyword)
[206]345
[221]346                        return default
[206]347
[221]348                mail_addresses = string.split(mail_addresses, ',')
349
350                for entry in mail_addresses:
[209]351                        entry = entry.strip()
[221]352                        TO_RE = re.compile(entry, re.VERBOSE|re.IGNORECASE)
353                        result =  TO_RE.search(header_field)
[208]354                        if result:
355                                return True
[149]356
[208]357                return False
358
[22]359        def email_header_txt(self, m):
[72]360                """
361                Display To and CC addresses in description field
362                """
[288]363                s = ''
[334]364
[213]365                if m['To'] and len(m['To']) > 0:
[288]366                        s = "'''To:''' %s\r\n" %(m['To'])
[22]367                if m['Cc'] and len(m['Cc']) > 0:
[288]368                        s = "%s'''Cc:''' %s\r\n" % (s, m['Cc'])
[22]369
[288]370                return  self.email_to_unicode(s)
[22]371
[138]372
[194]373        def get_sender_info(self, message):
[45]374                """
[72]375                Get the default author name and email address from the message
[226]376                """
[43]377
[226]378                self.email_to = self.email_to_unicode(message['to'])
379                self.to_name, self.to_email_addr = email.Utils.parseaddr (self.email_to)
380
[194]381                self.email_from = self.email_to_unicode(message['from'])
[287]382                self.email_name, self.email_addr  = email.Utils.parseaddr(self.email_from)
[142]383
[304]384                ## Trac can not handle author's name that contains spaces
385                #  and forbid the ticket email address as author field
[194]386
[305]387                if self.email_addr == self.trac_smtp_from:
[304]388                        self.author = "email2trac"
389                else:
390                        self.author = self.email_addr
391
[194]392                if self.IGNORE_TRAC_USER_SETTINGS:
393                        return
394
395                # Is this a registered user, use email address as search key:
396                # result:
397                #   u : login name
398                #   n : Name that the user has set in the settings tab
399                #   e : email address that the user has set in the settings tab
[45]400                #
[194]401                users = [ (u,n,e) for (u, n, e) in self.env.get_known_users(self.db)
[250]402                        if e and (e.lower() == self.email_addr.lower()) ]
[43]403
[45]404                if len(users) == 1:
[194]405                        self.email_from = users[0][0]
[250]406                        self.author = users[0][0]
[45]407
[72]408        def set_reply_fields(self, ticket, message):
409                """
410                Set all the right fields for a new ticket
411                """
[299]412                if self.DEBUG:
413                        print 'TD: set_reply_fields'
[72]414
[270]415                ## Only use name or email adress
416                #ticket['reporter'] = self.email_from
417                ticket['reporter'] = self.author
418
419
[45]420                # Put all CC-addresses in ticket CC field
[43]421                #
422                if self.REPLY_ALL:
423
[299]424                        email_cc = ''
425
426                        cc_addrs = email.Utils.getaddresses( message.get_all('cc', []) )
427
428                        if not cc_addrs:
[105]429                                return
[43]430
[299]431                        ## Build a list of forbidden CC addresses
432                        #
[300]433                        #to_addrs = email.Utils.getaddresses( message.get_all('to', []) )
434                        #to_list = list()
435                        #for n,e in to_addrs:
436                        #       to_list.append(e)
[299]437                               
[43]438                        # Remove reporter email address if notification is
439                        # on
440                        #
441                        if self.notification:
442                                try:
[299]443                                        cc_addrs.remove((self.author, self.email_addr))
[43]444                                except ValueError, detail:
445                                        pass
446
[299]447                        for name,addr in cc_addrs:
448               
449                                ## Prevent mail loop
450                                #
[300]451                                #if addr in to_list:
[304]452
453                                if addr == self.trac_smtp_from:
[299]454                                        if self.DEBUG:
455                                                print "Skipping %s mail address for CC-field" %(addr)
456                                        continue
[43]457
[299]458                                if email_cc:
459                                        email_cc = '%s, %s' %(email_cc, addr)
460                                else:
461                                        email_cc = addr
[96]462
[299]463                        if email_cc:
464                                if self.DEBUG:
465                                        print 'TD: set_reply_fields: %s' %email_cc
466
467                                ticket['cc'] = self.email_to_unicode(email_cc)
468
[339]469
470########## DEBUG functions  ###########################################################
471
[310]472        def debug_body(self, message_body, tempfile=False):
473                if tempfile:
474                        import tempfile
475                        body_file = tempfile.mktemp('.email2trac')
476                else:
477                        body_file = os.path.join(self.TMPDIR, 'body.txt')
478
[331]479                if self.DRY_RUN:
480                        print 'DRY-RUN: not saving body to %s' %(body_file)
481                        return
482
483                print 'TD: writing body to %s' %(body_file)
484                fx = open(body_file, 'wb')
[310]485                if not message_body:
[331]486                                message_body = '(None)'
[310]487
488                message_body = message_body.encode('utf-8')
489                #message_body = unicode(message_body, 'iso-8859-15')
490
491                fx.write(message_body)
492                fx.close()
493                try:
494                        os.chmod(body_file,S_IRWXU|S_IRWXG|S_IRWXO)
495                except OSError:
496                        pass
497
498        def debug_attachments(self, message_parts):
[317]499                """
500                """
501                if self.VERBOSE:
502                        print "VB: debug_attachments"
503               
[310]504                n = 0
[331]505                for item in message_parts:
[310]506                        # Skip inline text parts
[331]507                        if not isinstance(item, tuple):
[310]508                                continue
509                               
[331]510                        (original, filename, part) = item
[310]511
512                        n = n + 1
513                        print 'TD: part%d: Content-Type: %s' % (n, part.get_content_type())
[348]514                        print 'TD: part%d: filename: %s' % (n, filename)
[310]515
[348]516                        ## Forbidden chars
517                        #
518                        filename = filename.replace('\\', '_')
519                        filename = filename.replace('/', '_')
520       
521
522                        part_file = os.path.join(self.TMPDIR, filename)
[310]523                        print 'TD: writing part%d (%s)' % (n,part_file)
[331]524
525                        if self.DRY_RUN:
526                                print 'DRY_RUN: NOT saving attachments'
527                                continue
528
[310]529                        fx = open(part_file, 'wb')
530                        text = part.get_payload(decode=1)
[331]531
[310]532                        if not text:
533                                text = '(None)'
[331]534
[310]535                        fx.write(text)
536                        fx.close()
[331]537
[310]538                        try:
539                                os.chmod(part_file,S_IRWXU|S_IRWXG|S_IRWXO)
540                        except OSError:
541                                pass
542
[96]543        def save_email_for_debug(self, message, tempfile=False):
[309]544
[96]545                if tempfile:
546                        import tempfile
547                        msg_file = tempfile.mktemp('.email2trac')
548                else:
[173]549                        #msg_file = '/var/tmp/msg.txt'
550                        msg_file = os.path.join(self.TMPDIR, 'msg.txt')
551
[331]552                if self.DRY_RUN:
553                        print 'DRY_RUN: NOT saving email message to %s' %(msg_file)
554                else:
555                        print 'TD: saving email to %s' %(msg_file)
[44]556
[331]557                        fx = open(msg_file, 'wb')
558                        fx.write('%s' % message)
559                        fx.close()
560                       
561                        try:
562                                os.chmod(msg_file,S_IRWXU|S_IRWXG|S_IRWXO)
563                        except OSError:
564                                pass
565
[309]566                message_parts = self.get_message_parts(message)
567                message_parts = self.unique_attachment_names(message_parts)
568                body_text = self.body_text(message_parts)
569                self.debug_body(body_text, True)
570                self.debug_attachments(message_parts)
571
[339]572########## Conversion functions  ###########################################################
573
[341]574        def email_to_unicode(self, message_str):
575                """
576                Email has 7 bit ASCII code, convert it to unicode with the charset
577                that is encoded in 7-bit ASCII code and encode it as utf-8 so Trac
578                understands it.
579                """
580                if self.VERBOSE:
581                        print "VB: email_to_unicode"
582
583                results =  email.Header.decode_header(message_str)
584
585                s = None
586                for text,format in results:
587                        if format:
588                                try:
589                                        temp = unicode(text, format)
590                                except UnicodeError, detail:
591                                        # This always works
592                                        #
593                                        temp = unicode(text, 'iso-8859-15')
594                                except LookupError, detail:
595                                        #text = 'ERROR: Could not find charset: %s, please install' %format
596                                        #temp = unicode(text, 'iso-8859-15')
597                                        temp = message_str
598                                       
599                        else:
600                                temp = string.strip(text)
601                                temp = unicode(text, 'iso-8859-15')
602
603                        if s:
604                                s = '%s %s' %(s, temp)
605                        else:
606                                s = '%s' %temp
607
608                #s = s.encode('utf-8')
609                return s
610
[288]611        def str_to_dict(self, s):
[164]612                """
[288]613                Transfrom a string of the form [<key>=<value>]+ to dict[<key>] = <value>
[164]614                """
[359]615                if self.VERBOSE:
616                        print "VB: str_to_dict"
[164]617
[297]618                fields = string.split(s, self.SUBJECT_FIELD_SEPARATOR)
[262]619
[164]620                result = dict()
621                for field in fields:
622                        try:
[262]623                                index, value = string.split(field, '=')
[169]624
625                                # We can not change the description of a ticket via the subject
626                                # line. The description is the body of the email
627                                #
628                                if index.lower() in ['description']:
629                                        continue
630
[164]631                                if value:
[165]632                                        result[index.lower()] = value
[169]633
[164]634                        except ValueError:
635                                pass
[165]636                return result
[167]637
[339]638########## TRAC ticket functions  ###########################################################
639
[202]640        def update_ticket_fields(self, ticket, user_dict, use_default=None):
641                """
642                This will update the ticket fields. It will check if the
643                given fields are known and if the right values are specified
644                It will only update the ticket field value:
[169]645                        - If the field is known
[202]646                        - If the value supplied is valid for the ticket field.
647                          If not then there are two options:
648                           1) Skip the value (use_default=None)
649                           2) Set default value for field (use_default=1)
[169]650                """
[334]651                if self.VERBOSE:
652                        print "VB: update_ticket_fields"
[169]653
654                # Build a system dictionary from the ticket fields
655                # with field as index and option as value
656                #
657                sys_dict = dict()
658                for field in ticket.fields:
[167]659                        try:
[169]660                                sys_dict[field['name']] = field['options']
661
[167]662                        except KeyError:
[169]663                                sys_dict[field['name']] = None
[167]664                                pass
[169]665
[301]666                ## Check user supplied fields an compare them with the
[169]667                # system one's
668                #
669                for field,value in user_dict.items():
[202]670                        if self.DEBUG >= 10:
671                                print  'user_field\t %s = %s' %(field,value)
[169]672
[301]673                        ## To prevent mail loop
674                        #
675                        if field == 'cc':
676
677                                cc_list = user_dict['cc'].split(',')
678
[304]679                                if self.trac_smtp_from in cc_list:
[301]680                                        if self.DEBUG > 10:
[304]681                                                print 'TD: MAIL LOOP: %s is not allowed as CC address' %(self.trac_smtp_from)
682                                        cc_list.remove(self.trac_smtp_from)
[301]683
684                                value = ','.join(cc_list)
685                               
686
[169]687                        if sys_dict.has_key(field):
688
689                                # Check if value is an allowed system option, if TypeError then
690                                # every value is allowed
691                                #
692                                try:
693                                        if value in sys_dict[field]:
694                                                ticket[field] = value
[202]695                                        else:
696                                                # Must we set a default if value is not allowed
697                                                #
698                                                if use_default:
699                                                        value = self.get_config('ticket', 'default_%s' %(field) )
[169]700
701                                except TypeError:
[345]702                                        pass
703
704                                ## Only set if we have a value
705                                #
706                                if value:
[169]707                                        ticket[field] = value
[202]708
709                                if self.DEBUG >= 10:
710                                        print  'ticket_field\t %s = %s' %(field,  ticket[field])
[345]711
[260]712        def ticket_update(self, m, id, spam):
[78]713                """
[79]714                If the current email is a reply to an existing ticket, this function
715                will append the contents of this email to that ticket, instead of
716                creating a new one.
[78]717                """
[334]718                if self.VERBOSE:
719                        print "VB: ticket_update: %s" %id
[202]720
[164]721                # Must we update ticket fields
722                #
[220]723                update_fields = dict()
[165]724                try:
[260]725                        id, keywords = string.split(id, '?')
[262]726
727                        # Skip the last ':' character
728                        #
729                        keywords = keywords[:-1]
[220]730                        update_fields = self.str_to_dict(keywords)
[165]731
732                        # Strip '#'
733                        #
[260]734                        self.id = int(id[1:])
[165]735
[260]736                except ValueError:
[165]737                        # Strip '#' and ':'
738                        #
[260]739                        self.id = int(id[1:-1])
[164]740
[71]741
[194]742                # When is the change committed
743                #
744                if self.VERSION == 0.11:
745                        utc = UTC()
746                        when = datetime.now(utc)
747                else:
748                        when = int(time.time())
[77]749
[172]750                try:
[253]751                        tkt = Ticket(self.env, self.id, self.db)
[172]752                except util.TracError, detail:
[253]753                        # Not a valid ticket
754                        self.id = None
[172]755                        return False
[126]756
[288]757                # How many changes has this ticket
758                cnum = len(tkt.get_changelog())
759
760
[220]761                # reopen the ticket if it is was closed
762                # We must use the ticket workflow framework
763                #
[320]764                if tkt['status'] in ['closed'] and self.EMAIL_TRIGGERS_WORKFLOW:
[220]765
[257]766                        #print controller.actions['reopen']
767                        #
768                        # As reference 
769                        # req = Mock(href=Href('/'), abs_href=Href('http://www.example.com/'), authname='anonymous', perm=MockPerm(), args={})
770                        #
771                        #a = controller.render_ticket_action_control(req, tkt, 'reopen')
772                        #print 'controller : ', a
773                        #
774                        #b = controller.get_all_status()
775                        #print 'get all status: ', b
776                        #
777                        #b = controller.get_ticket_changes(req, tkt, 'reopen')
778                        #print 'get_ticket_changes :', b
779
[288]780                        if self.WORKFLOW and (self.VERSION in [0.11]) :
[257]781                                from trac.ticket.default_workflow import ConfigurableTicketWorkflow
782                                from trac.test import Mock, MockPerm
783
784                                req = Mock(authname='anonymous', perm=MockPerm(), args={})
785
786                                controller = ConfigurableTicketWorkflow(self.env)
787                                fields = controller.get_ticket_changes(req, tkt, self.WORKFLOW)
788
789                                if self.DEBUG:
790                                        print 'TD: Workflow ticket update fields: ', fields
791
792                                for key in fields.keys():
793                                        tkt[key] = fields[key]
794
795                        else:
796                                tkt['status'] = 'reopened'
797                                tkt['resolution'] = ''
798
[309]799                # Must we update some ticket fields properties via subjectline
[172]800                #
[220]801                if update_fields:
802                        self.update_ticket_fields(tkt, update_fields)
[166]803
[236]804                message_parts = self.get_message_parts(m)
[253]805                message_parts = self.unique_attachment_names(message_parts)
[210]806
[309]807                # Must we update some ticket fields properties via body_text
808                #
809                if self.properties:
810                                self.update_ticket_fields(tkt, self.properties)
811
[177]812                if self.EMAIL_HEADER:
[236]813                        message_parts.insert(0, self.email_header_txt(m))
[76]814
[236]815                body_text = self.body_text(message_parts)
816
[348]817                if self.VERSION  == 0.9:
818                        error_with_attachments = self.attach_attachments(message_parts, True)
819                else:
820                        error_with_attachments = self.attach_attachments(message_parts)
821
[309]822                if body_text.strip() or update_fields or self.properties:
[250]823                        if self.DRY_RUN:
[288]824                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
[250]825                        else:
[348]826                                if error_with_attachments:
827                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
828                               
[288]829                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
830                       
[219]831
[348]832                #if self.VERSION  == 0.9:
833                #       error_with_attachments = self.attach_attachments(message_parts, True)
834                #else:
835                #       error_with_attachments = self.attach_attachments(message_parts)
[76]836
[204]837                if self.notification and not spam:
[253]838                        self.notify(tkt, False, when)
[72]839
[71]840                return True
841
[202]842        def set_ticket_fields(self, ticket):
[77]843                """
[202]844                set the ticket fields to value specified
845                        - /etc/email2trac.conf with <prefix>_<field>
846                        - trac default values, trac.ini
847                """
848                user_dict = dict()
849
850                for field in ticket.fields:
851
852                        name = field['name']
853
[335]854                        ## default trac value
[202]855                        #
[233]856                        if not field.get('custom'):
857                                value = self.get_config('ticket', 'default_%s' %(name) )
858                        else:
[345]859                                ##  Else we get the default value for reporter
860                                #
[233]861                                value = field.get('value')
862                                options = field.get('options')
[345]863
[335]864                                if value and options and (value not in options):
[345]865                                         value = options[int(value)]
866       
[202]867                        if self.DEBUG > 10:
868                                print 'trac.ini name %s = %s' %(name, value)
869
[335]870                        ## email2trac.conf settings
871                        #
[206]872                        prefix = self.parameters['ticket_prefix']
[202]873                        try:
[206]874                                value = self.parameters['%s_%s' %(prefix, name)]
[202]875                                if self.DEBUG > 10:
876                                        print 'email2trac.conf %s = %s ' %(name, value)
877
878                        except KeyError, detail:
879                                pass
880               
881                        if self.DEBUG:
882                                print 'user_dict[%s] = %s' %(name, value)
883
[345]884                        if value:
885                                user_dict[name] = value
[202]886
887                self.update_ticket_fields(ticket, user_dict, use_default=1)
888
[352]889                if 'status' not in user_dict.keys():
890                        ticket['status'] = 'new'
[202]891
892
[356]893        def ticket_update_by_subject(self, subject):
894                """
895                This list of Re: prefixes is probably incomplete. Taken from
896                wikipedia. Here is how the subject is matched
897                  - Re: <subject>
898                  - Re: (<Mail list label>:)+ <subject>
[202]899
[356]900                So we must have the last column
901                """
902                if self.VERBOSE:
903                        print "VB: ticket_update_by_subject()"
904
905                matched_id = None
906                if self.TICKET_UPDATE and self.TICKET_UPDATE_BY_SUBJECT:
907                               
908                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
909                        result = SUBJECT_RE.search(subject)
910
911                        if result:
912                                # This is a reply
913                                orig_subject = result.group(3)
914
915                                if self.DEBUG:
916                                        print 'TD: subject search string: %s' %(orig_subject)
917
918                                cursor = self.db.cursor()
919                                summaries = [orig_subject, '%%: %s' % orig_subject]
920
921                                # hard-code the time to 30 days for now. Can easily be
922                                # made configurable
923                                lookback = int(time.mktime(time.gmtime())) - 2592000
924
925                                for summary in summaries:
926                                        if self.DEBUG:
927                                                print 'TD: Looking for summary matching: "%s"' % summary
928                                        sql = """SELECT id FROM ticket
929                                                        WHERE changetime >= %s AND summary LIKE %s
930                                                        ORDER BY changetime DESC"""
931                                        cursor.execute(sql, [lookback, summary.strip()])
932
933                                        for row in cursor:
934                                                (matched_id,) = row
935                                                if self.DEBUG:
936                                                        print 'TD: Found matching ticket id: %d' % matched_id
937                                                break
938
939                                        if matched_id:
940                                                matched_id = '#%d:' % matched_id
941                                                return matched_id
942
943                return matched_id
944
945
[262]946        def new_ticket(self, msg, subject, spam, set_fields = None):
[202]947                """
[77]948                Create a new ticket
949                """
[356]950                if self.VERBOSE:
951                        print "VB: function new_ticket()"
[250]952
[41]953                tkt = Ticket(self.env)
[326]954
955                self.set_reply_fields(tkt, msg)
956
[202]957                self.set_ticket_fields(tkt)
958
959                # Old style setting for component, will be removed
960                #
[204]961                if spam:
962                        tkt['component'] = 'Spam'
963
[206]964                elif self.parameters.has_key('component'):
965                        tkt['component'] = self.parameters['component']
[201]966
[22]967                if not msg['Subject']:
[151]968                        tkt['summary'] = u'(No subject)'
[22]969                else:
[264]970                        tkt['summary'] = subject
[22]971
972
[262]973                if set_fields:
974                        rest, keywords = string.split(set_fields, '?')
975
976                        if keywords:
977                                update_fields = self.str_to_dict(keywords)
978                                self.update_ticket_fields(tkt, update_fields)
979
[45]980                # produce e-mail like header
981                #
[22]982                head = ''
983                if self.EMAIL_HEADER > 0:
984                        head = self.email_header_txt(msg)
[296]985
[236]986                message_parts = self.get_message_parts(msg)
[309]987
988                # Must we update some ticket fields properties via body_text
989                #
990                if self.properties:
991                                self.update_ticket_fields(tkt, self.properties)
992
[296]993                if self.DEBUG:
994                        print 'TD: self.get_message_parts ',
995                        print message_parts
996
[236]997                message_parts = self.unique_attachment_names(message_parts)
[296]998                if self.DEBUG:
999                        print 'TD: self.unique_attachment_names',
1000                        print message_parts
[236]1001               
1002                if self.EMAIL_HEADER > 0:
1003                        message_parts.insert(0, self.email_header_txt(msg))
1004                       
1005                body_text = self.body_text(message_parts)
[45]1006
[236]1007                tkt['description'] = body_text
[90]1008
[182]1009                #when = int(time.time())
[192]1010                #
[182]1011                utc = UTC()
1012                when = datetime.now(utc)
[45]1013
[253]1014                if not self.DRY_RUN:
1015                        self.id = tkt.insert()
[273]1016       
[90]1017                changed = False
1018                comment = ''
[77]1019
[273]1020                # some routines in trac are dependend on ticket id     
1021                # like alternate notify template
1022                #
1023                if self.notify_template:
[274]1024                        tkt['id'] = self.id
[273]1025                        changed = True
1026
[295]1027                ## Rewrite the description if we have mailto enabled
[45]1028                #
[72]1029                if self.MAILTO:
[100]1030                        changed = True
[142]1031                        comment = u'\nadded mailto line\n'
[343]1032                        mailto = self.html_mailto_link( m['Subject'])
[253]1033
[213]1034                        tkt['description'] = u'%s\r\n%s%s\r\n' \
[142]1035                                %(head, mailto, body_text)
[295]1036       
1037                ## Save the attachments to the ticket   
1038                #
[340]1039                error_with_attachments =  self.attach_attachments(message_parts)
[295]1040
[319]1041                if error_with_attachments:
1042                        changed = True
1043                        comment = '%s\n%s\n' %(comment, error_with_attachments)
[45]1044
[90]1045                if changed:
[204]1046                        if self.DRY_RUN:
[344]1047                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
[201]1048                        else:
[344]1049                                tkt.save_changes(tkt['reporter'], comment)
[201]1050                                #print tkt.get_changelog(self.db, when)
[90]1051
[250]1052                if self.notification and not spam:
[253]1053                        self.notify(tkt, True)
[45]1054
[260]1055
[342]1056        def attach_attachments(self, message_parts, update=False):
1057                '''
1058                save any attachments as files in the ticket's directory
1059                '''
1060                if self.VERBOSE:
1061                        print "VB: attach_attachments()"
1062
1063                if self.DRY_RUN:
1064                        print "DRY_RUN: no attachments attached to tickets"
1065                        return ''
1066
1067                count = 0
1068
1069                # Get Maxium attachment size
1070                #
1071                max_size = int(self.get_config('attachment', 'max_size'))
1072                status   = None
1073               
1074                for item in message_parts:
1075                        # Skip body parts
1076                        if not isinstance(item, tuple):
1077                                continue
1078                               
1079                        (original, filename, part) = item
1080                        #
1081                        # Must be tuneables HvB
1082                        #
1083                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, filename))
1084                        text = part.get_payload(decode=1)
1085                        if not text:
1086                                text = '(None)'
1087                        fd.write(text)
1088                        fd.close()
1089
1090                        # get the file_size
1091                        #
1092                        stats = os.lstat(path)
1093                        file_size = stats[stat.ST_SIZE]
1094
1095                        # Check if the attachment size is allowed
1096                        #
1097                        if (max_size != -1) and (file_size > max_size):
1098                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1099                                        %(status, original, file_size, max_size)
1100
1101                                os.unlink(path)
1102                                continue
1103                        else:
1104                                count = count + 1
1105                                       
1106                        # Insert the attachment
1107                        #
1108                        fd = open(path, 'rb')
[359]1109                        if self.system == 'discussion':
1110                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1111                                  % (self.id,))
1112                        else:
1113                                att = attachment.Attachment(self.env, 'ticket', self.id)
1114 
[342]1115                        # This will break the ticket_update system, the body_text is vaporized
1116                        # ;-(
1117                        #
1118                        if not update:
1119                                att.author = self.author
1120                                att.description = self.email_to_unicode('Added by email2trac')
1121
[348]1122                        try:
1123                                att.insert(filename, fd, file_size)
1124                        except OSError, detail:
1125                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
[342]1126
1127                        # Remove the created temporary filename
1128                        #
1129                        fd.close()
1130                        os.unlink(path)
1131
1132                ## return error
1133                #
1134                return status
1135
[359]1136########## Fullblog functions  #################################################
[339]1137
[260]1138        def blog(self, id):
1139                """
1140                The blog create/update function
1141                """
1142                # import the modules
1143                #
1144                from tracfullblog.core import FullBlogCore
[312]1145                from tracfullblog.model import BlogPost, BlogComment
1146                from trac.test import Mock, MockPerm
[260]1147
1148                # instantiate blog core
1149                blog = FullBlogCore(self.env)
[312]1150                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1151
[260]1152                if id:
1153
1154                        # update blog
1155                        #
[268]1156                        comment = BlogComment(self.env, id)
[260]1157                        comment.author = self.author
[312]1158
1159                        message_parts = self.get_message_parts(m)
1160                        comment.comment = self.body_text(message_parts)
1161
[260]1162                        blog.create_comment(req, comment)
1163
1164                else:
1165                        # create blog
1166                        #
1167                        import time
1168                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1169
1170                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1171                       
1172                        post.author = self.author
1173                        post.title = self.email_to_unicode(m['Subject'])
[312]1174
1175                        message_parts = self.get_message_parts(m)
1176                        post.body = self.body_text(message_parts)
[260]1177                       
1178                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1179
1180
[359]1181########## Discussion functions  ##############################################
[342]1182
[359]1183        def discussion_topic(self, content, subject):
[342]1184
[359]1185                # Import modules.
1186                from tracdiscussion.api import DiscussionApi
1187                from trac.util.datefmt import to_timestamp, utc
1188
1189                if self.DEBUG:
1190                        print 'TD: Creating a new topic in forum:', self.id
1191
1192                # Get dissussion API component.
1193                api = self.env[DiscussionApi]
1194                context = self._create_context(content, subject)
1195
1196                # Get forum for new topic.
1197                forum = api.get_forum(context, self.id)
1198
1199                if not forum and self.DEBUG:
1200                        print 'ERROR: Replied forum doesn\'t exist'
1201
1202                # Prepare topic.
1203                topic = {'forum' : forum['id'],
1204                                 'subject' : context.subject,
1205                                 'time': to_timestamp(datetime.now(utc)),
1206                                 'author' : self.author,
1207                                 'subscribers' : [self.email_addr],
1208                                 'body' : self.body_text(context.content_parts)}
1209
1210                # Add topic to DB and commit it.
1211                self._add_topic(api, context, topic)
1212                self.db.commit()
1213
1214        def discussion_topic_reply(self, content, subject):
1215
1216                # Import modules.
1217                from tracdiscussion.api import DiscussionApi
1218                from trac.util.datefmt import to_timestamp, utc
1219
1220                if self.DEBUG:
1221                        print 'TD: Replying to discussion topic', self.id
1222
1223                # Get dissussion API component.
1224                api = self.env[DiscussionApi]
1225                context = self._create_context(content, subject)
1226
1227                # Get replied topic.
1228                topic = api.get_topic(context, self.id)
1229
1230                if not topic and self.DEBUG:
1231                        print 'ERROR: Replied topic doesn\'t exist'
1232
1233                # Prepare message.
1234                message = {'forum' : topic['forum'],
1235                                   'topic' : topic['id'],
1236                                   'replyto' : -1,
1237                                   'time' : to_timestamp(datetime.now(utc)),
1238                                   'author' : self.author,
1239                                   'body' : self.body_text(context.content_parts)}
1240
1241                # Add message to DB and commit it.
1242                self._add_message(api, context, message)
1243                self.db.commit()
1244
1245        def discussion_message_reply(self, content, subject):
1246
1247                # Import modules.
1248                from tracdiscussion.api import DiscussionApi
1249                from trac.util.datefmt import to_timestamp, utc
1250
1251                if self.DEBUG:
1252                        print 'TD: Replying to discussion message', self.id
1253
1254                # Get dissussion API component.
1255                api = self.env[DiscussionApi]
1256                context = self._create_context(content, subject)
1257
1258                # Get replied message.
1259                message = api.get_message(context, self.id)
1260
1261                if not message and self.DEBUG:
1262                        print 'ERROR: Replied message doesn\'t exist'
1263
1264                # Prepare message.
1265                message = {'forum' : message['forum'],
1266                                   'topic' : message['topic'],
1267                                   'replyto' : message['id'],
1268                                   'time' : to_timestamp(datetime.now(utc)),
1269                                   'author' : self.author,
1270                                   'body' : self.body_text(context.content_parts)}
1271
1272                # Add message to DB and commit it.
1273                self._add_message(api, context, message)
1274                self.db.commit()
1275
1276        def _create_context(self, content, subject):
1277
1278                # Import modules.
1279                from trac.mimeview import Context
1280                from trac.web.api import Request
1281                from trac.perm import PermissionCache
1282
1283                # TODO: Read server base URL from config.
1284                # Create request object to mockup context creation.
1285                #
1286                environ = {'SERVER_PORT' : 80,
1287                                   'SERVER_NAME' : 'test',
1288                                   'REQUEST_METHOD' : 'POST',
1289                                   'wsgi.url_scheme' : 'http',
1290                                   'wsgi.input' : sys.stdin}
1291                chrome =  {'links': {},
1292                                   'scripts': [],
1293                                   'ctxtnav': [],
1294                                   'warnings': [],
1295                                   'notices': []}
1296
1297                if self.env.base_url_for_redirect:
1298                        environ['trac.base_url'] = self.env.base_url
1299
1300                req = Request(environ, None)
1301                req.chrome = chrome
1302                req.tz = 'missing'
1303                req.authname = self.author
1304                req.perm = PermissionCache(self.env, self.author)
1305
1306                # Create and return context.
1307                context = Context.from_request(req)
1308                context.realm = 'discussion-email2trac'
1309                context.cursor = self.db.cursor()
1310                context.content = content
1311                context.subject = subject
1312
1313                # Read content parts from content.
1314                context.content_parts = self.get_message_parts(content)
1315                context.content_parts = self.unique_attachment_names(
1316                  context.content_parts)
1317
1318                return context
1319
1320        def _add_topic(self, api, context, topic):
1321                context.req.perm.assert_permission('DISCUSSION_APPEND')
1322
1323                # Filter topic.
1324                for discussion_filter in api.discussion_filters:
1325                        accept, topic_or_error = discussion_filter.filter_topic(
1326                          context, topic)
1327                        if accept:
1328                                topic = topic_or_error
1329                        else:
1330                                raise TracError(topic_or_error)
1331
1332                # Add a new topic.
1333                api.add_topic(context, topic)
1334
1335                # Get inserted topic with new ID.
1336                topic = api.get_topic_by_time(context, topic['time'])
1337
1338                # Attach attachments.
1339                self.id = topic['id']
1340                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1341
1342                # Notify change listeners.
1343                for listener in api.topic_change_listeners:
1344                        listener.topic_created(context, topic)
1345
1346        def _add_message(self, api, context, message):
1347                context.req.perm.assert_permission('DISCUSSION_APPEND')
1348
1349                # Filter message.
1350                for discussion_filter in api.discussion_filters:
1351                        accept, message_or_error = discussion_filter.filter_message(
1352                          context, message)
1353                        if accept:
1354                                message = message_or_error
1355                        else:
1356                                raise TracError(message_or_error)
1357
1358                # Add message.
1359                api.add_message(context, message)
1360
1361                # Get inserted message with new ID.
1362                message = api.get_message_by_time(context, message['time'])
1363
1364                # Attach attachments.
1365                self.id = message['topic']
1366                self.attach_attachments(context.content_parts, self.VERSION == 0.9)
1367
1368                # Notify change listeners.
1369                for listener in api.message_change_listeners:
1370                        listener.message_created(context, message)
1371
1372########## MAIN function  ######################################################
1373
[77]1374        def parse(self, fp):
[356]1375                """
1376                """
1377                if self.VERBOSE:
1378                        print "VB: main function parse()"
[96]1379                global m
1380
[77]1381                m = email.message_from_file(fp)
[239]1382               
[77]1383                if not m:
[221]1384                        if self.DEBUG:
[250]1385                                print "TD: This is not a valid email message format"
[77]1386                        return
[239]1387                       
1388                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
[316]1389                try:
1390                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1391                except AttributeError, detail:
1392                        pass
[239]1393
[77]1394                if self.DEBUG > 1:        # save the entire e-mail message text
[219]1395                        self.save_email_for_debug(m, True)
[77]1396
1397                self.db = self.env.get_db_cnx()
[194]1398                self.get_sender_info(m)
[152]1399
[221]1400                if not self.email_header_acl('white_list', self.email_addr, True):
1401                        if self.DEBUG > 1 :
1402                                print 'Message rejected : %s not in white list' %(self.email_addr)
1403                        return False
[77]1404
[221]1405                if self.email_header_acl('black_list', self.email_addr, False):
1406                        if self.DEBUG > 1 :
1407                                print 'Message rejected : %s in black list' %(self.email_addr)
1408                        return False
1409
[227]1410                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
[226]1411                        if self.DEBUG > 1 :
1412                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
1413                        return False
1414
[204]1415                # If drop the message
[194]1416                #
[204]1417                if self.spam(m) == 'drop':
[194]1418                        return False
1419
[204]1420                elif self.spam(m) == 'spam':
1421                        spam_msg = True
1422                else:
1423                        spam_msg = False
1424
[77]1425                if self.get_config('notification', 'smtp_enabled') in ['true']:
1426                        self.notification = 1
1427                else:
1428                        self.notification = 0
1429
[359]1430                if not m['Subject']:
1431                        subject  = 'No Subject'
1432                else:
1433                        subject  = self.email_to_unicode(m['Subject'])
[304]1434
[359]1435                if self.DEBUG:
1436                         print "TD:", subject
1437
1438                #
1439                # [hic] #1529: Re: LRZ
1440                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1441                #
1442                ticket_regex = r'''
1443                        (?P<new_fields>[#][?].*)
1444                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*?:)*)
1445                        '''
[260]1446                # Check if  FullBlogPlugin is installed
[77]1447                #
[260]1448                blog_enabled = None
[359]1449                blog_regex = ''
[260]1450                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1451                        blog_enabled = True
[359]1452                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
[329]1453
[77]1454
[359]1455                # Check if DiscussionPlugin is installed
[260]1456                #
[359]1457                discussion_enabled = None
1458                discussion_regex = ''
1459                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1460                        discussion_enabled = True
1461                        discussion_regex = r'''
1462                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1463                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1464                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1465                        '''
[77]1466
[359]1467
1468                regex_str = ticket_regex + blog_regex + discussion_regex
1469                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1470
1471                # Find out if this is a ticket, a blog or a discussion
[265]1472                #
[359]1473                result =  SYSTEM_RE.search(subject)
[260]1474                if result:
1475                        # update ticket + fields
1476                        #
[359]1477                        if result.group('reply') and self.TICKET_UPDATE:
1478                                self.system = 'ticket'
[262]1479                                self.ticket_update(m, result.group('reply'), spam_msg)
[260]1480
[262]1481                        # New ticket + fields
1482                        #
1483                        elif result.group('new_fields'):
[359]1484                                self.system = 'ticket'
[262]1485                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1486
[359]1487                        if blog_enabled:
1488                                if result.group('blog'):
1489                                        self.system = 'blog'
1490                                        self.blog(result.group('blog_id'))
1491
1492                        if discussion_enabled:
1493                                # New topic.
1494                                #
1495                                if result.group('forum'):
1496                                        self.system = 'discussion'
1497                                        self.id = int(result.group('forum_id'))
1498                                        self.discussion_topic(m, subject[result.end('forum'):])
1499
1500                                # Reply to topic.
1501                                #
1502                                elif result.group('topic'):
1503                                        self.system = 'discussion'
1504                                        self.id = int(result.group('topic_id'))
1505                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1506
1507                                # Reply to topic message.
1508                                #
1509                                elif result.group('message'):
1510                                        self.system = 'discussion'
1511                                        self.id = int(result.group('message_id'))
1512                                        self.discussion_message_reply(m, subject[result.end('message'):])
1513
[260]1514                else:
[359]1515                        self.system = 'ticket'
[356]1516                        result = self.ticket_update_by_subject(subject)
1517                        if result:
1518                                self.ticket_update(m, result, spam_msg)
[353]1519                        else:
1520                                # No update by subject, so just create a new ticket
1521                                self.new_ticket(m, subject, spam_msg)
1522
[356]1523
[343]1524########## BODY TEXT functions  ###########################################################
1525
[136]1526        def strip_signature(self, text):
1527                """
1528                Strip signature from message, inspired by Mailman software
1529                """
1530                body = []
1531                for line in text.splitlines():
1532                        if line == '-- ':
1533                                break
1534                        body.append(line)
1535
1536                return ('\n'.join(body))
1537
[231]1538        def reflow(self, text, delsp = 0):
1539                """
1540                Reflow the message based on the format="flowed" specification (RFC 3676)
1541                """
1542                flowedlines = []
1543                quotelevel = 0
1544                prevflowed = 0
1545
1546                for line in text.splitlines():
1547                        from re import match
1548                       
1549                        # Figure out the quote level and the content of the current line
1550                        m = match('(>*)( ?)(.*)', line)
1551                        linequotelevel = len(m.group(1))
1552                        line = m.group(3)
1553
1554                        # Determine whether this line is flowed
1555                        if line and line != '-- ' and line[-1] == ' ':
1556                                flowed = 1
1557                        else:
1558                                flowed = 0
1559
1560                        if flowed and delsp and line and line[-1] == ' ':
1561                                line = line[:-1]
1562
1563                        # If the previous line is flowed, append this line to it
1564                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1565                                flowedlines[-1] += line
1566                        # Otherwise, start a new line
1567                        else:
1568                                flowedlines.append('>' * linequotelevel + line)
1569
1570                        prevflowed = flowed
1571                       
1572
1573                return '\n'.join(flowedlines)
1574
[191]1575        def strip_quotes(self, text):
[193]1576                """
1577                Strip quotes from message by Nicolas Mendoza
1578                """
1579                body = []
1580                for line in text.splitlines():
1581                        if line.startswith(self.EMAIL_QUOTE):
1582                                continue
1583                        body.append(line)
[151]1584
[193]1585                return ('\n'.join(body))
[191]1586
[309]1587        def inline_properties(self, text):
1588                """
1589                Parse text if we use inline keywords to set ticket fields
1590                """
1591                if self.DEBUG:
1592                        print 'TD: inline_properties function'
1593
1594                properties = dict()
1595                body = list()
1596
1597                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1598
1599                for line in text.splitlines():
1600                        match = INLINE_EXP.match(line)
1601                        if match:
1602                                keyword, value = match.groups()
1603                                self.properties[keyword] = value.strip()
[311]1604                                if self.DEBUG:
1605                                        print "TD: inline properties: %s : %s" %(keyword,value)
[309]1606                        else:
1607                                body.append(line)
1608                               
1609                return '\n'.join(body)
1610
1611
[154]1612        def wrap_text(self, text, replace_whitespace = False):
[151]1613                """
[191]1614                Will break a lines longer then given length into several small
1615                lines of size given length
[151]1616                """
1617                import textwrap
[154]1618
[151]1619                LINESEPARATOR = '\n'
[153]1620                reformat = ''
[151]1621
[154]1622                for s in text.split(LINESEPARATOR):
1623                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
1624                        if tmp:
1625                                reformat = '%s\n%s' %(reformat,tmp)
1626                        else:
1627                                reformat = '%s\n' %reformat
[153]1628
1629                return reformat
1630
[154]1631                # Python2.4 and higher
1632                #
1633                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1634                #
1635
[343]1636########## EMAIL attachements functions ###########################################################
1637
[340]1638        def inline_part(self, part):
1639                """
1640                """
1641                if self.VERBOSE:
1642                        print "VB: inline_part()"
[154]1643
[340]1644                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1645
[236]1646        def get_message_parts(self, msg):
[45]1647                """
[236]1648                parses the email message and returns a list of body parts and attachments
1649                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
[45]1650                """
[317]1651                if self.VERBOSE:
1652                        print "VB: get_message_parts()"
1653
[309]1654                message_parts = list()
[294]1655       
[278]1656                ALTERNATIVE_MULTIPART = False
1657
[22]1658                for part in msg.walk():
[236]1659                        if self.DEBUG:
[278]1660                                print 'TD: Message part: Main-Type: %s' % part.get_content_maintype()
[236]1661                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
[278]1662
1663                        ## Check content type
[294]1664                        #
1665                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
[238]1666
[294]1667                                if self.DEBUG:
1668                                        print "TD: A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename())
[238]1669
1670                                continue
1671
[294]1672                        ## Catch some mulitpart execptions
1673                        #
1674                        if part.get_content_type() == 'multipart/alternative':
[278]1675                                ALTERNATIVE_MULTIPART = True
1676                                continue
1677
[294]1678                        ## Skip multipart containers
[278]1679                        #
[45]1680                        if part.get_content_maintype() == 'multipart':
[278]1681                                if self.DEBUG:
1682                                        print "TD: Skipping multipart container"
[22]1683                                continue
[278]1684                       
[294]1685                        ## Check if this is an inline part. It's inline if there is co Cont-Disp header, or if there is one and it says "inline"
1686                        #
[236]1687                        inline = self.inline_part(part)
1688
[294]1689                        ## Drop HTML message
1690                        #
[278]1691                        if ALTERNATIVE_MULTIPART and self.DROP_ALTERNATIVE_HTML_VERSION:
1692                                if part.get_content_type() == 'text/html':
1693                                        if self.DEBUG:
1694                                                print "TD: Skipping alternative HTML message"
1695
1696                                        ALTERNATIVE_MULTIPART = False
1697                                        continue
1698
[294]1699                        ## Inline text parts are where the body is
1700                        #
[236]1701                        if part.get_content_type() == 'text/plain' and inline:
1702                                if self.DEBUG:
1703                                        print 'TD:               Inline body part'
1704
[45]1705                                # Try to decode, if fails then do not decode
1706                                #
[90]1707                                body_text = part.get_payload(decode=1)
[45]1708                                if not body_text:                       
[90]1709                                        body_text = part.get_payload(decode=0)
[231]1710
[232]1711                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1712                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
[231]1713
1714                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1715                                        body_text = self.reflow(body_text, delsp == 'yes')
[154]1716       
[136]1717                                if self.STRIP_SIGNATURE:
1718                                        body_text = self.strip_signature(body_text)
[22]1719
[191]1720                                if self.STRIP_QUOTES:
1721                                        body_text = self.strip_quotes(body_text)
1722
[309]1723                                if self.INLINE_PROPERTIES:
1724                                        body_text = self.inline_properties(body_text)
1725
[148]1726                                if self.USE_TEXTWRAP:
[151]1727                                        body_text = self.wrap_text(body_text)
[148]1728
[294]1729                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
[45]1730                                #
[100]1731                                charset = part.get_content_charset()
[102]1732                                if not charset:
1733                                        charset = 'iso-8859-15'
1734
[89]1735                                try:
[96]1736                                        ubody_text = unicode(body_text, charset)
[100]1737
1738                                except UnicodeError, detail:
[96]1739                                        ubody_text = unicode(body_text, 'iso-8859-15')
[89]1740
[100]1741                                except LookupError, detail:
[139]1742                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
[100]1743
[236]1744                                if self.VERBATIM_FORMAT:
1745                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1746                                else:
1747                                        message_parts.append('%s' %ubody_text)
1748                        else:
1749                                if self.DEBUG:
[315]1750                                        try:
1751                                                print 'TD:               Filename: %s' % part.get_filename()
1752                                        except UnicodeEncodeError, detail:
[331]1753                                                print 'TD:               Filename: Can not be printed due to non-ascii characters'
[22]1754
[317]1755                                ## Convert 7-bit filename to 8 bits value
1756                                #
1757                                filename = self.email_to_unicode(part.get_filename())
1758                                message_parts.append((filename, part))
[236]1759
1760                return message_parts
1761               
[253]1762        def unique_attachment_names(self, message_parts):
[296]1763                """
1764                """
[236]1765                renamed_parts = []
1766                attachment_names = set()
[296]1767
[331]1768                for item in message_parts:
[236]1769                       
[296]1770                        ## If not an attachment, leave it alone
1771                        #
[331]1772                        if not isinstance(item, tuple):
1773                                renamed_parts.append(item)
[236]1774                                continue
1775                               
[331]1776                        (filename, part) = item
[295]1777
[296]1778                        ## If no filename, use a default one
1779                        #
1780                        if not filename:
[236]1781                                filename = 'untitled-part'
[22]1782
[242]1783                                # Guess the extension from the content type, use non strict mode
1784                                # some additional non-standard but commonly used MIME types
1785                                # are also recognized
1786                                #
1787                                ext = mimetypes.guess_extension(part.get_content_type(), False)
[236]1788                                if not ext:
1789                                        ext = '.bin'
[22]1790
[236]1791                                filename = '%s%s' % (filename, ext)
[22]1792
[348]1793                        ## Discard relative paths for windows/unix in attachment names
[296]1794                        #
[348]1795                        #filename = filename.replace('\\', '/').replace(':', '/')
1796                        filename = filename.replace('\\', '_')
1797                        filename = filename.replace('/', '_')
[347]1798
[296]1799                        #
[236]1800                        # We try to normalize the filename to utf-8 NFC if we can.
1801                        # Files uploaded from OS X might be in NFD.
1802                        # Check python version and then try it
1803                        #
[348]1804                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1805                        #       try:
1806                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1807                        #       except TypeError:
1808                        #               pass
[100]1809
[236]1810                        # Make the filename unique for this ticket
1811                        num = 0
1812                        unique_filename = filename
[296]1813                        dummy_filename, ext = os.path.splitext(filename)
[134]1814
[339]1815                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
[236]1816                                num += 1
[296]1817                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
[236]1818                               
1819                        if self.DEBUG:
[331]1820                                try:
1821                                        print 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1822                                except UnicodeEncodeError, detail:
1823                                        print 'Filename can not be printed due to non-ascii characters'
[100]1824
[236]1825                        attachment_names.add(unique_filename)
1826
1827                        renamed_parts.append((filename, unique_filename, part))
[296]1828       
[236]1829                return renamed_parts
1830                       
1831                       
[253]1832        def attachment_exists(self, filename):
[250]1833
1834                if self.DEBUG:
[359]1835                        s = 'TD: attachment already exists: Id : '
[331]1836                        try:
[359]1837                                print "%s%s, Filename : %s" % (s, self.id, filename)
[331]1838                        except UnicodeEncodeError, detail:
1839                                print "%s%s, Filename : Can not be printed due to non-ascii characters" %(s, self.id)
[250]1840
1841                # We have no valid ticket id
1842                #
[253]1843                if not self.id:
[236]1844                        return False
[250]1845
[236]1846                try:
[359]1847                        if self.system == 'discussion':
1848                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1849                                  % (self.id,), filename)
1850                        else:
1851                                att = attachment.Attachment(self.env, 'ticket', self.id,
1852                                  filename)
[236]1853                        return True
[250]1854                except attachment.ResourceNotFound:
[236]1855                        return False
[343]1856
1857########## TRAC Ticket Text ###########################################################
[236]1858                       
1859        def body_text(self, message_parts):
1860                body_text = []
1861               
1862                for part in message_parts:
1863                        # Plain text part, append it
1864                        if not isinstance(part, tuple):
1865                                body_text.extend(part.strip().splitlines())
1866                                body_text.append("")
1867                                continue
1868                               
1869                        (original, filename, part) = part
1870                        inline = self.inline_part(part)
1871                       
1872                        if part.get_content_maintype() == 'image' and inline:
[359]1873                                if self.system != 'discussion':
1874                                        body_text.append('[[Image(%s)]]' % filename)
[236]1875                                body_text.append("")
1876                        else:
[359]1877                                if self.system != 'discussion':
1878                                        body_text.append('[attachment:"%s"]' % filename)
[236]1879                                body_text.append("")
1880                               
1881                body_text = '\r\n'.join(body_text)
1882                return body_text
1883
[343]1884        def html_mailto_link(self, subject):
1885                """
1886                This function returns a HTML mailto tag with the ticket id and author email address
1887                """
1888                if not self.author:
1889                        author = self.email_addr
1890                else:   
1891                        author = self.author
1892
1893                # use urllib to escape the chars
1894                #
1895                s = 'mailto:%s?Subject=%s&Cc=%s' %(
1896                       urllib.quote(self.email_addr),
1897                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
1898                           urllib.quote(self.MAILTO_CC)
1899                           )
1900
1901                s = '\r\n{{{\r\n#!html\r\n<a\r\n href="%s">Reply to: %s\r\n</a>\r\n}}}\r\n' %(s, author)
1902                return s
1903
1904########## TRAC notify section ###########################################################
1905
[253]1906        def notify(self, tkt, new=True, modtime=0):
[79]1907                """
1908                A wrapper for the TRAC notify function. So we can use templates
1909                """
[250]1910                if self.DRY_RUN:
[344]1911                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
[250]1912                                return
[41]1913                try:
1914                        # create false {abs_}href properties, to trick Notify()
1915                        #
[193]1916                        if not self.VERSION == 0.11:
[192]1917                                self.env.abs_href = Href(self.get_config('project', 'url'))
1918                                self.env.href = Href(self.get_config('project', 'url'))
[22]1919
[41]1920                        tn = TicketNotifyEmail(self.env)
[213]1921
[42]1922                        if self.notify_template:
[222]1923
[221]1924                                if self.VERSION == 0.11:
[222]1925
[221]1926                                        from trac.web.chrome import Chrome
[222]1927
1928                                        if self.notify_template_update and not new:
1929                                                tn.template_name = self.notify_template_update
1930                                        else:
1931                                                tn.template_name = self.notify_template
1932
[221]1933                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
1934                                               
1935                                else:
[222]1936
[221]1937                                        tn.template_name = self.notify_template;
[42]1938
[77]1939                        tn.notify(tkt, new, modtime)
[41]1940
1941                except Exception, e:
[253]1942                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
[41]1943
[22]1944
[74]1945
[343]1946########## Parse Config File  ###########################################################
[22]1947
1948def ReadConfig(file, name):
1949        """
1950        Parse the config file
1951        """
1952        if not os.path.isfile(file):
[79]1953                print 'File %s does not exist' %file
[22]1954                sys.exit(1)
1955
[199]1956        config = trac_config.Configuration(file)
[22]1957
1958        # Use given project name else use defaults
1959        #
1960        if name:
[199]1961                sections = config.sections()
1962                if not name in sections:
[79]1963                        print "Not a valid project name: %s" %name
[199]1964                        print "Valid names: %s" %sections
[22]1965                        sys.exit(1)
1966
1967                project =  dict()
[199]1968                for option, value in  config.options(name):
1969                        project[option] = value
[22]1970
1971        else:
[270]1972                # use some trac internals to get the defaults
[217]1973                #
1974                project = config.parser.defaults()
[22]1975
1976        return project
1977
[87]1978
[22]1979if __name__ == '__main__':
1980        # Default config file
1981        #
[24]1982        configfile = '@email2trac_conf@'
[22]1983        project = ''
1984        component = ''
[202]1985        ticket_prefix = 'default'
[204]1986        dry_run = None
[317]1987        verbose = None
[202]1988
[87]1989        ENABLE_SYSLOG = 0
[201]1990
[317]1991        SHORT_OPT = 'chf:np:t:v'
1992        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
[201]1993
[22]1994        try:
[201]1995                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
[22]1996        except getopt.error,detail:
1997                print __doc__
1998                print detail
1999                sys.exit(1)
[87]2000       
[22]2001        project_name = None
2002        for opt,value in opts:
2003                if opt in [ '-h', '--help']:
2004                        print __doc__
2005                        sys.exit(0)
2006                elif opt in ['-c', '--component']:
2007                        component = value
2008                elif opt in ['-f', '--file']:
2009                        configfile = value
[201]2010                elif opt in ['-n', '--dry-run']:
[204]2011                        dry_run = True
[22]2012                elif opt in ['-p', '--project']:
2013                        project_name = value
[202]2014                elif opt in ['-t', '--ticket_prefix']:
2015                        ticket_prefix = value
[317]2016                elif opt in ['-v', '--version']:
2017                        verbose = True
[87]2018       
[22]2019        settings = ReadConfig(configfile, project_name)
2020        if not settings.has_key('project'):
2021                print __doc__
[79]2022                print 'No Trac project is defined in the email2trac config file.'
[22]2023                sys.exit(1)
[87]2024       
[22]2025        if component:
2026                settings['component'] = component
[202]2027
2028        # The default prefix for ticket values in email2trac.conf
2029        #
2030        settings['ticket_prefix'] = ticket_prefix
[206]2031        settings['dry_run'] = dry_run
[317]2032        settings['verbose'] = verbose
[87]2033       
[22]2034        if settings.has_key('trac_version'):
[189]2035                version = settings['trac_version']
[22]2036        else:
2037                version = trac_default_version
2038
[189]2039
[22]2040        #debug HvB
2041        #print settings
[189]2042
[87]2043        try:
[189]2044                if version == '0.9':
[87]2045                        from trac import attachment
2046                        from trac.env import Environment
2047                        from trac.ticket import Ticket
2048                        from trac.web.href import Href
2049                        from trac import util
2050                        from trac.Notify import TicketNotifyEmail
[189]2051                elif version == '0.10':
[87]2052                        from trac import attachment
2053                        from trac.env import Environment
2054                        from trac.ticket import Ticket
2055                        from trac.web.href import Href
2056                        from trac import util
[139]2057                        #
2058                        # return  util.text.to_unicode(str)
2059                        #
[87]2060                        # see http://projects.edgewall.com/trac/changeset/2799
2061                        from trac.ticket.notification import TicketNotifyEmail
[199]2062                        from trac import config as trac_config
[359]2063                        from trac.core import TracError
2064
[189]2065                elif version == '0.11':
[182]2066                        from trac import attachment
2067                        from trac.env import Environment
2068                        from trac.ticket import Ticket
2069                        from trac.web.href import Href
[199]2070                        from trac import config as trac_config
[182]2071                        from trac import util
[359]2072                        from trac.core import TracError
[260]2073
2074
[182]2075                        #
2076                        # return  util.text.to_unicode(str)
2077                        #
2078                        # see http://projects.edgewall.com/trac/changeset/2799
2079                        from trac.ticket.notification import TicketNotifyEmail
[189]2080                else:
2081                        print 'TRAC version %s is not supported' %version
2082                        sys.exit(1)
2083                       
2084                if settings.has_key('enable_syslog'):
[190]2085                        if SYSLOG_AVAILABLE:
2086                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
[182]2087
[291]2088
2089                # Must be set before environment is created
2090                #
2091                if settings.has_key('python_egg_cache'):
2092                        python_egg_cache = str(settings['python_egg_cache'])
2093                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2094
[359]2095       
2096                if int(settings['debug']) > 0:
2097                        print 'Loading environment', settings['project']
2098
[87]2099                env = Environment(settings['project'], create=0)
[333]2100
[206]2101                tktparser = TicketEmailParser(env, settings, float(version))
[87]2102                tktparser.parse(sys.stdin)
[22]2103
[87]2104        # Catch all errors ans log to SYSLOG if we have enabled this
2105        # else stdout
2106        #
2107        except Exception, error:
2108                if ENABLE_SYSLOG:
2109                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
[187]2110
[87]2111                        etype, evalue, etb = sys.exc_info()
2112                        for e in traceback.format_exception(etype, evalue, etb):
2113                                syslog.syslog(e)
[187]2114
[87]2115                        syslog.closelog()
2116                else:
2117                        traceback.print_exc()
[22]2118
[97]2119                if m:
[98]2120                        tktparser.save_email_for_debug(m, True)
[97]2121
[356]2122
[249]2123                sys.exit(1)
[22]2124# EOB
Note: See TracBrowser for help on using the repository browser.