source: trunk/email2trac.py.in @ 377

Last change on this file since 377 was 377, checked in by bas, 11 years ago

Fixed some issues with Unicode suport for attachments, see #206

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