PageRenderTime 76ms CodeModel.GetById 60ms app.highlight 12ms RepoModel.GetById 1ms app.codeStats 0ms

/askbot/mail/__init__.py

https://github.com/rtnpro/askbot-devel
Python | 393 lines | 368 code | 3 blank | 22 comment | 9 complexity | 9aacedc95cc706749f4fed998137dfe1 MD5 | raw file
  1"""functions that send email in askbot
  2these automatically catch email-related exceptions
  3"""
  4import os
  5import smtplib
  6import logging
  7from django.core import mail
  8from django.conf import settings as django_settings
  9from django.core.exceptions import PermissionDenied
 10from django.forms import ValidationError
 11from django.utils.translation import ugettext_lazy as _
 12from django.utils.translation import string_concat
 13from django.template import Context
 14from askbot import exceptions
 15from askbot import const
 16from askbot.conf import settings as askbot_settings
 17from askbot.utils import url_utils
 18from askbot.utils.file_utils import store_file
 19#todo: maybe send_mail functions belong to models
 20#or the future API
 21def prefix_the_subject_line(subject):
 22    """prefixes the subject line with the
 23    EMAIL_SUBJECT_LINE_PREFIX either from
 24    from live settings, which take default from django
 25    """
 26    prefix = askbot_settings.EMAIL_SUBJECT_PREFIX
 27    if prefix != '':
 28        subject = prefix.strip() + ' ' + subject.strip()
 29    return subject
 30
 31def extract_first_email_address(text):
 32    """extract first matching email address
 33    from text string
 34    returns ``None`` if there are no matches
 35    """
 36    match = const.EMAIL_REGEX.search(text)
 37    if match:
 38        return match.group(0)
 39    else:
 40        return None
 41
 42def thread_headers(post, orig_post, update):
 43    """modify headers for email messages, so
 44    that emails appear as threaded conversations in gmail"""
 45    suffix_id = django_settings.SERVER_EMAIL
 46    if update == const.TYPE_ACTIVITY_ASK_QUESTION:
 47        msg_id = "NQ-%s-%s" % (post.id, suffix_id)
 48        headers = {'Message-ID': msg_id}
 49    elif update == const.TYPE_ACTIVITY_ANSWER:
 50        msg_id = "NA-%s-%s" % (post.id, suffix_id)
 51        orig_id = "NQ-%s-%s" % (orig_post.id, suffix_id)
 52        headers = {'Message-ID': msg_id,
 53                  'In-Reply-To': orig_id}
 54    elif update == const.TYPE_ACTIVITY_UPDATE_QUESTION:
 55        msg_id = "UQ-%s-%s-%s" % (post.id, post.last_edited_at, suffix_id)
 56        orig_id = "NQ-%s-%s" % (orig_post.id, suffix_id)
 57        headers = {'Message-ID': msg_id,
 58                  'In-Reply-To': orig_id}
 59    elif update == const.TYPE_ACTIVITY_COMMENT_QUESTION:
 60        msg_id = "CQ-%s-%s" % (post.id, suffix_id)
 61        orig_id = "NQ-%s-%s" % (orig_post.id, suffix_id)
 62        headers = {'Message-ID': msg_id,
 63                  'In-Reply-To': orig_id}
 64    elif update == const.TYPE_ACTIVITY_UPDATE_ANSWER:
 65        msg_id = "UA-%s-%s-%s" % (post.id, post.last_edited_at, suffix_id)
 66        orig_id = "NQ-%s-%s" % (orig_post.id, suffix_id)
 67        headers = {'Message-ID': msg_id,
 68                  'In-Reply-To': orig_id}
 69    elif update == const.TYPE_ACTIVITY_COMMENT_ANSWER:
 70        msg_id = "CA-%s-%s" % (post.id, suffix_id)
 71        orig_id = "NQ-%s-%s" % (orig_post.id, suffix_id)
 72        headers = {'Message-ID': msg_id,
 73                  'In-Reply-To': orig_id}
 74    else:
 75        # Unknown type -> Can't set headers
 76        return {}
 77
 78    return headers
 79
 80def send_mail(
 81            subject_line = None,
 82            body_text = None,
 83            from_email = django_settings.DEFAULT_FROM_EMAIL,
 84            recipient_list = None,
 85            activity_type = None,
 86            related_object = None,
 87            headers = None,
 88            raise_on_failure = False,
 89        ):
 90    """
 91    todo: remove parameters not relevant to the function
 92    sends email message
 93    logs email sending activity
 94    and any errors are reported as critical
 95    in the main log file
 96
 97    related_object is not mandatory, other arguments
 98    are. related_object (if given, will be saved in
 99    the activity record)
100
101    if raise_on_failure is True, exceptions.EmailNotSent is raised
102    """
103    try:
104        assert(subject_line is not None)
105        subject_line = prefix_the_subject_line(subject_line)
106        msg = mail.EmailMessage(
107                        subject_line, 
108                        body_text, 
109                        from_email,
110                        recipient_list,
111                        headers = headers
112                    )
113        msg.content_subtype = 'html'
114        msg.send()
115        if related_object is not None:
116            assert(activity_type is not None)
117    except Exception, error:
118        logging.critical(unicode(error))
119        if raise_on_failure == True:
120            raise exceptions.EmailNotSent(unicode(error))
121
122def mail_moderators(
123            subject_line = '',
124            body_text = '',
125            raise_on_failure = False):
126    """sends email to forum moderators and admins
127    """
128    from django.db.models import Q
129    from askbot.models import User
130    recipient_list = User.objects.filter(
131                    Q(status='m') | Q(is_superuser=True)
132                ).filter(
133                    is_active = True
134                ).values_list('email', flat=True)
135    recipient_list = set(recipient_list)
136
137    from_email = ''
138    if hasattr(django_settings, 'DEFAULT_FROM_EMAIL'):
139        from_email = django_settings.DEFAULT_FROM_EMAIL
140
141    try:
142        mail.send_mail(subject_line, body_text, from_email, recipient_list)
143    except smtplib.SMTPException, error:
144        logging.critical(unicode(error))
145        if raise_on_failure == True:
146            raise exceptions.EmailNotSent(unicode(error))
147
148INSTRUCTIONS_PREAMBLE = _('<p>To ask by email, please:</p>')
149QUESTION_TITLE_INSTRUCTION = _(
150    '<li>Type title in the subject line</li>'
151)
152QUESTION_DETAILS_INSTRUCTION = _(
153    '<li>Type details of your question into the email body</li>'
154)
155OPTIONAL_TAGS_INSTRUCTION = _(
156"""<li>The beginning of the subject line can contain tags,
157<em>enclosed in the square brackets</em> like so: [Tag1; Tag2]</li>"""
158)
159REQUIRED_TAGS_INSTRUCTION = _(
160"""<li>In the beginning of the subject add at least one tag
161<em>enclosed in the brackets</em> like so: [Tag1; Tag2].</li>"""
162)
163TAGS_INSTRUCTION_FOOTNOTE = _(
164"""<p>Note that a tag may consist of more than one word, to separate
165the tags, use a semicolon or a comma, for example, [One tag; Other tag]</p>"""
166)
167
168def bounce_email(
169    email, subject, reason = None, body_text = None, reply_to = None
170):
171    """sends a bounce email at address ``email``, with the subject
172    line ``subject``, accepts several reasons for the bounce:
173    * ``'problem_posting'``, ``unknown_user`` and ``permission_denied``
174    * ``body_text`` in an optional parameter that allows to append
175      extra text to the message
176    """
177    if reason == 'problem_posting':
178        error_message = _(
179            '<p>Sorry, there was an error posting your question '
180            'please contact the %(site)s administrator</p>'
181        ) % {'site': askbot_settings.APP_SHORT_NAME}
182
183        if askbot_settings.TAGS_ARE_REQUIRED:
184            error_message = string_concat(
185                                    INSTRUCTIONS_PREAMBLE,
186                                    '<ul>',
187                                    QUESTION_TITLE_INSTRUCTION,
188                                    REQUIRED_TAGS_INSTRUCTION,
189                                    QUESTION_DETAILS_INSTRUCTION,
190                                    '</ul>',
191                                    TAGS_INSTRUCTION_FOOTNOTE
192                                )
193        else:
194            error_message = string_concat(
195                                    INSTRUCTIONS_PREAMBLE,
196                                    '<ul>',
197                                        QUESTION_TITLE_INSTRUCTION,
198                                        QUESTION_DETAILS_INSTRUCTION,
199                                        OPTIONAL_TAGS_INSTRUCTION,
200                                    '</ul>',
201                                    TAGS_INSTRUCTION_FOOTNOTE
202                                )
203
204    elif reason == 'unknown_user':
205        error_message = _(
206            '<p>Sorry, in order to post questions on %(site)s '
207            'by email, please <a href="%(url)s">register first</a></p>'
208        ) % {
209            'site': askbot_settings.APP_SHORT_NAME,
210            'url': url_utils.get_login_url()
211        }
212    elif reason == 'permission_denied' and body_text is None:
213        error_message = _(
214            '<p>Sorry, your question could not be posted '
215            'due to insufficient privileges of your user account</p>'
216        )
217    elif body_text:
218        error_message = body_text
219    else:
220        raise ValueError('unknown reason to bounce an email: "%s"' % reason)
221
222
223    #print 'sending email'
224    #print email
225    #print subject
226    #print error_message
227    headers = {}
228    if reply_to:
229        headers['Reply-To'] = reply_to
230        
231    send_mail(
232        recipient_list = (email,),
233        subject_line = 'Re: ' + subject,
234        body_text = error_message,
235        headers = headers
236    )
237
238def extract_reply(text):
239    """take the part above the separator
240    and discard the last line above the separator"""
241    if const.REPLY_SEPARATOR_REGEX.search(text):
242        text = const.REPLY_SEPARATOR_REGEX.split(text)[0]
243        return '\n'.join(text.splitlines(True)[:-3])
244    else:
245        return text
246
247def process_attachment(attachment):
248    """will save a single
249    attachment and return
250    link to file in the markdown format and the
251    file storage object
252    """
253    file_storage, file_name, file_url = store_file(attachment)
254    markdown_link = '[%s](%s) ' % (attachment.name, file_url)
255    file_extension = os.path.splitext(attachment.name)[1]
256    #todo: this is a hack - use content type
257    if file_extension.lower() in ('.png', '.jpg', '.jpeg', '.gif'):
258        markdown_link = '!' + markdown_link
259    return markdown_link, file_storage
260
261def extract_user_signature(text, reply_code):
262    """extracts email signature as text trailing
263    the reply code"""
264    if reply_code in text:
265        #extract the signature
266        tail = list()
267        for line in reversed(text.splitlines()):
268            #scan backwards from the end until the magic line
269            if reply_code in line:
270                break
271            tail.insert(0, line)
272
273        #strip off the leading quoted lines, there could be one or two
274        #also strip empty lines
275        while tail and (tail[0].startswith('>') or tail[0].strip() == ''):
276            tail.pop(0)
277
278        return '\n'.join(tail)
279    else:
280        return ''
281
282
283def process_parts(parts, reply_code = None):
284    """Process parts will upload the attachments and parse out the
285    body, if body is multipart. Secondly - links to attachments
286    will be added to the body of the question.
287    Returns ready to post body of the message and the list 
288    of uploaded files.
289    """
290    body_markdown = ''
291    stored_files = list()
292    attachments_markdown = ''
293    for (part_type, content) in parts:
294        if part_type == 'attachment':
295            markdown, stored_file = process_attachment(content)
296            stored_files.append(stored_file)
297            attachments_markdown += '\n\n' + markdown
298        elif part_type == 'body':
299            body_markdown += '\n\n' + content.strip('\n\t ')
300        elif part_type == 'inline':
301            markdown, stored_file = process_attachment(content)
302            stored_files.append(stored_file)
303            body_markdown += markdown
304
305    #if the response separator is present - 
306    #split the body with it, and discard the "so and so wrote:" part
307    if reply_code:
308        signature = extract_user_signature(body_markdown, reply_code)
309    else:
310        signature = None
311    body_markdown = extract_reply(body_markdown)
312
313    body_markdown += attachments_markdown
314    return body_markdown.strip(), stored_files, signature
315
316
317def process_emailed_question(
318    from_address, subject, body_text, stored_files, tags = None
319):
320    """posts question received by email or bounces the message"""
321    #a bunch of imports here, to avoid potential circular import issues
322    from askbot.forms import AskByEmailForm
323    from askbot.models import ReplyAddress, User
324    from askbot.mail import messages
325
326    reply_to = None
327    try:
328        #todo: delete uploaded files when posting by email fails!!!
329        data = {
330            'sender': from_address,
331            'subject': subject,
332            'body_text': body_text
333        }
334        form = AskByEmailForm(data)
335        if form.is_valid():
336            email_address = form.cleaned_data['email']
337            user = User.objects.get(
338                        email__iexact = email_address
339                    )
340
341            if user.can_post_by_email() == False:
342                raise PermissionDenied(messages.insufficient_reputation(user))
343
344            if user.email_isvalid == False:
345                reply_to = ReplyAddress.objects.create_new(
346                    user = user,
347                    reply_action = 'validate_email'
348                ).as_email_address()
349                message = messages.ask_for_signature(user, footer_code = reply_to)
350                raise PermissionDenied(message)
351
352            tagnames = form.cleaned_data['tagnames']
353            title = form.cleaned_data['title']
354            body_text = form.cleaned_data['body_text']
355
356            #defect - here we might get "too many tags" issue
357            if tags:
358                tagnames += ' ' + ' '.join(tags)
359
360            stripped_body_text = user.strip_email_signature(body_text)
361            if stripped_body_text == body_text and user.email_signature:
362                #todo: send an email asking to update the signature
363                raise ValueError('email signature changed')
364
365            user.post_question(
366                title = title,
367                tags = tagnames.strip(),
368                body_text = stripped_body_text,
369                by_email = True,
370                email_address = from_address
371            )
372        else:
373            raise ValidationError()
374
375    except User.DoesNotExist:
376        bounce_email(email_address, subject, reason = 'unknown_user')
377    except User.MultipleObjectsReturned:
378        bounce_email(email_address, subject, reason = 'problem_posting')
379    except PermissionDenied, error:
380        bounce_email(
381            email_address,
382            subject,
383            reason = 'permission_denied',
384            body_text = unicode(error),
385            reply_to = reply_to
386        )
387    except ValidationError:
388        if from_address:
389            bounce_email(
390                from_address,
391                subject,
392                reason = 'problem_posting',
393            )