source: trunk/email2trac.py.in @ 400

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

Added permission check system, closes #202, #203.

  • can be activated by setting ticket_permission_system:
    • ticket_permission_system: trac
    • ticket_permission_system: update_restricted_to_participants

If it is set to trac it will honour the settings that are set in the trac system.

If 'update_restricted_to_participants' is choosen then a ticket update is allowed only if:

1) the updater is the reporter,
2) the updater is in the CC
3) the updater has trac permission to update the ticket.

If the update is denied, a new ticket will be generated instead as to not loose the issue .

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