source: trunk/email2trac.py.in @ 392

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

Do not check if notification is enabled. Trac will do the checking and determine it. Some plugins will override some settings, eg: announcerplugin. So we did not receive any notifications, see #199

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