source: trunk/email2trac.py.in @ 401

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

remove support for trac 0.9 version

  • Property svn:executable set to *
  • Property svn:keywords set to Id
File size: 56.7 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 401 2010-07-15 14:41:21Z 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                error_with_attachments = self.attach_attachments(message_parts)
922
923                if body_text.strip() or update_fields or self.properties:
924                        if self.DRY_RUN:
925                                print 'DRY_RUN: tkt.save_changes(self.author, body_text, ticket_change_number) ', self.author, cnum
926                        else:
927                                if error_with_attachments:
928                                        body_text = '%s\\%s' %(error_with_attachments, body_text)
929                               
930                                tkt.save_changes(self.author, body_text, when, None, str(cnum))
931                       
932
933                if not spam:
934                        self.notify(tkt, False, when)
935
936                return True
937
938        def set_ticket_fields(self, ticket):
939                """
940                set the ticket fields to value specified
941                        - /etc/email2trac.conf with <prefix>_<field>
942                        - trac default values, trac.ini
943                """
944                user_dict = dict()
945
946                for field in ticket.fields:
947
948                        name = field['name']
949
950                        ## default trac value
951                        #
952                        if not field.get('custom'):
953                                value = self.get_config('ticket', 'default_%s' %(name) )
954                        else:
955                                ##  Else we get the default value for reporter
956                                #
957                                value = field.get('value')
958                                options = field.get('options')
959
960                                if value and options and (value not in options):
961                                         value = options[int(value)]
962       
963                        if self.DEBUG > 10:
964                                s = 'TD: trac.ini name %s = %s' %(name, value)
965                                self.print_unicode(s)
966
967                        ## email2trac.conf settings
968                        #
969                        prefix = self.parameters['ticket_prefix']
970                        try:
971                                value = self.parameters['%s_%s' %(prefix, name)]
972                                if self.DEBUG > 10:
973                                        s = 'TD: email2trac.conf %s = %s ' %(name, value)
974                                        self.print_unicode(s)
975
976                        except KeyError, detail:
977                                pass
978               
979                        if self.DEBUG:
980                                s = 'TD: user_dict[%s] = %s' %(name, value)
981                                self.print_unicode(s)
982
983                        if value:
984                                user_dict[name] = value
985
986                self.update_ticket_fields(ticket, user_dict, use_default=1)
987
988                if 'status' not in user_dict.keys():
989                        ticket['status'] = 'new'
990
991
992        def ticket_update_by_subject(self, subject):
993                """
994                This list of Re: prefixes is probably incomplete. Taken from
995                wikipedia. Here is how the subject is matched
996                  - Re: <subject>
997                  - Re: (<Mail list label>:)+ <subject>
998
999                So we must have the last column
1000                """
1001                if self.VERBOSE:
1002                        print "VB: ticket_update_by_subject()"
1003
1004                matched_id = None
1005                if self.TICKET_UPDATE and self.TICKET_UPDATE_BY_SUBJECT:
1006                               
1007                        SUBJECT_RE = re.compile(r'^(RE|AW|VS|SV):(.*:)*\s*(.*)', re.IGNORECASE)
1008                        result = SUBJECT_RE.search(subject)
1009
1010                        if result:
1011                                # This is a reply
1012                                orig_subject = result.group(3)
1013
1014                                if self.DEBUG:
1015                                        print 'TD: subject search string: %s' %(orig_subject)
1016
1017                                cursor = self.db.cursor()
1018                                summaries = [orig_subject, '%%: %s' % orig_subject]
1019
1020                                ##
1021                                # Convert days to seconds
1022                                lookback = int(time.mktime(time.gmtime())) - \
1023                                                self.TICKET_UPDATE_BY_SUBJECT_LOOKBACK * 24 * 3600
1024
1025
1026                                for summary in summaries:
1027                                        if self.DEBUG:
1028                                                print 'TD: Looking for summary matching: "%s"' % summary
1029                                        sql = """SELECT id FROM ticket
1030                                                        WHERE changetime >= %s AND summary LIKE %s
1031                                                        ORDER BY changetime DESC"""
1032                                        cursor.execute(sql, [lookback, summary.strip()])
1033
1034                                        for row in cursor:
1035                                                (matched_id,) = row
1036                                                if self.DEBUG:
1037                                                        print 'TD: Found matching ticket id: %d' % matched_id
1038                                                break
1039
1040                                        if matched_id:
1041                                                matched_id = '#%d' % matched_id
1042                                                return matched_id
1043
1044                return matched_id
1045
1046
1047        def new_ticket(self, msg, subject, spam, set_fields = None):
1048                """
1049                Create a new ticket
1050                """
1051                if self.VERBOSE:
1052                        print "VB: function new_ticket()"
1053
1054                tkt = Ticket(self.env)
1055
1056                self.set_reply_fields(tkt, msg)
1057
1058                self.set_ticket_fields(tkt)
1059
1060                # Check the permission of the reporter
1061                #
1062                if self.TICKET_PERMISSION_SYSTEM:
1063                        if not self.check_permission(tkt, 'TICKET_CREATE'):
1064                                print 'Reporter: %s has no permission to create tickets' %self.author
1065                                return False
1066
1067                # Old style setting for component, will be removed
1068                #
1069                if spam:
1070                        tkt['component'] = 'Spam'
1071
1072                elif self.parameters.has_key('component'):
1073                        tkt['component'] = self.parameters['component']
1074
1075                if not msg['Subject']:
1076                        tkt['summary'] = u'(No subject)'
1077                else:
1078                        tkt['summary'] = subject
1079
1080
1081                if set_fields:
1082                        rest, keywords = string.split(set_fields, '?')
1083
1084                        if keywords:
1085                                update_fields = self.str_to_dict(keywords)
1086                                self.update_ticket_fields(tkt, update_fields)
1087
1088                # produce e-mail like header
1089                #
1090                head = ''
1091                if self.EMAIL_HEADER > 0:
1092                        head = self.email_header_txt(msg)
1093
1094                message_parts = self.get_message_parts(msg)
1095
1096                # Must we update some ticket fields properties via body_text
1097                #
1098                if self.properties:
1099                                self.update_ticket_fields(tkt, self.properties)
1100
1101                if self.DEBUG:
1102                        print 'TD: self.get_message_parts ',
1103                        print message_parts
1104
1105                message_parts = self.unique_attachment_names(message_parts)
1106                if self.DEBUG:
1107                        print 'TD: self.unique_attachment_names',
1108                        print message_parts
1109               
1110                if self.EMAIL_HEADER > 0:
1111                        message_parts.insert(0, self.email_header_txt(msg))
1112                       
1113                body_text = self.body_text(message_parts)
1114
1115                tkt['description'] = body_text
1116
1117                #when = int(time.time())
1118                #
1119                utc = UTC()
1120                when = datetime.now(utc)
1121
1122                if not self.DRY_RUN:
1123                        self.id = tkt.insert()
1124       
1125                changed = False
1126                comment = ''
1127
1128                # some routines in trac are dependend on ticket id     
1129                # like alternate notify template
1130                #
1131                if self.notify_template:
1132                        tkt['id'] = self.id
1133                        changed = True
1134
1135                ## Rewrite the description if we have mailto enabled
1136                #
1137                if self.MAILTO:
1138                        changed = True
1139                        comment = u'\nadded mailto line\n'
1140                        mailto = self.html_mailto_link( m['Subject'])
1141
1142                        tkt['description'] = u'%s\r\n%s%s\r\n' \
1143                                %(head, mailto, body_text)
1144       
1145                ## Save the attachments to the ticket   
1146                #
1147                error_with_attachments =  self.attach_attachments(message_parts)
1148
1149                if error_with_attachments:
1150                        changed = True
1151                        comment = '%s\n%s\n' %(comment, error_with_attachments)
1152
1153                if changed:
1154                        if self.DRY_RUN:
1155                                print 'DRY_RUN: tkt.save_changes(%s, comment) real reporter = %s' %( tkt['reporter'], self.author)
1156                        else:
1157                                tkt.save_changes(tkt['reporter'], comment)
1158                                #print tkt.get_changelog(self.db, when)
1159
1160                if not spam:
1161                        self.notify(tkt, True)
1162
1163
1164        def attach_attachments(self, message_parts, update=False):
1165                '''
1166                save any attachments as files in the ticket's directory
1167                '''
1168                if self.VERBOSE:
1169                        print "VB: attach_attachments()"
1170
1171                if self.DRY_RUN:
1172                        print "DRY_RUN: no attachments attached to tickets"
1173                        return ''
1174
1175                count = 0
1176
1177                # Get Maxium attachment size
1178                #
1179                max_size = int(self.get_config('attachment', 'max_size'))
1180                status   = None
1181               
1182                for item in message_parts:
1183                        # Skip body parts
1184                        if not isinstance(item, tuple):
1185                                continue
1186                               
1187                        (original, filename, part) = item
1188                        #
1189                        # We have to determine the size so we use this temporary solution. we must escape it
1190                        # else we get UnicodeErrors.
1191                        #
1192                        path, fd =  util.create_unique_file(os.path.join(self.TMPDIR, util.text.unicode_quote(filename)))
1193                        text = part.get_payload(decode=1)
1194                        if not text:
1195                                text = '(None)'
1196                        fd.write(text)
1197                        fd.close()
1198
1199                        # get the file_size
1200                        #
1201                        stats = os.lstat(path)
1202                        file_size = stats[stat.ST_SIZE]
1203
1204                        # Check if the attachment size is allowed
1205                        #
1206                        if (max_size != -1) and (file_size > max_size):
1207                                status = '%s\nFile %s is larger then allowed attachment size (%d > %d)\n\n' \
1208                                        %(status, original, file_size, max_size)
1209
1210                                os.unlink(path)
1211                                continue
1212                        else:
1213                                count = count + 1
1214                                       
1215                        # Insert the attachment
1216                        #
1217                        fd = open(path, 'rb')
1218                        if self.system == 'discussion':
1219                                att = attachment.Attachment(self.env, 'discussion', 'topic/%s'
1220                                  % (self.id,))
1221                        else:
1222                                att = attachment.Attachment(self.env, 'ticket', self.id)
1223 
1224                        # This will break the ticket_update system, the body_text is vaporized
1225                        # ;-(
1226                        #
1227                        if not update:
1228                                att.author = self.author
1229                                att.description = self.email_to_unicode('Added by email2trac')
1230
1231                        try:
1232                                att.insert(filename, fd, file_size)
1233                        except OSError, detail:
1234                                status = '%s\nFilename %s could not be saved, problem: %s' %(status, filename, detail)
1235
1236                        # Remove the created temporary filename
1237                        #
1238                        fd.close()
1239                        os.unlink(path)
1240
1241                ## return error
1242                #
1243                return status
1244
1245########## Fullblog functions  #################################################
1246
1247        def blog(self, id):
1248                """
1249                The blog create/update function
1250                """
1251                # import the modules
1252                #
1253                from tracfullblog.core import FullBlogCore
1254                from tracfullblog.model import BlogPost, BlogComment
1255                from trac.test import Mock, MockPerm
1256
1257                # instantiate blog core
1258                blog = FullBlogCore(self.env)
1259                req = Mock(authname='anonymous', perm=MockPerm(), args={})
1260
1261                if id:
1262
1263                        # update blog
1264                        #
1265                        comment = BlogComment(self.env, id)
1266                        comment.author = self.author
1267
1268                        message_parts = self.get_message_parts(m)
1269                        comment.comment = self.body_text(message_parts)
1270
1271                        blog.create_comment(req, comment)
1272
1273                else:
1274                        # create blog
1275                        #
1276                        import time
1277                        post = BlogPost(self.env, 'blog_'+time.strftime("%Y%m%d%H%M%S", time.gmtime()))
1278
1279                        #post = BlogPost(self.env, blog._get_default_postname(self.env))
1280                       
1281                        post.author = self.author
1282                        post.title = self.email_to_unicode(m['Subject'])
1283
1284                        message_parts = self.get_message_parts(m)
1285                        post.body = self.body_text(message_parts)
1286                       
1287                        blog.create_post(req, post, self.author, u'Created by email2trac', False)
1288
1289
1290########## Discussion functions  ##############################################
1291
1292        def discussion_topic(self, content, subject):
1293
1294                # Import modules.
1295                from tracdiscussion.api import DiscussionApi
1296                from trac.util.datefmt import to_timestamp, utc
1297
1298                if self.DEBUG:
1299                        print 'TD: Creating a new topic in forum:', self.id
1300
1301                # Get dissussion API component.
1302                api = self.env[DiscussionApi]
1303                context = self._create_context(content, subject)
1304
1305                # Get forum for new topic.
1306                forum = api.get_forum(context, self.id)
1307
1308                if not forum and self.DEBUG:
1309                        print 'ERROR: Replied forum doesn\'t exist'
1310
1311                # Prepare topic.
1312                topic = {'forum' : forum['id'],
1313                                 'subject' : context.subject,
1314                                 'time': to_timestamp(datetime.now(utc)),
1315                                 'author' : self.author,
1316                                 'subscribers' : [self.email_addr],
1317                                 'body' : self.body_text(context.content_parts)}
1318
1319                # Add topic to DB and commit it.
1320                self._add_topic(api, context, topic)
1321                self.db.commit()
1322
1323        def discussion_topic_reply(self, content, subject):
1324
1325                # Import modules.
1326                from tracdiscussion.api import DiscussionApi
1327                from trac.util.datefmt import to_timestamp, utc
1328
1329                if self.DEBUG:
1330                        print 'TD: Replying to discussion topic', self.id
1331
1332                # Get dissussion API component.
1333                api = self.env[DiscussionApi]
1334                context = self._create_context(content, subject)
1335
1336                # Get replied topic.
1337                topic = api.get_topic(context, self.id)
1338
1339                if not topic and self.DEBUG:
1340                        print 'ERROR: Replied topic doesn\'t exist'
1341
1342                # Prepare message.
1343                message = {'forum' : topic['forum'],
1344                                   'topic' : topic['id'],
1345                                   'replyto' : -1,
1346                                   'time' : to_timestamp(datetime.now(utc)),
1347                                   'author' : self.author,
1348                                   'body' : self.body_text(context.content_parts)}
1349
1350                # Add message to DB and commit it.
1351                self._add_message(api, context, message)
1352                self.db.commit()
1353
1354        def discussion_message_reply(self, content, subject):
1355
1356                # Import modules.
1357                from tracdiscussion.api import DiscussionApi
1358                from trac.util.datefmt import to_timestamp, utc
1359
1360                if self.DEBUG:
1361                        print 'TD: Replying to discussion message', self.id
1362
1363                # Get dissussion API component.
1364                api = self.env[DiscussionApi]
1365                context = self._create_context(content, subject)
1366
1367                # Get replied message.
1368                message = api.get_message(context, self.id)
1369
1370                if not message and self.DEBUG:
1371                        print 'ERROR: Replied message doesn\'t exist'
1372
1373                # Prepare message.
1374                message = {'forum' : message['forum'],
1375                                   'topic' : message['topic'],
1376                                   'replyto' : message['id'],
1377                                   'time' : to_timestamp(datetime.now(utc)),
1378                                   'author' : self.author,
1379                                   'body' : self.body_text(context.content_parts)}
1380
1381                # Add message to DB and commit it.
1382                self._add_message(api, context, message)
1383                self.db.commit()
1384
1385        def _create_context(self, content, subject):
1386
1387                # Import modules.
1388                from trac.mimeview import Context
1389                from trac.web.api import Request
1390                from trac.perm import PermissionCache
1391
1392                # TODO: Read server base URL from config.
1393                # Create request object to mockup context creation.
1394                #
1395                environ = {'SERVER_PORT' : 80,
1396                                   'SERVER_NAME' : 'test',
1397                                   'REQUEST_METHOD' : 'POST',
1398                                   'wsgi.url_scheme' : 'http',
1399                                   'wsgi.input' : sys.stdin}
1400                chrome =  {'links': {},
1401                                   'scripts': [],
1402                                   'ctxtnav': [],
1403                                   'warnings': [],
1404                                   'notices': []}
1405
1406                if self.env.base_url_for_redirect:
1407                        environ['trac.base_url'] = self.env.base_url
1408
1409                req = Request(environ, None)
1410                req.chrome = chrome
1411                req.tz = 'missing'
1412                req.authname = self.author
1413                req.perm = PermissionCache(self.env, self.author)
1414
1415                # Create and return context.
1416                context = Context.from_request(req)
1417                context.realm = 'discussion-email2trac'
1418                context.cursor = self.db.cursor()
1419                context.content = content
1420                context.subject = subject
1421
1422                # Read content parts from content.
1423                context.content_parts = self.get_message_parts(content)
1424                context.content_parts = self.unique_attachment_names(
1425                  context.content_parts)
1426
1427                return context
1428
1429        def _add_topic(self, api, context, topic):
1430                context.req.perm.assert_permission('DISCUSSION_APPEND')
1431
1432                # Filter topic.
1433                for discussion_filter in api.discussion_filters:
1434                        accept, topic_or_error = discussion_filter.filter_topic(
1435                          context, topic)
1436                        if accept:
1437                                topic = topic_or_error
1438                        else:
1439                                raise TracError(topic_or_error)
1440
1441                # Add a new topic.
1442                api.add_topic(context, topic)
1443
1444                # Get inserted topic with new ID.
1445                topic = api.get_topic_by_time(context, topic['time'])
1446
1447                # Attach attachments.
1448                self.id = topic['id']
1449                self.attach_attachments(context.content_parts, True)
1450
1451                # Notify change listeners.
1452                for listener in api.topic_change_listeners:
1453                        listener.topic_created(context, topic)
1454
1455        def _add_message(self, api, context, message):
1456                context.req.perm.assert_permission('DISCUSSION_APPEND')
1457
1458                # Filter message.
1459                for discussion_filter in api.discussion_filters:
1460                        accept, message_or_error = discussion_filter.filter_message(
1461                          context, message)
1462                        if accept:
1463                                message = message_or_error
1464                        else:
1465                                raise TracError(message_or_error)
1466
1467                # Add message.
1468                api.add_message(context, message)
1469
1470                # Get inserted message with new ID.
1471                message = api.get_message_by_time(context, message['time'])
1472
1473                # Attach attachments.
1474                self.id = message['topic']
1475                self.attach_attachments(context.content_parts, True)
1476
1477                # Notify change listeners.
1478                for listener in api.message_change_listeners:
1479                        listener.message_created(context, message)
1480
1481########## MAIN function  ######################################################
1482
1483        def parse(self, fp):
1484                """
1485                """
1486                if self.VERBOSE:
1487                        print "VB: main function parse()"
1488                global m
1489
1490                m = email.message_from_file(fp)
1491               
1492                if not m:
1493                        if self.DEBUG:
1494                                print "TD: This is not a valid email message format"
1495                        return
1496                       
1497                # Work around lack of header folding in Python; see http://bugs.python.org/issue4696
1498                try:
1499                        m.replace_header('Subject', m['Subject'].replace('\r', '').replace('\n', ''))
1500                except AttributeError, detail:
1501                        pass
1502
1503                if self.DEBUG > 1:        # save the entire e-mail message text
1504                        self.save_email_for_debug(m, True)
1505
1506                self.db = self.env.get_db_cnx()
1507                self.get_sender_info(m)
1508
1509                if not self.email_header_acl('white_list', self.email_addr, True):
1510                        if self.DEBUG > 1 :
1511                                print 'Message rejected : %s not in white list' %(self.email_addr)
1512                        return False
1513
1514                if self.email_header_acl('black_list', self.email_addr, False):
1515                        if self.DEBUG > 1 :
1516                                print 'Message rejected : %s in black list' %(self.email_addr)
1517                        return False
1518
1519                if not self.email_header_acl('recipient_list', self.to_email_addr, True):
1520                        if self.DEBUG > 1 :
1521                                print 'Message rejected : %s not in recipient list' %(self.to_email_addr)
1522                        return False
1523
1524                # If drop the message
1525                #
1526                if self.spam(m) == 'drop':
1527                        return False
1528
1529                elif self.spam(m) == 'spam':
1530                        spam_msg = True
1531                else:
1532                        spam_msg = False
1533
1534                if not m['Subject']:
1535                        subject  = 'No Subject'
1536                else:
1537                        subject  = self.email_to_unicode(m['Subject'])
1538
1539                if self.DEBUG:
1540                         print "TD:", subject
1541
1542                #
1543                # [hic] #1529: Re: LRZ
1544                # [hic] #1529?owner=bas,priority=medium: Re: LRZ
1545                #
1546                ticket_regex = r'''
1547                        (?P<new_fields>[#][?].*)
1548                        |(?P<reply>(?P<id>[#][\d]+)(?P<fields>\?.*)?:)
1549                        '''
1550                # Check if  FullBlogPlugin is installed
1551                #
1552                blog_enabled = None
1553                blog_regex = ''
1554                if self.get_config('components', 'tracfullblog.*') in ['enabled']:
1555                        blog_enabled = True
1556                        blog_regex = '''|(?P<blog>blog:(?P<blog_id>\w*))'''
1557
1558
1559                # Check if DiscussionPlugin is installed
1560                #
1561                discussion_enabled = None
1562                discussion_regex = ''
1563                if self.get_config('components', 'tracdiscussion.api.*') in ['enabled']:
1564                        discussion_enabled = True
1565                        discussion_regex = r'''
1566                        |(?P<forum>Forum[ ][#](?P<forum_id>\d+)[ ]-[ ]?)
1567                        |(?P<topic>Topic[ ][#](?P<topic_id>\d+)[ ]-[ ]?)
1568                        |(?P<message>Message[ ][#](?P<message_id>\d+)[ ]-[ ]?)
1569                        '''
1570
1571
1572                regex_str = ticket_regex + blog_regex + discussion_regex
1573                SYSTEM_RE = re.compile(regex_str, re.VERBOSE)
1574
1575                # Find out if this is a ticket, a blog or a discussion
1576                #
1577                result =  SYSTEM_RE.search(subject)
1578
1579                if result:
1580                        # update ticket + fields
1581                        #
1582                        if result.group('reply') and self.TICKET_UPDATE:
1583                                self.system = 'ticket'
1584
1585                                # Skip the last ':' character
1586                                #
1587                                if not self.ticket_update(m, result.group('reply')[:-1], spam_msg):
1588                                        self.new_ticket(m, subject, spam_msg)
1589
1590                        # New ticket + fields
1591                        #
1592                        elif result.group('new_fields'):
1593                                self.system = 'ticket'
1594                                self.new_ticket(m, subject[:result.start('new_fields')], spam_msg, result.group('new_fields'))
1595
1596                        if blog_enabled:
1597                                if result.group('blog'):
1598                                        self.system = 'blog'
1599                                        self.blog(result.group('blog_id'))
1600
1601                        if discussion_enabled:
1602                                # New topic.
1603                                #
1604                                if result.group('forum'):
1605                                        self.system = 'discussion'
1606                                        self.id = int(result.group('forum_id'))
1607                                        self.discussion_topic(m, subject[result.end('forum'):])
1608
1609                                # Reply to topic.
1610                                #
1611                                elif result.group('topic'):
1612                                        self.system = 'discussion'
1613                                        self.id = int(result.group('topic_id'))
1614                                        self.discussion_topic_reply(m, subject[result.end('topic'):])
1615
1616                                # Reply to topic message.
1617                                #
1618                                elif result.group('message'):
1619                                        self.system = 'discussion'
1620                                        self.id = int(result.group('message_id'))
1621                                        self.discussion_message_reply(m, subject[result.end('message'):])
1622
1623                else:
1624                        self.system = 'ticket'
1625                        result = self.ticket_update_by_subject(subject)
1626                        if result:
1627                                if not self.ticket_update(m, result, spam_msg):
1628                                        self.new_ticket(m, subject, spam_msg)
1629                        else:
1630                                # No update by subject, so just create a new ticket
1631                                self.new_ticket(m, subject, spam_msg)
1632
1633
1634########## BODY TEXT functions  ###########################################################
1635
1636        def strip_signature(self, text):
1637                """
1638                Strip signature from message, inspired by Mailman software
1639                """
1640                body = []
1641                for line in text.splitlines():
1642                        if line == '-- ':
1643                                break
1644                        body.append(line)
1645
1646                return ('\n'.join(body))
1647
1648        def reflow(self, text, delsp = 0):
1649                """
1650                Reflow the message based on the format="flowed" specification (RFC 3676)
1651                """
1652                flowedlines = []
1653                quotelevel = 0
1654                prevflowed = 0
1655
1656                for line in text.splitlines():
1657                        from re import match
1658                       
1659                        # Figure out the quote level and the content of the current line
1660                        m = match('(>*)( ?)(.*)', line)
1661                        linequotelevel = len(m.group(1))
1662                        line = m.group(3)
1663
1664                        # Determine whether this line is flowed
1665                        if line and line != '-- ' and line[-1] == ' ':
1666                                flowed = 1
1667                        else:
1668                                flowed = 0
1669
1670                        if flowed and delsp and line and line[-1] == ' ':
1671                                line = line[:-1]
1672
1673                        # If the previous line is flowed, append this line to it
1674                        if prevflowed and line != '-- ' and linequotelevel == quotelevel:
1675                                flowedlines[-1] += line
1676                        # Otherwise, start a new line
1677                        else:
1678                                flowedlines.append('>' * linequotelevel + line)
1679
1680                        prevflowed = flowed
1681                       
1682
1683                return '\n'.join(flowedlines)
1684
1685        def strip_quotes(self, text):
1686                """
1687                Strip quotes from message by Nicolas Mendoza
1688                """
1689                body = []
1690                for line in text.splitlines():
1691                        if line.startswith(self.EMAIL_QUOTE):
1692                                continue
1693                        body.append(line)
1694
1695                return ('\n'.join(body))
1696
1697        def inline_properties(self, text):
1698                """
1699                Parse text if we use inline keywords to set ticket fields
1700                """
1701                if self.DEBUG:
1702                        print 'TD: inline_properties function'
1703
1704                properties = dict()
1705                body = list()
1706
1707                INLINE_EXP = re.compile('\s*[@]\s*([a-zA-Z]+)\s*:(.*)$')
1708
1709                for line in text.splitlines():
1710                        match = INLINE_EXP.match(line)
1711                        if match:
1712                                keyword, value = match.groups()
1713                                self.properties[keyword] = value.strip()
1714                                if self.DEBUG:
1715                                        print "TD: inline properties: %s : %s" %(keyword,value)
1716                        else:
1717                                body.append(line)
1718                               
1719                return '\n'.join(body)
1720
1721
1722        def wrap_text(self, text, replace_whitespace = False):
1723                """
1724                Will break a lines longer then given length into several small
1725                lines of size given length
1726                """
1727                import textwrap
1728
1729                LINESEPARATOR = '\n'
1730                reformat = ''
1731
1732                for s in text.split(LINESEPARATOR):
1733                        tmp = textwrap.fill(s,self.USE_TEXTWRAP)
1734                        if tmp:
1735                                reformat = '%s\n%s' %(reformat,tmp)
1736                        else:
1737                                reformat = '%s\n' %reformat
1738
1739                return reformat
1740
1741                # Python2.4 and higher
1742                #
1743                #return LINESEPARATOR.join(textwrap.fill(s,width) for s in str.split(LINESEPARATOR))
1744                #
1745
1746########## EMAIL attachements functions ###########################################################
1747
1748        def inline_part(self, part):
1749                """
1750                """
1751                if self.VERBOSE:
1752                        print "VB: inline_part()"
1753
1754                return part.get_param('inline', None, 'Content-Disposition') == '' or not part.has_key('Content-Disposition')
1755
1756        def get_message_parts(self, msg):
1757                """
1758                parses the email message and returns a list of body parts and attachments
1759                body parts are returned as strings, attachments are returned as tuples of (filename, Message object)
1760                """
1761                if self.VERBOSE:
1762                        print "VB: get_message_parts()"
1763
1764                message_parts = list()
1765       
1766                ALTERNATIVE_MULTIPART = False
1767
1768                for part in msg.walk():
1769                        if self.DEBUG:
1770                                print 'TD: Message part: Main-Type: %s' % part.get_content_maintype()
1771                                print 'TD: Message part: Content-Type: %s' % part.get_content_type()
1772
1773                        ## Check content type
1774                        #
1775                        if part.get_content_type() in self.STRIP_CONTENT_TYPES:
1776
1777                                if self.DEBUG:
1778                                        print "TD: A %s attachment named '%s' was skipped" %(part.get_content_type(), part.get_filename())
1779
1780                                continue
1781
1782                        ## Catch some mulitpart execptions
1783                        #
1784                        if part.get_content_type() == 'multipart/alternative':
1785                                ALTERNATIVE_MULTIPART = True
1786                                continue
1787
1788                        ## Skip multipart containers
1789                        #
1790                        if part.get_content_maintype() == 'multipart':
1791                                if self.DEBUG:
1792                                        print "TD: Skipping multipart container"
1793                                continue
1794                       
1795                        ## 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"
1796                        #
1797                        inline = self.inline_part(part)
1798
1799                        ## Drop HTML message
1800                        #
1801                        if ALTERNATIVE_MULTIPART and self.DROP_ALTERNATIVE_HTML_VERSION:
1802                                if part.get_content_type() == 'text/html':
1803                                        if self.DEBUG:
1804                                                print "TD: Skipping alternative HTML message"
1805
1806                                        ALTERNATIVE_MULTIPART = False
1807                                        continue
1808
1809                        ## Inline text parts are where the body is
1810                        #
1811                        if part.get_content_type() == 'text/plain' and inline:
1812                                if self.DEBUG:
1813                                        print 'TD:               Inline body part'
1814
1815                                # Try to decode, if fails then do not decode
1816                                #
1817                                body_text = part.get_payload(decode=1)
1818                                if not body_text:                       
1819                                        body_text = part.get_payload(decode=0)
1820
1821                                format = email.Utils.collapse_rfc2231_value(part.get_param('Format', 'fixed')).lower()
1822                                delsp = email.Utils.collapse_rfc2231_value(part.get_param('DelSp', 'no')).lower()
1823
1824                                if self.REFLOW and not self.VERBATIM_FORMAT and format == 'flowed':
1825                                        body_text = self.reflow(body_text, delsp == 'yes')
1826       
1827                                if self.STRIP_SIGNATURE:
1828                                        body_text = self.strip_signature(body_text)
1829
1830                                if self.STRIP_QUOTES:
1831                                        body_text = self.strip_quotes(body_text)
1832
1833                                if self.INLINE_PROPERTIES:
1834                                        body_text = self.inline_properties(body_text)
1835
1836                                if self.USE_TEXTWRAP:
1837                                        body_text = self.wrap_text(body_text)
1838
1839                                ## Get contents charset (iso-8859-15 if not defined in mail headers)
1840                                #
1841                                charset = part.get_content_charset()
1842                                if not charset:
1843                                        charset = 'iso-8859-15'
1844
1845                                try:
1846                                        ubody_text = unicode(body_text, charset)
1847
1848                                except UnicodeError, detail:
1849                                        ubody_text = unicode(body_text, 'iso-8859-15')
1850
1851                                except LookupError, detail:
1852                                        ubody_text = 'ERROR: Could not find charset: %s, please install' %(charset)
1853
1854                                if self.VERBATIM_FORMAT:
1855                                        message_parts.append('{{{\r\n%s\r\n}}}' %ubody_text)
1856                                else:
1857                                        message_parts.append('%s' %ubody_text)
1858                        else:
1859                                if self.DEBUG:
1860                                        s = 'TD:               Filename: %s' % part.get_filename()
1861                                        self.print_unicode(s)
1862
1863                                ##
1864                                #  First try to use email header function to convert filename.
1865                                #  If this fails the use the plan filename
1866                                try:
1867                                        filename = self.email_to_unicode(part.get_filename())
1868                                except UnicodeEncodeError, detail:
1869                                        filename = part.get_filename()
1870
1871                                message_parts.append((filename, part))
1872
1873                return message_parts
1874               
1875        def unique_attachment_names(self, message_parts):
1876                """
1877                """
1878                renamed_parts = []
1879                attachment_names = set()
1880
1881                for item in message_parts:
1882                       
1883                        ## If not an attachment, leave it alone
1884                        #
1885                        if not isinstance(item, tuple):
1886                                renamed_parts.append(item)
1887                                continue
1888                               
1889                        (filename, part) = item
1890
1891                        ## If no filename, use a default one
1892                        #
1893                        if not filename:
1894                                filename = 'untitled-part'
1895
1896                                # Guess the extension from the content type, use non strict mode
1897                                # some additional non-standard but commonly used MIME types
1898                                # are also recognized
1899                                #
1900                                ext = mimetypes.guess_extension(part.get_content_type(), False)
1901                                if not ext:
1902                                        ext = '.bin'
1903
1904                                filename = '%s%s' % (filename, ext)
1905
1906                        ## Discard relative paths for windows/unix in attachment names
1907                        #
1908                        #filename = filename.replace('\\', '/').replace(':', '/')
1909                        filename = filename.replace('\\', '_')
1910                        filename = filename.replace('/', '_')
1911
1912                        #
1913                        # We try to normalize the filename to utf-8 NFC if we can.
1914                        # Files uploaded from OS X might be in NFD.
1915                        # Check python version and then try it
1916                        #
1917                        #if sys.version_info[0] > 2 or (sys.version_info[0] == 2 and sys.version_info[1] >= 3):
1918                        #       try:
1919                        #               filename = unicodedata.normalize('NFC', unicode(filename, 'utf-8')).encode('utf-8') 
1920                        #       except TypeError:
1921                        #               pass
1922
1923                        # Make the filename unique for this ticket
1924                        num = 0
1925                        unique_filename = filename
1926                        dummy_filename, ext = os.path.splitext(filename)
1927
1928                        while (unique_filename in attachment_names) or self.attachment_exists(unique_filename):
1929                                num += 1
1930                                unique_filename = "%s-%s%s" % (dummy_filename, num, ext)
1931                               
1932                        if self.DEBUG:
1933                                s = 'TD: Attachment with filename %s will be saved as %s' % (filename, unique_filename)
1934                                self.print_unicode(s)
1935
1936                        attachment_names.add(unique_filename)
1937
1938                        renamed_parts.append((filename, unique_filename, part))
1939       
1940                return renamed_parts
1941                       
1942                       
1943        def attachment_exists(self, filename):
1944
1945                if self.DEBUG:
1946                        s = 'TD: attachment already exists: Id : %s, Filename : %s' %(self.id, filename)
1947                        self.print_unicode(s)
1948
1949                # We have no valid ticket id
1950                #
1951                if not self.id:
1952                        return False
1953
1954                try:
1955                        if self.system == 'discussion':
1956                                att = attachment.Attachment(self.env, 'discussion', 'ticket/%s'
1957                                  % (self.id,), filename)
1958                        else:
1959                                att = attachment.Attachment(self.env, 'ticket', self.id,
1960                                  filename)
1961                        return True
1962                except attachment.ResourceNotFound:
1963                        return False
1964
1965########## TRAC Ticket Text ###########################################################
1966                       
1967        def body_text(self, message_parts):
1968                body_text = []
1969               
1970                for part in message_parts:
1971                        # Plain text part, append it
1972                        if not isinstance(part, tuple):
1973                                body_text.extend(part.strip().splitlines())
1974                                body_text.append("")
1975                                continue
1976                               
1977                        (original, filename, part) = part
1978                        inline = self.inline_part(part)
1979                       
1980                        if part.get_content_maintype() == 'image' and inline:
1981                                if self.system != 'discussion':
1982                                        body_text.append('[[Image(%s)]]' % filename)
1983                                body_text.append("")
1984                        else:
1985                                if self.system != 'discussion':
1986                                        body_text.append('[attachment:"%s"]' % filename)
1987                                body_text.append("")
1988                               
1989                body_text = '\r\n'.join(body_text)
1990                return body_text
1991
1992        def html_mailto_link(self, subject):
1993                """
1994                This function returns a HTML mailto tag with the ticket id and author email address
1995                """
1996                if not self.author:
1997                        author = self.email_addr
1998                else:   
1999                        author = self.author
2000
2001                # use urllib to escape the chars
2002                #
2003                s = 'mailto:%s?Subject=%s&Cc=%s' %(
2004                       urllib.quote(self.email_addr),
2005                           urllib.quote('Re: #%s: %s' %(self.id, subject)),
2006                           urllib.quote(self.MAILTO_CC)
2007                           )
2008
2009                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)
2010                return s
2011
2012########## TRAC notify section ###########################################################
2013
2014        def notify(self, tkt, new=True, modtime=0):
2015                """
2016                A wrapper for the TRAC notify function. So we can use templates
2017                """
2018                if self.VERBOSE:
2019                        print "VB: notify()"
2020
2021                if self.DRY_RUN:
2022                                print 'DRY_RUN: self.notify(tkt, True) reporter = %s' %tkt['reporter']
2023                                return
2024                try:
2025
2026                        #from trac.ticket.web_ui import TicketModule
2027                        #from trac.ticket.notification import TicketNotificationSystem
2028                        #ticket_sys = TicketNotificationSystem(self.env)
2029                        #a = TicketModule(self.env)
2030                        #print a.__dict__
2031                        #tn_sys = TicketNotificationSystem(self.env)
2032                        #print tn_sys
2033                        #print tn_sys.__dict__
2034                        #sys.exit(0)
2035
2036                        # create false {abs_}href properties, to trick Notify()
2037                        #
2038                        if not (self.VERSION in [0.11, 0.12]):
2039                                self.env.abs_href = Href(self.get_config('project', 'url'))
2040                                self.env.href = Href(self.get_config('project', 'url'))
2041
2042
2043                        tn = TicketNotifyEmail(self.env)
2044
2045                        if self.notify_template:
2046
2047                                if self.VERSION >= 0.11:
2048
2049                                        from trac.web.chrome import Chrome
2050
2051                                        if self.notify_template_update and not new:
2052                                                tn.template_name = self.notify_template_update
2053                                        else:
2054                                                tn.template_name = self.notify_template
2055
2056                                        tn.template = Chrome(tn.env).load_template(tn.template_name, method='text')
2057                                               
2058                                else:
2059
2060                                        tn.template_name = self.notify_template;
2061
2062                        tn.notify(tkt, new, modtime)
2063
2064                except Exception, e:
2065                        print 'TD: Failure sending notification on creation of ticket #%s: %s' %(self.id, e)
2066
2067
2068
2069########## Parse Config File  ###########################################################
2070
2071def ReadConfig(file, name):
2072        """
2073        Parse the config file
2074        """
2075        if not os.path.isfile(file):
2076                print 'File %s does not exist' %file
2077                sys.exit(1)
2078
2079        config = trac_config.Configuration(file)
2080
2081        # Use given project name else use defaults
2082        #
2083        if name:
2084                sections = config.sections()
2085                if not name in sections:
2086                        print "Not a valid project name: %s" %name
2087                        print "Valid names: %s" %sections
2088                        sys.exit(1)
2089
2090                project =  dict()
2091                for option, value in  config.options(name):
2092                        project[option] = value
2093
2094        else:
2095                # use some trac internals to get the defaults
2096                #
2097                project = config.parser.defaults()
2098
2099        return project
2100
2101
2102if __name__ == '__main__':
2103        # Default config file
2104        #
2105        configfile = '@email2trac_conf@'
2106        project = ''
2107        component = ''
2108        ticket_prefix = 'default'
2109        dry_run = None
2110        verbose = None
2111
2112        ENABLE_SYSLOG = 0
2113
2114        SHORT_OPT = 'chf:np:t:v'
2115        LONG_OPT  =  ['component=', 'dry-run', 'help', 'file=', 'project=', 'ticket_prefix=', 'verbose']
2116
2117        try:
2118                opts, args = getopt.getopt(sys.argv[1:], SHORT_OPT, LONG_OPT)
2119        except getopt.error,detail:
2120                print __doc__
2121                print detail
2122                sys.exit(1)
2123       
2124        project_name = None
2125        for opt,value in opts:
2126                if opt in [ '-h', '--help']:
2127                        print __doc__
2128                        sys.exit(0)
2129                elif opt in ['-c', '--component']:
2130                        component = value
2131                elif opt in ['-f', '--file']:
2132                        configfile = value
2133                elif opt in ['-n', '--dry-run']:
2134                        dry_run = True
2135                elif opt in ['-p', '--project']:
2136                        project_name = value
2137                elif opt in ['-t', '--ticket_prefix']:
2138                        ticket_prefix = value
2139                elif opt in ['-v', '--verbose']:
2140                        verbose = True
2141       
2142        settings = ReadConfig(configfile, project_name)
2143        if not settings.has_key('project'):
2144                print __doc__
2145                print 'No Trac project is defined in the email2trac config file.'
2146                sys.exit(1)
2147       
2148        if component:
2149                settings['component'] = component
2150
2151        # The default prefix for ticket values in email2trac.conf
2152        #
2153        settings['ticket_prefix'] = ticket_prefix
2154        settings['dry_run'] = dry_run
2155        settings['verbose'] = verbose
2156
2157
2158        # Determine major trac version used to be in email2trac.conf
2159        # Quick hack for 0.12
2160        #
2161        version = '0.%s' %(trac_version.split('.')[1])
2162        if version.startswith('0.12'):
2163                version = '0.12'
2164
2165        if verbose:
2166                print "Found trac version: %s" %(version)
2167       
2168        #debug HvB
2169        #print settings
2170
2171        try:
2172                if version == '0.10':
2173                        from trac import attachment
2174                        from trac.env import Environment
2175                        from trac.ticket import Ticket
2176                        from trac.web.href import Href
2177                        from trac import util
2178                        #
2179                        # return  util.text.to_unicode(str)
2180                        #
2181                        # see http://projects.edgewall.com/trac/changeset/2799
2182                        from trac.ticket.notification import TicketNotifyEmail
2183                        from trac import config as trac_config
2184                        from trac.core import TracError
2185
2186                elif version == '0.11':
2187                        from trac import attachment
2188                        from trac.env import Environment
2189                        from trac.ticket import Ticket
2190                        from trac.web.href import Href
2191                        from trac import config as trac_config
2192                        from trac import util
2193                        from trac.core import TracError
2194                        from trac.perm import PermissionSystem
2195
2196                        #
2197                        # return  util.text.to_unicode(str)
2198                        #
2199                        # see http://projects.edgewall.com/trac/changeset/2799
2200                        from trac.ticket.notification import TicketNotifyEmail
2201
2202                elif version == '0.12':
2203                        from trac import attachment
2204                        from trac.env import Environment
2205                        from trac.ticket import Ticket
2206                        from trac.web.href import Href
2207                        from trac import config as trac_config
2208                        from trac import util
2209                        from trac.core import TracError
2210                        from trac.perm import PermissionSystem
2211
2212                        #
2213                        # return  util.text.to_unicode(str)
2214                        #
2215                        # see http://projects.edgewall.com/trac/changeset/2799
2216                        from trac.ticket.notification import TicketNotifyEmail
2217
2218
2219                else:
2220                        print 'TRAC version %s is not supported' %version
2221                        sys.exit(1)
2222                       
2223                if settings.has_key('enable_syslog'):
2224                        if SYSLOG_AVAILABLE:
2225                                ENABLE_SYSLOG =  float(settings['enable_syslog'])
2226
2227
2228                # Must be set before environment is created
2229                #
2230                if settings.has_key('python_egg_cache'):
2231                        python_egg_cache = str(settings['python_egg_cache'])
2232                        os.environ['PYTHON_EGG_CACHE'] = python_egg_cache
2233
2234       
2235                if int(settings['debug']) > 0:
2236                        print 'Loading environment', settings['project']
2237
2238                env = Environment(settings['project'], create=0)
2239
2240                tktparser = TicketEmailParser(env, settings, float(version))
2241                tktparser.parse(sys.stdin)
2242
2243        # Catch all errors ans log to SYSLOG if we have enabled this
2244        # else stdout
2245        #
2246        except Exception, error:
2247                if ENABLE_SYSLOG:
2248                        syslog.openlog('email2trac', syslog.LOG_NOWAIT)
2249
2250                        etype, evalue, etb = sys.exc_info()
2251                        for e in traceback.format_exception(etype, evalue, etb):
2252                                syslog.syslog(e)
2253
2254                        syslog.closelog()
2255                else:
2256                        traceback.print_exc()
2257
2258                if m:
2259                        tktparser.save_email_for_debug(m, True)
2260
2261
2262                sys.exit(1)
2263# EOB
Note: See TracBrowser for help on using the repository browser.