Add API endpoint to process POST email content and add to activity log;
emails received send notification emails; review emails and notification emails can be replied to.
This commit is contained in:
Родитель
75038ece26
Коммит
a0facd5236
|
@ -0,0 +1,93 @@
|
||||||
|
========
|
||||||
|
Activity
|
||||||
|
========
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
These APIs are experimental and are currently being worked on. Endpoints
|
||||||
|
may change without warning. The only authentication method available at
|
||||||
|
the moment is :ref:`the internal one<api-auth-internal>`.
|
||||||
|
|
||||||
|
|
||||||
|
-----------------
|
||||||
|
Review Notes List
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. _review-notes-version-list:
|
||||||
|
|
||||||
|
This endpoint allows you to list the approval/rejection review history for a version of an add-on.
|
||||||
|
|
||||||
|
.. http:get:: /api/v3/addons/addon/(int:addon_id|string:addon_slug|string:addon_guid)/versions/(int:id)/reviewnotes/
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
All add-ons require authentication and either
|
||||||
|
reviewer permissions or a user account listed as a developer of the
|
||||||
|
add-on.
|
||||||
|
|
||||||
|
:>json int count: The number of versions for this add-on.
|
||||||
|
:>json string next: The URL of the next page of results.
|
||||||
|
:>json string previous: The URL of the previous page of results.
|
||||||
|
:>json array results: An array of :ref:`per version review notes<review-notes-version-detail-object>`.
|
||||||
|
|
||||||
|
|
||||||
|
-------------------
|
||||||
|
Review Notes Detail
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
.. _review-notes-version-detail:
|
||||||
|
|
||||||
|
This endpoint allows you to fetch a single review note for a specific version of an add-on.
|
||||||
|
|
||||||
|
.. http:get:: /api/v3/addons/addon/(int:addon_id|string:addon_slug|string:addon_guid)/versions/(int:id)/reviewnotes/(int:id)/
|
||||||
|
|
||||||
|
.. _review-notes-version-detail-object:
|
||||||
|
|
||||||
|
:>json int id: The id for a review note.
|
||||||
|
:>json string action: The :ref:`type of review note<review-note-action>`.
|
||||||
|
:>json string action_label: The text label of the action.
|
||||||
|
:>json int user.id: The id of the reviewer or author who left the review note.
|
||||||
|
:>json string user.name: The name of the reviewer or author.
|
||||||
|
:>json string user.url: The link to the profile page for of the reviewer or author.
|
||||||
|
:>json string comments: The text content of the review note.
|
||||||
|
:>json string date: The date the review note was created.
|
||||||
|
|
||||||
|
|
||||||
|
.. _review-note-action:
|
||||||
|
|
||||||
|
Possible values for the ``action`` field:
|
||||||
|
|
||||||
|
========================== ==========================================================
|
||||||
|
Value Description
|
||||||
|
========================== ==========================================================
|
||||||
|
approved Version, or file in the version, was approved
|
||||||
|
rejected Version, or file in the version, was rejected
|
||||||
|
review-requested Developer requested review
|
||||||
|
more-information-requested Reviewer requested more information from developer
|
||||||
|
super-review-requested Add-on was referred to an admin for attention
|
||||||
|
comment Reviewer added comment for other reviewers
|
||||||
|
review-note Generic review comment
|
||||||
|
========================== ==========================================================
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------
|
||||||
|
Incoming Mail End-point
|
||||||
|
-----------------------
|
||||||
|
|
||||||
|
.. _activity_mail:
|
||||||
|
|
||||||
|
This endpoint allows a mail server or similar to submit a json object containing single email into AMO which will be processed.
|
||||||
|
The only type of email currently supported is a reply to an activity email (e.g an add-on review, or a reply to an add-on review).
|
||||||
|
Any other content or invalid emails will be discarded.
|
||||||
|
|
||||||
|
.. http:post:: /api/v3/activity/mail
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
This API endpoint uses a custom authentication method.
|
||||||
|
The value `SecretKey` in the submitted json must match one defined in `settings.INBOUND_EMAIL_SECRET_KEY`.
|
||||||
|
The IP address of the request must match one defined in `settings.ALLOWED_CLIENTS_EMAIL_API`, if defined.
|
||||||
|
|
||||||
|
:<json string SecretKey: A value that matches `settings.INBOUND_EMAIL_SECRET_KEY`.
|
||||||
|
:<json string Message.TextBody: The plain text body of the email.
|
||||||
|
:<json array To: Array of To email addresses. All will be parsed, and the first matching the correct format used.
|
||||||
|
:<json string To[].EmailAddress: An email address in the format `reviewreply+randomuuidstring@addons.mozilla.org`.
|
||||||
|
|
|
@ -284,66 +284,6 @@ This endpoint allows you to fetch a single version belonging to a specific add-o
|
||||||
:>json string version: The version number string for the version.
|
:>json string version: The version number string for the version.
|
||||||
|
|
||||||
|
|
||||||
-----------------
|
|
||||||
Review Notes List
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
.. _review-notes-version-list:
|
|
||||||
|
|
||||||
This endpoint allows you to list the approval/rejection review history for a version of an add-on.
|
|
||||||
|
|
||||||
.. http:get:: /api/v3/addons/addon/(int:addon_id|string:addon_slug|string:addon_guid)/versions/(int:id)/reviewnotes/
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
All add-ons require authentication and either
|
|
||||||
reviewer permissions or a user account listed as a developer of the
|
|
||||||
add-on.
|
|
||||||
|
|
||||||
:>json int count: The number of versions for this add-on.
|
|
||||||
:>json string next: The URL of the next page of results.
|
|
||||||
:>json string previous: The URL of the previous page of results.
|
|
||||||
:>json array results: An array of :ref:`per version review notes<review-notes-version-detail-object>`.
|
|
||||||
|
|
||||||
|
|
||||||
-------------------
|
|
||||||
Review Notes Detail
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
.. _review-notes-version-detail:
|
|
||||||
|
|
||||||
This endpoint allows you to fetch a single review note for a specific version of an add-on.
|
|
||||||
|
|
||||||
.. http:get:: /api/v3/addons/addon/(int:addon_id|string:addon_slug|string:addon_guid)/versions/(int:id)/reviewnotes/(int:id)/
|
|
||||||
|
|
||||||
.. _review-notes-version-detail-object:
|
|
||||||
|
|
||||||
:>json int id: The id for a review note.
|
|
||||||
:>json string action: The :ref:`type of review note<review-note-action>`.
|
|
||||||
:>json string .action_label: The text label of the action.
|
|
||||||
:>json int user.id: The id of the reviewer or author who left the review note.
|
|
||||||
:>json string user.name: The name of the reviewer or author.
|
|
||||||
:>json string user.url: The link to the profile page for of the reviewer or author.
|
|
||||||
:>json string comments: The text content of the review note.
|
|
||||||
:>json string date: The date the review note was created.
|
|
||||||
|
|
||||||
|
|
||||||
.. _review-note-action:
|
|
||||||
|
|
||||||
Possible values for the ``action`` field:
|
|
||||||
|
|
||||||
========================== ==========================================================
|
|
||||||
Value Description
|
|
||||||
========================== ==========================================================
|
|
||||||
approved Version, or file in the version, was approved
|
|
||||||
rejected Version, or file in the version, was rejected
|
|
||||||
review-requested Developer requested review
|
|
||||||
more-information-requested Reviewer requested more information from developer
|
|
||||||
super-review-requested Add-on was referred to an admin for attention
|
|
||||||
comment Reviewer added comment for other reviewers
|
|
||||||
review-note Generic review comment
|
|
||||||
========================== ==========================================================
|
|
||||||
|
|
||||||
|
|
||||||
----------------------------
|
----------------------------
|
||||||
Add-on Feature Compatibility
|
Add-on Feature Compatibility
|
||||||
----------------------------
|
----------------------------
|
||||||
|
|
|
@ -33,6 +33,7 @@ using the API.
|
||||||
auth
|
auth
|
||||||
auth_internal
|
auth_internal
|
||||||
accounts
|
accounts
|
||||||
|
activity
|
||||||
addons
|
addons
|
||||||
categories
|
categories
|
||||||
discovery
|
discovery
|
||||||
|
|
|
@ -126,6 +126,7 @@ django-tables2==1.1.2 \
|
||||||
django-waffle==0.11 \
|
django-waffle==0.11 \
|
||||||
--hash=sha256:9cd9e3a976849a3cd816830d7811b93543c1dc9da9f3cb1ccc2f9da831b8b025 \
|
--hash=sha256:9cd9e3a976849a3cd816830d7811b93543c1dc9da9f3cb1ccc2f9da831b8b025 \
|
||||||
--hash=sha256:914b4b874fa6250a0897bc30b67e02034d92a7235ab72a91bf6da49b71751de4
|
--hash=sha256:914b4b874fa6250a0897bc30b67e02034d92a7235ab72a91bf6da49b71751de4
|
||||||
|
# djangorestframework is required by drf-nested-routers
|
||||||
djangorestframework==3.3.3 \
|
djangorestframework==3.3.3 \
|
||||||
--hash=sha256:4f47056ad798103fc9fb049dff8a67a91963bd215d31bad12ad72b891559ab16 \
|
--hash=sha256:4f47056ad798103fc9fb049dff8a67a91963bd215d31bad12ad72b891559ab16 \
|
||||||
--hash=sha256:f606f2bb4e9bb320937cb6ccce299991b2d302f5cc705a671dffca491e55935c
|
--hash=sha256:f606f2bb4e9bb320937cb6ccce299991b2d302f5cc705a671dffca491e55935c
|
||||||
|
@ -144,6 +145,8 @@ elasticsearch==1.1.1 \
|
||||||
elasticsearch-dsl==0.0.11 \
|
elasticsearch-dsl==0.0.11 \
|
||||||
--hash=sha256:663fb62ad39200c7d903e973aa0aa693578613264d83796455cbf4cd172bd878 \
|
--hash=sha256:663fb62ad39200c7d903e973aa0aa693578613264d83796455cbf4cd172bd878 \
|
||||||
--hash=sha256:59a76c4142478a1952bba6f9a9ca4fc7b029afb619e8ffcf0d135ce37ea692da
|
--hash=sha256:59a76c4142478a1952bba6f9a9ca4fc7b029afb619e8ffcf0d135ce37ea692da
|
||||||
|
email-reply-parser==0.3.0 \
|
||||||
|
--hash=sha256:83cd931e40b5dbef0a69462d9392d71610afbae729a135706f09255f78c08043
|
||||||
# enum34 is required by cryptography
|
# enum34 is required by cryptography
|
||||||
enum34==1.1.6 \
|
enum34==1.1.6 \
|
||||||
--hash=sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79 \
|
--hash=sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79 \
|
||||||
|
@ -163,7 +166,7 @@ funcsigs==1.0.2 \
|
||||||
google-api-python-client==1.2 \
|
google-api-python-client==1.2 \
|
||||||
--hash=sha256:3cb3f39c4a634950aee34f52e2a160b9a064b15210f7196ba364f670780aa675 \
|
--hash=sha256:3cb3f39c4a634950aee34f52e2a160b9a064b15210f7196ba364f670780aa675 \
|
||||||
--hash=sha256:f6506e837a7401ecd97cad45900716eb12fb480f303d0dee5c61e8a4b16ff5ec
|
--hash=sha256:f6506e837a7401ecd97cad45900716eb12fb480f303d0dee5c61e8a4b16ff5ec
|
||||||
# html5lib is required by bleach
|
# html5lib is required by bleach, rdflib
|
||||||
html5lib==0.9999999 \
|
html5lib==0.9999999 \
|
||||||
--hash=sha256:2612a191a8d5842bfa057e41ba50bbb9dcb722419d2408c78cff4758d0754868
|
--hash=sha256:2612a191a8d5842bfa057e41ba50bbb9dcb722419d2408c78cff4758d0754868
|
||||||
# httplib2 is required by google-api-python-client
|
# httplib2 is required by google-api-python-client
|
||||||
|
@ -265,7 +268,7 @@ rdflib==3.4.0 \
|
||||||
--hash=sha256:78d5f11a7001661d7637f9e61554a5f8971e197f3f6d17ba5e4039b0668116cf
|
--hash=sha256:78d5f11a7001661d7637f9e61554a5f8971e197f3f6d17ba5e4039b0668116cf
|
||||||
redis==2.8.0 \
|
redis==2.8.0 \
|
||||||
--hash=sha256:5a34f92937cacb4082f5834d2ce8b710b791342d17d1769b998327e6479e2b24
|
--hash=sha256:5a34f92937cacb4082f5834d2ce8b710b791342d17d1769b998327e6479e2b24
|
||||||
# requests is required by amo-validator, codecov, nobot
|
# requests is required by amo-validator, django-mozilla-product-details, nobot
|
||||||
requests==2.11.1 \
|
requests==2.11.1 \
|
||||||
--hash=sha256:545c4855cd9d7c12671444326337013766f4eea6068c3f0307fb2dc2696d580e \
|
--hash=sha256:545c4855cd9d7c12671444326337013766f4eea6068c3f0307fb2dc2696d580e \
|
||||||
--hash=sha256:5acf980358283faba0b897c73959cecf8b841205bb4b2ad3ef545f46eae1a133
|
--hash=sha256:5acf980358283faba0b897c73959cecf8b841205bb4b2ad3ef545f46eae1a133
|
||||||
|
|
|
@ -121,6 +121,9 @@ CSP_FRAME_SRC += ('https://www.sandbox.paypal.com',)
|
||||||
CSP_IMG_SRC += (HTTP_GA_SRC,)
|
CSP_IMG_SRC += (HTTP_GA_SRC,)
|
||||||
CSP_SCRIPT_SRC += (HTTP_GA_SRC, "'self'")
|
CSP_SCRIPT_SRC += (HTTP_GA_SRC, "'self'")
|
||||||
|
|
||||||
|
# Auth token required to authorize inbound email.
|
||||||
|
INBOUND_EMAIL_SECRET_KEY = 'totally-unsecure-string-for-local-development-goodness'
|
||||||
|
|
||||||
# If you have settings you want to overload, put them in a local_settings.py.
|
# If you have settings you want to overload, put them in a local_settings.py.
|
||||||
try:
|
try:
|
||||||
from local_settings import * # noqa
|
from local_settings import * # noqa
|
||||||
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
import commonware.log
|
||||||
|
|
||||||
|
from olympia.amo.models import ModelBase
|
||||||
|
from olympia.versions.models import Version
|
||||||
|
|
||||||
|
log = commonware.log.getLogger('z.devhub')
|
||||||
|
|
||||||
|
# Number of times a token can be used.
|
||||||
|
MAX_TOKEN_USE_COUNT = 100
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityLogToken(ModelBase):
|
||||||
|
version = models.ForeignKey(Version, related_name='token')
|
||||||
|
user = models.ForeignKey('users.UserProfile',
|
||||||
|
related_name='activity_log_tokens')
|
||||||
|
uuid = models.UUIDField(default=lambda: uuid.uuid4().hex, unique=True)
|
||||||
|
use_count = models.IntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text='Stores the number of times the token has been used')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'log_activity_tokens'
|
||||||
|
unique_together = ('version', 'user')
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
return self.use_count >= MAX_TOKEN_USE_COUNT
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return (not self.is_expired() and
|
||||||
|
self.version.addon.latest_version == self.version)
|
||||||
|
|
||||||
|
def expire(self):
|
||||||
|
self.update(use_count=MAX_TOKEN_USE_COUNT)
|
||||||
|
|
||||||
|
def increment_use(self):
|
||||||
|
self.__class__.objects.filter(pk=self.pk).update(
|
||||||
|
use_count=models.expressions.F('use_count') + 1)
|
||||||
|
self.use_count = self.use_count + 1
|
|
@ -0,0 +1,14 @@
|
||||||
|
import commonware.log
|
||||||
|
from olympia.amo.celery import task
|
||||||
|
from olympia.activity.utils import add_email_to_activity_log
|
||||||
|
|
||||||
|
|
||||||
|
log = commonware.log.getLogger('z.task')
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def process_email(message, **kwargs):
|
||||||
|
"""Parse emails and save activity log entry."""
|
||||||
|
res = add_email_to_activity_log(message)
|
||||||
|
if not res:
|
||||||
|
log.error('Failed to save email.')
|
|
@ -0,0 +1,12 @@
|
||||||
|
Hello,
|
||||||
|
|
||||||
|
A reply has been added to the review of version {{ number }} of add-on {{ name }}.
|
||||||
|
|
||||||
|
{{ author }} wrote:
|
||||||
|
|
||||||
|
{{ comments }}
|
||||||
|
|
||||||
|
If you want to respond to this email please reply to this email or visit {{ url }}. If you need to send file attachments, please include the address amo-editors@mozilla.org and mention it in your reply.
|
||||||
|
--
|
||||||
|
Mozilla Add-ons
|
||||||
|
{{ SITE_URL }}
|
|
@ -0,0 +1,97 @@
|
||||||
|
{
|
||||||
|
"SecretKey":"SOME SECRET KEY",
|
||||||
|
"Message":{
|
||||||
|
"TextCharSet":"us-ascii",
|
||||||
|
"HtmlCharSet":"us-ascii",
|
||||||
|
"EmbeddedMedia":null,
|
||||||
|
"MailingId":null,
|
||||||
|
"MessageId":null,
|
||||||
|
"Subject":"This is the subject of a test message.",
|
||||||
|
"TextBody":"This is a developer reply to an AMO. It's nice.",
|
||||||
|
"HtmlBody":"\u003chtml xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\" xmlns:w=\"urn:schemas-microsoft-com:office:word\" xmlns:m=\"http://schemas.microsoft.com/office/2004/12/omml\" xmlns=\"http://www.w3.org/TR/REC-html40\"\u003e\u003chead\u003e\u003cMETA HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=us-ascii\"\u003e\u003cmeta name=Generator content=\"Microsoft Word 15 (filtered medium)\"\u003e\u003cstyle\u003e\u003c!--\r\n/* Font Definitions */\r\n@font-face\r\n\t{font-family:\"Cambria Math\";\r\n\tpanose-1:2 4 5 3 5 4 6 3 2 4;}\r\n@font-face\r\n\t{font-family:Calibri;\r\n\tpanose-1:2 15 5 2 2 2 4 3 2 4;}\r\n/* Style Definitions */\r\np.MsoNormal, li.MsoNormal, div.MsoNormal\r\n\t{margin:0in;\r\n\tmargin-bottom:.0001pt;\r\n\tfont-size:11.0pt;\r\n\tfont-family:\"Calibri\",\"sans-serif\";}\r\na:link, span.MsoHyperlink\r\n\t{mso-style-priority:99;\r\n\tcolor:#0563C1;\r\n\ttext-decoration:underline;}\r\na:visited, span.MsoHyperlinkFollowed\r\n\t{mso-style-priority:99;\r\n\tcolor:#954F72;\r\n\ttext-decoration:underline;}\r\nspan.EmailStyle17\r\n\t{mso-style-type:personal-compose;\r\n\tfont-family:\"Calibri\",\"sans-serif\";\r\n\tcolor:windowtext;}\r\n.MsoChpDefault\r\n\t{mso-style-type:export-only;\r\n\tfont-family:\"Calibri\",\"sans-serif\";}\r\n@page WordSection1\r\n\t{size:8.5in 11.0in;\r\n\tmargin:1.0in 1.0in 1.0in 1.0in;}\r\ndiv.WordSection1\r\n\t{page:WordSection1;}\r\n--\u003e\u003c/style\u003e\u003c!--[if gte mso 9]\u003e\u003cxml\u003e\r\n\u003co:shapedefaults v:ext=\"edit\" spidmax=\"1026\" /\u003e\r\n\u003c/xml\u003e\u003c![endif]--\u003e\u003c!--[if gte mso 9]\u003e\u003cxml\u003e\r\n\u003co:shapelayout v:ext=\"edit\"\u003e\r\n\u003co:idmap v:ext=\"edit\" data=\"1\" /\u003e\r\n\u003c/o:shapelayout\u003e\u003c/xml\u003e\u003c![endif]--\u003e\u003c/head\u003e\u003cbody lang=EN-US link=\"#0563C1\" vlink=\"#954F72\"\u003e\u003cdiv class=WordSection1\u003e\u003cp class=MsoNormal\u003eThis is the body of the test message.\u003co:p\u003e\u003c/o:p\u003e\u003c/p\u003e\u003c/div\u003e\u003c/body\u003e\u003c/html\u003e",
|
||||||
|
"CustomHeaders":[
|
||||||
|
{
|
||||||
|
"Name":"Received",
|
||||||
|
"Value":"from smtp176.dfw.emailsrvr.com ([67.192.241.176]) by mx.socketlabs.com with ESMTP; Mon, 20 May 2013 10:01:04 -0400"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Received",
|
||||||
|
"Value":"from localhost (localhost.localdomain [127.0.0.1])by smtp7.relay.dfw1a.emailsrvr.com (SMTP Server) with ESMTP id F39D3258692for \u003ctest@customerexample.com\u003e; Mon, 20 May 2013 10:00:47 -0400 (EDT)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Received",
|
||||||
|
"Value":"from smtp192.mex07a.mlsrvr.com (unknown [67.192.133.128])by smtp7.relay.dfw1a.emailsrvr.com (SMTP Server) with ESMTPS id 3E6022586DEfor \u003ctest@customerexample.com\u003e; Mon, 20 May 2013 10:00:45 -0400 (EDT)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Received",
|
||||||
|
"Value":"from DFW1MBX19.mex07a.mlsrvr.com ([192.168.1.230]) byDFW1HUB14.mex07a.mlsrvr.com ([fe80::222:19ff:fe00:52bd%11]) with mapi; Mon,20 May 2013 09:00:45 -0500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"X-Virus-Scanned",
|
||||||
|
"Value":"OK"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Date",
|
||||||
|
"Value":"Mon, 20 May 2013 09:00:52 -0500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Thread-Topic",
|
||||||
|
"Value":"This is the subject of a test message."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Thread-Index",
|
||||||
|
"Value":"Ac5VYnBo0JcanJnQTWSVA0k86Viq/Q=="
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Message-ID",
|
||||||
|
"Value":"\u003cCC49375B755FC1499ECDFBA782412D5F08F64FD3E6@DFW1MBX19.mex07a.mlsrvr.com\u003e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Accept-Language",
|
||||||
|
"Value":"en-US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Content-Language",
|
||||||
|
"Value":"en-US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"X-MS-Has-Attach",
|
||||||
|
"Value":null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"X-MS-TNEF-Correlator",
|
||||||
|
"Value":null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"acceptlanguage",
|
||||||
|
"Value":"en-US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"Content-Type",
|
||||||
|
"Value":"multipart/alternative;boundary=\"_000_CC49375B755FC1499ECDFBA782412D5F08F64FD3E6DFW1MBX19mex0_\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name":"MIME-Version",
|
||||||
|
"Value":"1.0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"To":[{
|
||||||
|
"EmailAddress":"reviewreply+5a0b8a83d501412589cc5d562334b46b@addons.mozilla.org",
|
||||||
|
"FriendlyName":"AMO Reply"
|
||||||
|
}],
|
||||||
|
"Cc":null,
|
||||||
|
"Bcc":null,
|
||||||
|
"From":{
|
||||||
|
"EmailAddress":"sender@example.com",
|
||||||
|
"FriendlyName":"Example Sender"
|
||||||
|
},
|
||||||
|
"ReplyTo":null,
|
||||||
|
"Attachments":null
|
||||||
|
},
|
||||||
|
"InboundMailFrom":"sender@example.com",
|
||||||
|
"InboundRcptTo":"test@customerexample.com",
|
||||||
|
"InboundIpAddress":"10.10.10.10",
|
||||||
|
"ErrorLog":null,
|
||||||
|
"SpamScore":0,
|
||||||
|
"SpamDetails":null
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
from olympia.activity.models import ActivityLogToken, MAX_TOKEN_USE_COUNT
|
||||||
|
from olympia.amo.tests import addon_factory, user_factory, TestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivityLogToken(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestActivityLogToken, self).setUp()
|
||||||
|
self.addon = addon_factory()
|
||||||
|
self.version = self.addon.latest_version
|
||||||
|
self.user = user_factory()
|
||||||
|
self.token = ActivityLogToken.objects.create(
|
||||||
|
version=self.version, user=self.user)
|
||||||
|
|
||||||
|
def test_validity_use_expiry(self):
|
||||||
|
assert self.token.use_count == 0
|
||||||
|
self.token.increment_use()
|
||||||
|
assert self.token.use_count == 1
|
||||||
|
assert not self.token.is_expired()
|
||||||
|
self.token.expire()
|
||||||
|
assert self.token.use_count == MAX_TOKEN_USE_COUNT
|
||||||
|
# Being expired is invalid too.
|
||||||
|
assert self.token.is_expired()
|
||||||
|
# But the version is still the latest version.
|
||||||
|
assert self.version == self.addon.latest_version
|
||||||
|
assert not self.token.is_valid()
|
||||||
|
|
||||||
|
def test_increment_use(self):
|
||||||
|
assert self.token.use_count == 0
|
||||||
|
self.token.increment_use()
|
||||||
|
assert self.token.use_count == 1
|
||||||
|
token_from_db = ActivityLogToken.objects.get(
|
||||||
|
version=self.version, user=self.user)
|
||||||
|
assert token_from_db.use_count == 1
|
||||||
|
|
||||||
|
def test_validity_version_out_of_date(self):
|
||||||
|
self.addon._latest_version = None
|
||||||
|
# The token isn't expired.
|
||||||
|
assert not self.token.is_expired()
|
||||||
|
# But is invalid, because the version isn't the latest version.
|
||||||
|
assert not self.token.is_valid()
|
|
@ -0,0 +1,196 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import json
|
||||||
|
import mock
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core import mail
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from olympia import amo
|
||||||
|
from olympia.amo.helpers import absolutify
|
||||||
|
from olympia.amo.tests import addon_factory, user_factory, TestCase
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
from olympia.activity.models import ActivityLogToken, MAX_TOKEN_USE_COUNT
|
||||||
|
from olympia.activity.utils import (
|
||||||
|
add_email_to_activity_log, log_and_notify, send_activity_mail,
|
||||||
|
ActivityEmailParser)
|
||||||
|
from olympia.devhub.models import ActivityLog
|
||||||
|
|
||||||
|
|
||||||
|
TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sample_message = os.path.join(TESTS_DIR, 'emails', 'message.json')
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailParser(TestCase):
|
||||||
|
|
||||||
|
def test_basic_email(self):
|
||||||
|
message = json.loads(open(sample_message).read())
|
||||||
|
email_text = message['Message']
|
||||||
|
parser = ActivityEmailParser(email_text)
|
||||||
|
assert parser.get_uuid() == '5a0b8a83d501412589cc5d562334b46b'
|
||||||
|
assert parser.get_body() == (
|
||||||
|
'This is a developer reply to an AMO. It\'s nice.')
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddEmailToActivityLog(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.addon = addon_factory(name='Badger', status=amo.STATUS_NOMINATED)
|
||||||
|
self.profile = user_factory()
|
||||||
|
self.token = ActivityLogToken.objects.create(
|
||||||
|
version=self.addon.current_version, user=self.profile)
|
||||||
|
self.token.update(uuid='5a0b8a83d501412589cc5d562334b46b')
|
||||||
|
message = json.loads(open(sample_message).read())
|
||||||
|
self.message = message['Message']
|
||||||
|
|
||||||
|
def test_developer_comment(self):
|
||||||
|
self.profile.addonuser_set.create(addon=self.addon)
|
||||||
|
note = add_email_to_activity_log(self.message)
|
||||||
|
assert note.log == amo.LOG.DEVELOPER_REPLY_VERSION
|
||||||
|
self.token.refresh_from_db()
|
||||||
|
assert self.token.use_count == 1
|
||||||
|
|
||||||
|
def test_reviewer_comment(self):
|
||||||
|
self.grant_permission(self.profile, 'Addons:Review')
|
||||||
|
note = add_email_to_activity_log(self.message)
|
||||||
|
assert note.log == amo.LOG.REVIEWER_REPLY_VERSION
|
||||||
|
self.token.refresh_from_db()
|
||||||
|
assert self.token.use_count == 1
|
||||||
|
|
||||||
|
def test_with_max_count_token(self):
|
||||||
|
# Test with an invalid token.
|
||||||
|
self.token.update(use_count=MAX_TOKEN_USE_COUNT + 1)
|
||||||
|
assert not add_email_to_activity_log(self.message)
|
||||||
|
self.token.refresh_from_db()
|
||||||
|
assert self.token.use_count == MAX_TOKEN_USE_COUNT + 1
|
||||||
|
|
||||||
|
def test_with_unpermitted_token(self):
|
||||||
|
"""Test when the token user doesn't have a permission to add a note."""
|
||||||
|
assert not add_email_to_activity_log(self.message)
|
||||||
|
self.token.refresh_from_db()
|
||||||
|
assert self.token.use_count == 0
|
||||||
|
|
||||||
|
def test_non_existent_token(self):
|
||||||
|
self.token.update(uuid='12345678901234567890123456789012')
|
||||||
|
assert not add_email_to_activity_log(self.message)
|
||||||
|
|
||||||
|
def test_with_invalid_msg(self):
|
||||||
|
assert not add_email_to_activity_log('youtube?v=dQw4w9WgXcQ')
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogAndNotify(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.developer = user_factory()
|
||||||
|
self.developer2 = user_factory()
|
||||||
|
self.reviewer = user_factory()
|
||||||
|
self.grant_permission(self.reviewer, 'Addons:Review',
|
||||||
|
'Addon Reviewers')
|
||||||
|
self.senior_reviewer = user_factory()
|
||||||
|
self.grant_permission(self.senior_reviewer, 'Addons:Edit',
|
||||||
|
'Senior Addon Reviewers')
|
||||||
|
self.grant_permission(self.senior_reviewer, 'Addons:Review',
|
||||||
|
'Senior Addon Reviewers')
|
||||||
|
|
||||||
|
self.addon = addon_factory()
|
||||||
|
self.addon.addonuser_set.create(user=self.developer)
|
||||||
|
self.addon.addonuser_set.create(user=self.developer2)
|
||||||
|
|
||||||
|
def _create(self, action, author=None):
|
||||||
|
author = author or self.reviewer
|
||||||
|
details = {
|
||||||
|
'comments': u'I spy, with my líttle €ye...',
|
||||||
|
'version': self.addon.latest_version.version}
|
||||||
|
return amo.log(action, self.addon, self.addon.latest_version,
|
||||||
|
user=author, details=details, created=self.days_ago(1))
|
||||||
|
|
||||||
|
def _recipients(self, email_mock):
|
||||||
|
recipients = []
|
||||||
|
for call in email_mock.call_args_list:
|
||||||
|
recipients += call[1]['recipient_list']
|
||||||
|
[reply_to] = call[1]['reply_to']
|
||||||
|
assert reply_to.startswith('reviewreply+')
|
||||||
|
assert reply_to.endswith(settings.INBOUND_EMAIL_DOMAIN)
|
||||||
|
return recipients
|
||||||
|
|
||||||
|
def _check_email(self, call, url):
|
||||||
|
assert call[0][0] == (
|
||||||
|
'Mozilla Add-ons: %s Updated' % self.addon.name)
|
||||||
|
assert ('visit %s' % url) in call[0][1]
|
||||||
|
|
||||||
|
@mock.patch('olympia.activity.utils.send_mail')
|
||||||
|
def test_developer_reply(self, send_mail_mock):
|
||||||
|
# One from the reviewer.
|
||||||
|
self._create(amo.LOG.REJECT_VERSION, self.reviewer)
|
||||||
|
# One from the developer. So the developer is on the 'thread'
|
||||||
|
self._create(amo.LOG.DEVELOPER_REPLY_VERSION, self.developer)
|
||||||
|
action = amo.LOG.DEVELOPER_REPLY_VERSION
|
||||||
|
comments = u'Thïs is á reply'
|
||||||
|
version = self.addon.latest_version
|
||||||
|
log_and_notify(action, comments, self.developer, version)
|
||||||
|
|
||||||
|
logs = ActivityLog.objects.filter(action=action.id)
|
||||||
|
assert len(logs) == 2 # We added one above.
|
||||||
|
assert logs[0].details['comments'] == u'Thïs is á reply'
|
||||||
|
|
||||||
|
assert send_mail_mock.call_count == 2 # One author, one reviewer.
|
||||||
|
recipients = self._recipients(send_mail_mock)
|
||||||
|
assert self.reviewer.email in recipients
|
||||||
|
assert self.developer2.email in recipients
|
||||||
|
# The developer who sent it doesn't get their email back.
|
||||||
|
assert self.developer.email not in recipients
|
||||||
|
|
||||||
|
self._check_email(send_mail_mock.call_args_list[0],
|
||||||
|
self.addon.get_dev_url('versions'))
|
||||||
|
review_url = absolutify(
|
||||||
|
reverse('editors.review', args=[self.addon.pk], add_prefix=False))
|
||||||
|
self._check_email(send_mail_mock.call_args_list[1],
|
||||||
|
review_url)
|
||||||
|
|
||||||
|
@mock.patch('olympia.activity.utils.send_mail')
|
||||||
|
def test_reviewer_reply(self, send_mail_mock):
|
||||||
|
# One from the reviewer.
|
||||||
|
self._create(amo.LOG.REJECT_VERSION, self.reviewer)
|
||||||
|
# One from the developer.
|
||||||
|
self._create(amo.LOG.DEVELOPER_REPLY_VERSION, self.developer)
|
||||||
|
action = amo.LOG.REVIEWER_REPLY_VERSION
|
||||||
|
comments = u'Thîs ïs a revïewer replyîng'
|
||||||
|
version = self.addon.latest_version
|
||||||
|
log_and_notify(action, comments, self.reviewer, version)
|
||||||
|
|
||||||
|
logs = ActivityLog.objects.filter(action=action.id)
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0].details['comments'] == u'Thîs ïs a revïewer replyîng'
|
||||||
|
|
||||||
|
assert send_mail_mock.call_count == 2 # Both authors.
|
||||||
|
recipients = self._recipients(send_mail_mock)
|
||||||
|
assert self.developer.email in recipients
|
||||||
|
assert self.developer2.email in recipients
|
||||||
|
# The reviewer who sent it doesn't get their email back.
|
||||||
|
assert self.reviewer.email not in recipients
|
||||||
|
|
||||||
|
self._check_email(send_mail_mock.call_args_list[0],
|
||||||
|
self.addon.get_dev_url('versions'))
|
||||||
|
self._check_email(send_mail_mock.call_args_list[1],
|
||||||
|
self.addon.get_dev_url('versions'))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_send_activity_mail():
|
||||||
|
subject = u'This ïs ã subject'
|
||||||
|
message = u'And... this ïs a messãge!'
|
||||||
|
addon = addon_factory()
|
||||||
|
user = user_factory()
|
||||||
|
recipients = [user, ]
|
||||||
|
from_email = 'bob@bob.bob'
|
||||||
|
send_activity_mail(subject, message, addon.latest_version, recipients,
|
||||||
|
from_email)
|
||||||
|
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
assert mail.outbox[0].body == message
|
||||||
|
assert mail.outbox[0].subject == subject
|
||||||
|
|
||||||
|
uuid = addon.latest_version.token.get(user=user).uuid.hex
|
||||||
|
reply_email = 'reviewreply+%s@%s' % (uuid, settings.INBOUND_EMAIL_DOMAIN)
|
||||||
|
assert mail.outbox[0].reply_to == [reply_email]
|
|
@ -1,13 +1,21 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import json
|
import json
|
||||||
|
import mock
|
||||||
|
import StringIO
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from olympia import amo
|
from olympia import amo
|
||||||
|
from olympia.activity.models import ActivityLogToken
|
||||||
from olympia.activity.tests.test_serializers import LogMixin
|
from olympia.activity.tests.test_serializers import LogMixin
|
||||||
|
from olympia.activity.tests.test_utils import sample_message
|
||||||
|
from olympia.activity.views import inbound_email, EmailCreationPermission
|
||||||
from olympia.amo.tests import (
|
from olympia.amo.tests import (
|
||||||
addon_factory, APITestClient, user_factory, TestCase)
|
addon_factory, APITestClient, req_factory_factory, user_factory, TestCase)
|
||||||
from olympia.amo.urlresolvers import reverse
|
from olympia.amo.urlresolvers import reverse
|
||||||
from olympia.addons.models import AddonUser
|
from olympia.addons.models import AddonUser
|
||||||
from olympia.addons.utils import generate_addon_guid
|
from olympia.addons.utils import generate_addon_guid
|
||||||
|
from olympia.devhub.models import ActivityLog
|
||||||
from olympia.users.models import UserProfile
|
from olympia.users.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
@ -198,3 +206,66 @@ class TestReviewNotesViewSetList(ReviewNotesViewSetDetailMixin, TestCase):
|
||||||
self.url = reverse('version-reviewnotes-list', kwargs={
|
self.url = reverse('version-reviewnotes-list', kwargs={
|
||||||
'addon_pk': addon_pk or self.addon.pk,
|
'addon_pk': addon_pk or self.addon.pk,
|
||||||
'version_pk': version_pk or self.version.pk})
|
'version_pk': version_pk or self.version.pk})
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ALLOWED_CLIENTS_EMAIL_API=['10.10.10.10'])
|
||||||
|
@override_settings(INBOUND_EMAIL_SECRET_KEY='SOME SECRET KEY')
|
||||||
|
class TestEmailApi(TestCase):
|
||||||
|
|
||||||
|
def get_request(self, data):
|
||||||
|
datastr = json.dumps(data)
|
||||||
|
req = req_factory_factory(reverse('inbound-email-api'))
|
||||||
|
req.META['REMOTE_ADDR'] = '10.10.10.10'
|
||||||
|
req.META['CONTENT_LENGTH'] = len(datastr)
|
||||||
|
req.META['CONTENT_TYPE'] = 'application/json'
|
||||||
|
req.POST = data
|
||||||
|
req.method = 'POST'
|
||||||
|
req._stream = StringIO.StringIO(datastr)
|
||||||
|
return req
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
user = user_factory()
|
||||||
|
self.grant_permission(user, '*:*')
|
||||||
|
addon = addon_factory()
|
||||||
|
req = self.get_request(
|
||||||
|
json.loads(open(sample_message).read()))
|
||||||
|
|
||||||
|
ActivityLogToken.objects.create(
|
||||||
|
user=user, version=addon.latest_version,
|
||||||
|
uuid='5a0b8a83d501412589cc5d562334b46b')
|
||||||
|
|
||||||
|
res = inbound_email(req)
|
||||||
|
assert res.status_code == 201
|
||||||
|
logs = ActivityLog.objects.for_addons(addon)
|
||||||
|
assert logs.count() == 1
|
||||||
|
assert logs.get(action=amo.LOG.REVIEWER_REPLY_VERSION.id)
|
||||||
|
|
||||||
|
def test_allowed(self):
|
||||||
|
assert EmailCreationPermission().has_permission(
|
||||||
|
self.get_request({'SecretKey': 'SOME SECRET KEY'}), None)
|
||||||
|
|
||||||
|
def test_ip_denied(self):
|
||||||
|
req = self.get_request({'SecretKey': 'SOME SECRET KEY'})
|
||||||
|
req.META['REMOTE_ADDR'] = '10.10.10.1'
|
||||||
|
assert not EmailCreationPermission().has_permission(req, None)
|
||||||
|
|
||||||
|
def test_no_postfix_token(self):
|
||||||
|
req = self.get_request({})
|
||||||
|
assert not EmailCreationPermission().has_permission(req, None)
|
||||||
|
|
||||||
|
def test_postfix_token_denied(self):
|
||||||
|
req = self.get_request({'SecretKey': 'WRONG SECRET'})
|
||||||
|
assert not EmailCreationPermission().has_permission(req, None)
|
||||||
|
|
||||||
|
@mock.patch('olympia.activity.tasks.process_email.apply_async')
|
||||||
|
def test_successful(self, _mock):
|
||||||
|
req = self.get_request(
|
||||||
|
{'SecretKey': 'SOME SECRET KEY', 'Message': 'something'})
|
||||||
|
res = inbound_email(req)
|
||||||
|
_mock.assert_called_with(('something',))
|
||||||
|
assert res.status_code == 201
|
||||||
|
|
||||||
|
def test_bad_request(self):
|
||||||
|
"""Test with no email body."""
|
||||||
|
res = inbound_email(self.get_request({'SecretKey': 'SOME SECRET KEY'}))
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r'^mail/', views.inbound_email, name='inbound-email-api'),
|
||||||
|
]
|
|
@ -0,0 +1,182 @@
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.template import Context, loader
|
||||||
|
|
||||||
|
from email_reply_parser import EmailReplyParser
|
||||||
|
|
||||||
|
from olympia import amo
|
||||||
|
from olympia.access import acl
|
||||||
|
from olympia.activity.models import ActivityLogToken
|
||||||
|
from olympia.amo.helpers import absolutify
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
from olympia.amo.utils import send_mail
|
||||||
|
from olympia.devhub.models import ActivityLog
|
||||||
|
|
||||||
|
log = logging.getLogger('z.amo.activity')
|
||||||
|
|
||||||
|
# Prefix of the reply to address in devcomm emails.
|
||||||
|
REPLY_TO_PREFIX = 'reviewreply+'
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityEmailError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityEmailEncodingError(ActivityEmailError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityEmailUUIDError(ActivityEmailError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityEmailParser(object):
|
||||||
|
"""Utility to parse email replies."""
|
||||||
|
address_prefix = REPLY_TO_PREFIX
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
if (not type(message) is dict or 'TextBody' not in message):
|
||||||
|
log.exception('ActivityEmailParser didn\'t get a valid message.')
|
||||||
|
raise ActivityEmailEncodingError(
|
||||||
|
'Invalid or malformed json message object')
|
||||||
|
|
||||||
|
self.email = message
|
||||||
|
reply = self._extra_email_reply_parse(self.email['TextBody'])
|
||||||
|
self.reply = EmailReplyParser.read(reply).reply
|
||||||
|
|
||||||
|
def _extra_email_reply_parse(self, email):
|
||||||
|
"""
|
||||||
|
Adds an extra case to the email reply parser where the reply is
|
||||||
|
followed by headers like "From: amo-editors@mozilla.org" and
|
||||||
|
strips that part out.
|
||||||
|
"""
|
||||||
|
email_header_re = re.compile('From: [^@]+@[^@]+\.[^@]+')
|
||||||
|
split_email = email_header_re.split(email)
|
||||||
|
if split_email[0].startswith('From: '):
|
||||||
|
# In case, it's a bottom reply, return everything.
|
||||||
|
return email
|
||||||
|
else:
|
||||||
|
# Else just return the email reply portion.
|
||||||
|
return split_email[0]
|
||||||
|
|
||||||
|
def get_uuid(self):
|
||||||
|
for to in self.email.get('To', []):
|
||||||
|
address = to.get('EmailAddress', '')
|
||||||
|
if address.startswith(self.address_prefix):
|
||||||
|
# Strip everything between "reviewreply+" and the "@" sign.
|
||||||
|
return address[len(self.address_prefix):].split('@')[0]
|
||||||
|
log.exception(
|
||||||
|
'TO: address missing or not related to activity emails. (%s)'
|
||||||
|
% self.email)
|
||||||
|
raise ActivityEmailUUIDError(
|
||||||
|
'TO: address doesn\'t contain activity email uuid (%s)'
|
||||||
|
% self.email)
|
||||||
|
|
||||||
|
def get_body(self):
|
||||||
|
return self.reply
|
||||||
|
|
||||||
|
|
||||||
|
def add_email_to_activity_log(message):
|
||||||
|
log.debug("Saving from email reply")
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = ActivityEmailParser(message)
|
||||||
|
uuid = parser.get_uuid()
|
||||||
|
token = ActivityLogToken.objects.get(uuid=uuid)
|
||||||
|
except ActivityLogToken.DoesNotExist:
|
||||||
|
log.error('An email was skipped with non-existing uuid %s.' % uuid)
|
||||||
|
return False
|
||||||
|
except ActivityEmailError:
|
||||||
|
# We logged already when the exception occurred.
|
||||||
|
return False
|
||||||
|
|
||||||
|
version = token.version
|
||||||
|
user = token.user
|
||||||
|
if token.is_valid():
|
||||||
|
log_type = None
|
||||||
|
|
||||||
|
review_perm = 'Review' if version.addon.is_listed else 'ReviewUnlisted'
|
||||||
|
if version.addon.authors.filter(pk=user.pk).exists():
|
||||||
|
log_type = amo.LOG.DEVELOPER_REPLY_VERSION
|
||||||
|
elif acl.action_allowed_user(user, 'Addons', review_perm):
|
||||||
|
log_type = amo.LOG.REVIEWER_REPLY_VERSION
|
||||||
|
|
||||||
|
if log_type:
|
||||||
|
note = log_and_notify(log_type, parser.get_body(), user, version)
|
||||||
|
log.info('A new note has been created (from %s using tokenid %s).'
|
||||||
|
% (user.id, uuid))
|
||||||
|
token.increment_use()
|
||||||
|
return note
|
||||||
|
else:
|
||||||
|
log.error('%s did not have perms to reply to email thread %s.'
|
||||||
|
% (user.email, version.id))
|
||||||
|
else:
|
||||||
|
log.error('%s tried to use an invalid activity email token for '
|
||||||
|
'version %s.' % (user.email, version.id))
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def log_and_notify(action, comments, note_creator, version):
|
||||||
|
log_kwargs = {
|
||||||
|
'user': note_creator,
|
||||||
|
'created': datetime.datetime.now(),
|
||||||
|
'details': {
|
||||||
|
'comments': comments,
|
||||||
|
'version': version.version}}
|
||||||
|
note = amo.log(action, version.addon, version, **log_kwargs)
|
||||||
|
|
||||||
|
# Collect reviewers/others involved with this version.
|
||||||
|
log_users = [
|
||||||
|
alog.user for alog in ActivityLog.objects.for_version(version)]
|
||||||
|
# Collect add-on authors (excl. the person who sent the email.)
|
||||||
|
addon_authors = set(version.addon.authors.all()) - {note_creator}
|
||||||
|
# Collect reviewers on the thread (again, excl. the email sender)
|
||||||
|
reviewer_recipients = set(log_users) - addon_authors - {note_creator}
|
||||||
|
author_context_dict = {
|
||||||
|
'name': version.addon.name,
|
||||||
|
'number': version.version,
|
||||||
|
'author': note_creator.name,
|
||||||
|
'comments': comments,
|
||||||
|
'url': version.addon.get_dev_url('versions'),
|
||||||
|
'SITE_URL': settings.SITE_URL,
|
||||||
|
}
|
||||||
|
reviewer_context_dict = author_context_dict.copy()
|
||||||
|
reviewer_context_dict['url'] = absolutify(
|
||||||
|
reverse('editors.review', args=[version.addon.pk], add_prefix=False))
|
||||||
|
|
||||||
|
# Not being localised because we don't know the recipients locale.
|
||||||
|
subject = 'Mozilla Add-ons: %s Updated' % version.addon.name
|
||||||
|
template = loader.get_template('activity/emails/developer.txt')
|
||||||
|
send_activity_mail(
|
||||||
|
subject, template.render(Context(author_context_dict)), version,
|
||||||
|
addon_authors, settings.EDITORS_EMAIL)
|
||||||
|
send_activity_mail(
|
||||||
|
subject, template.render(Context(reviewer_context_dict)), version,
|
||||||
|
reviewer_recipients, settings.EDITORS_EMAIL)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
def send_activity_mail(subject, message, version, recipients, from_email,
|
||||||
|
perm_setting=None):
|
||||||
|
for recipient in recipients:
|
||||||
|
token, created = ActivityLogToken.objects.get_or_create(
|
||||||
|
version=version, user=recipient)
|
||||||
|
if not created:
|
||||||
|
token.update(use_count=0)
|
||||||
|
else:
|
||||||
|
# We need .uuid to be a real UUID not just a str.
|
||||||
|
token.reload()
|
||||||
|
log.info('Created token with UUID %s for user: %s.' % (
|
||||||
|
token.uuid, recipient.id))
|
||||||
|
reply_to = "%s%s@%s" % (
|
||||||
|
REPLY_TO_PREFIX, token.uuid.hex, settings.INBOUND_EMAIL_DOMAIN)
|
||||||
|
log.info('Sending activity email to %s for %s version %s' % (
|
||||||
|
recipient, version.addon.pk, version.pk))
|
||||||
|
send_mail(
|
||||||
|
subject, message, recipient_list=[recipient.email],
|
||||||
|
from_email=from_email, use_blacklist=False,
|
||||||
|
perm_setting=perm_setting, reply_to=[reply_to])
|
|
@ -1,10 +1,20 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import (api_view, authentication_classes,
|
||||||
|
permission_classes)
|
||||||
|
from rest_framework.exceptions import ParseError
|
||||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||||
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import GenericViewSet
|
from rest_framework.viewsets import GenericViewSet
|
||||||
|
|
||||||
from olympia import amo
|
from olympia import amo
|
||||||
from olympia.activity.serializers import ActivityLogSerializer
|
from olympia.activity.serializers import ActivityLogSerializer
|
||||||
|
from olympia.activity.tasks import process_email
|
||||||
from olympia.addons.views import AddonChildMixin
|
from olympia.addons.views import AddonChildMixin
|
||||||
from olympia.api.permissions import (
|
from olympia.api.permissions import (
|
||||||
AllowAddonAuthor, AllowReviewer, AllowReviewerUnlisted, AnyOf)
|
AllowAddonAuthor, AllowReviewer, AllowReviewerUnlisted, AnyOf)
|
||||||
|
@ -50,3 +60,43 @@ class VersionReviewNotesViewSet(AddonChildMixin, RetrieveModelMixin,
|
||||||
if not latest_reply:
|
if not latest_reply:
|
||||||
return version_qs
|
return version_qs
|
||||||
return version_qs.filter(created__gt=latest_reply.created)
|
return version_qs.filter(created__gt=latest_reply.created)
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger('z.amo.mail')
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCreationPermission(object):
|
||||||
|
"""Permit if client's IP address is allowed."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
try:
|
||||||
|
# request.data isn't available at this point.
|
||||||
|
data = json.loads(request.body)
|
||||||
|
except ValueError:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
secret_key = data.get('SecretKey', '')
|
||||||
|
if not secret_key == settings.INBOUND_EMAIL_SECRET_KEY:
|
||||||
|
log.info('Invalid secret key [%s] provided' % (secret_key,))
|
||||||
|
return False
|
||||||
|
|
||||||
|
remote_ip = request.META.get('REMOTE_ADDR', '')
|
||||||
|
allowed_ips = settings.ALLOWED_CLIENTS_EMAIL_API
|
||||||
|
if allowed_ips and remote_ip not in allowed_ips:
|
||||||
|
log.info('Request from invalid ip address [%s]' % (remote_ip,))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@authentication_classes(())
|
||||||
|
@permission_classes((EmailCreationPermission,))
|
||||||
|
def inbound_email(request):
|
||||||
|
message = request.data.get('Message', None)
|
||||||
|
if not message:
|
||||||
|
raise ParseError(
|
||||||
|
detail='Message not present in the POST data.')
|
||||||
|
|
||||||
|
process_email.apply_async((message,))
|
||||||
|
return Response(status=status.HTTP_201_CREATED)
|
||||||
|
|
|
@ -658,6 +658,14 @@ class DEVELOPER_REPLY_VERSION(_LOG):
|
||||||
review_queue = True
|
review_queue = True
|
||||||
|
|
||||||
|
|
||||||
|
class REVIEWER_REPLY_VERSION(_LOG):
|
||||||
|
id = 141
|
||||||
|
format = _(u'Reply by reviewer on {addon} {version}.')
|
||||||
|
short = _(u'Reviewer Reply')
|
||||||
|
keep = True
|
||||||
|
review_queue = True
|
||||||
|
|
||||||
|
|
||||||
LOGS = [x for x in vars().values()
|
LOGS = [x for x in vars().values()
|
||||||
if isclass(x) and issubclass(x, _LOG) and x != _LOG]
|
if isclass(x) and issubclass(x, _LOG) and x != _LOG]
|
||||||
# Make sure there's no duplicate IDs.
|
# Make sure there's no duplicate IDs.
|
||||||
|
|
|
@ -22,12 +22,14 @@ log = commonware.log.getLogger('z.task')
|
||||||
def send_email(recipient, subject, message, from_email=None,
|
def send_email(recipient, subject, message, from_email=None,
|
||||||
html_message=None, attachments=None, real_email=False,
|
html_message=None, attachments=None, real_email=False,
|
||||||
cc=None, headers=None, fail_silently=False, async=False,
|
cc=None, headers=None, fail_silently=False, async=False,
|
||||||
max_retries=None, **kwargs):
|
max_retries=None, reply_to=None, **kwargs):
|
||||||
backend = EmailMultiAlternatives if html_message else EmailMessage
|
backend = EmailMultiAlternatives if html_message else EmailMessage
|
||||||
connection = get_email_backend(real_email)
|
connection = get_email_backend(real_email)
|
||||||
result = backend(subject, message,
|
|
||||||
from_email, recipient, cc=cc, connection=connection,
|
result = backend(subject, message, from_email, to=recipient, cc=cc,
|
||||||
headers=headers, attachments=attachments)
|
connection=connection, headers=headers,
|
||||||
|
attachments=attachments, reply_to=reply_to)
|
||||||
|
|
||||||
if html_message:
|
if html_message:
|
||||||
result.attach_alternative(html_message, 'text/html')
|
result.attach_alternative(html_message, 'text/html')
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -250,6 +250,14 @@ class TestSendMail(BaseTestCase):
|
||||||
assert headers['X-Auto-Response-Suppress'] == 'RN, NRN, OOF, AutoReply'
|
assert headers['X-Auto-Response-Suppress'] == 'RN, NRN, OOF, AutoReply'
|
||||||
assert headers['Auto-Submitted'] == 'auto-generated'
|
assert headers['Auto-Submitted'] == 'auto-generated'
|
||||||
|
|
||||||
|
def test_reply_to(self):
|
||||||
|
send_mail('subject', 'test body', from_email='a@example.com',
|
||||||
|
recipient_list=['b@example.com'], reply_to=['c@example.com'])
|
||||||
|
|
||||||
|
headers = mail.outbox[0].extra_headers
|
||||||
|
assert mail.outbox[0].reply_to == ['c@example.com']
|
||||||
|
assert headers['Auto-Submitted'] == 'auto-generated' # Still there.
|
||||||
|
|
||||||
def make_backend_class(self, error_order):
|
def make_backend_class(self, error_order):
|
||||||
throw_error = iter(error_order)
|
throw_error = iter(error_order)
|
||||||
|
|
||||||
|
|
|
@ -180,7 +180,7 @@ def send_mail(subject, message, from_email=None, recipient_list=None,
|
||||||
fail_silently=False, use_blacklist=True, perm_setting=None,
|
fail_silently=False, use_blacklist=True, perm_setting=None,
|
||||||
manage_url=None, headers=None, cc=None, real_email=False,
|
manage_url=None, headers=None, cc=None, real_email=False,
|
||||||
html_message=None, attachments=None, async=False,
|
html_message=None, attachments=None, async=False,
|
||||||
max_retries=None):
|
max_retries=None, reply_to=None):
|
||||||
"""
|
"""
|
||||||
A wrapper around django.core.mail.EmailMessage.
|
A wrapper around django.core.mail.EmailMessage.
|
||||||
|
|
||||||
|
@ -246,6 +246,7 @@ def send_mail(subject, message, from_email=None, recipient_list=None,
|
||||||
'html_message': html_message,
|
'html_message': html_message,
|
||||||
'max_retries': max_retries,
|
'max_retries': max_retries,
|
||||||
'real_email': real_email,
|
'real_email': real_email,
|
||||||
|
'reply_to': reply_to,
|
||||||
}
|
}
|
||||||
kwargs.update(options)
|
kwargs.update(options)
|
||||||
# Email subject *must not* contain newlines
|
# Email subject *must not* contain newlines
|
||||||
|
|
|
@ -16,4 +16,5 @@ urlpatterns = patterns(
|
||||||
url(r'^v3/internal/', include('olympia.internal_tools.urls')),
|
url(r'^v3/internal/', include('olympia.internal_tools.urls')),
|
||||||
url(r'^v3/', include('olympia.signing.urls')),
|
url(r'^v3/', include('olympia.signing.urls')),
|
||||||
url(r'^v3/statistics/', include('olympia.stats.api_urls')),
|
url(r'^v3/statistics/', include('olympia.stats.api_urls')),
|
||||||
|
url(r'^v3/activity', include('olympia.activity.urls')),
|
||||||
)
|
)
|
||||||
|
|
|
@ -17,6 +17,7 @@ from jingo import register
|
||||||
from olympia import amo
|
from olympia import amo
|
||||||
from olympia.access import acl
|
from olympia.access import acl
|
||||||
from olympia.access.models import GroupUser
|
from olympia.access.models import GroupUser
|
||||||
|
from olympia.activity.utils import send_activity_mail
|
||||||
from olympia.addons.helpers import new_context
|
from olympia.addons.helpers import new_context
|
||||||
from olympia.addons.models import Addon
|
from olympia.addons.models import Addon
|
||||||
from olympia.amo.helpers import absolutify, breadcrumbs, page_title
|
from olympia.amo.helpers import absolutify, breadcrumbs, page_title
|
||||||
|
@ -457,13 +458,6 @@ PENDING_STATUSES = (amo.STATUS_BETA, amo.STATUS_DISABLED, amo.STATUS_NULL,
|
||||||
amo.STATUS_PENDING, amo.STATUS_PUBLIC)
|
amo.STATUS_PENDING, amo.STATUS_PUBLIC)
|
||||||
|
|
||||||
|
|
||||||
def send_mail(template, subject, emails, context, perm_setting=None):
|
|
||||||
template = loader.get_template(template)
|
|
||||||
amo_send_mail(subject, template.render(Context(context, autoescape=False)),
|
|
||||||
recipient_list=emails, from_email=settings.EDITORS_EMAIL,
|
|
||||||
use_blacklist=False, perm_setting=perm_setting)
|
|
||||||
|
|
||||||
|
|
||||||
@register.function
|
@register.function
|
||||||
def get_position(addon):
|
def get_position(addon):
|
||||||
if addon.is_persona() and addon.is_pending():
|
if addon.is_persona() and addon.is_pending():
|
||||||
|
@ -701,9 +695,8 @@ class ReviewBase(object):
|
||||||
'details': details}
|
'details': details}
|
||||||
amo.log(action, *args, **kwargs)
|
amo.log(action, *args, **kwargs)
|
||||||
|
|
||||||
def notify_email(self, template, subject):
|
def notify_email(self, template, subject, perm_setting='editor_reviewed'):
|
||||||
"""Notify the authors that their addon has been reviewed."""
|
"""Notify the authors that their addon has been reviewed."""
|
||||||
emails = [a.email for a in self.addon.authors.all()]
|
|
||||||
data = self.data.copy() if self.data else {}
|
data = self.data.copy() if self.data else {}
|
||||||
data.update(self.get_context_data())
|
data.update(self.get_context_data())
|
||||||
data['tested'] = ''
|
data['tested'] = ''
|
||||||
|
@ -716,8 +709,20 @@ class ReviewBase(object):
|
||||||
data['tested'] = 'Tested with %s' % app
|
data['tested'] = 'Tested with %s' % app
|
||||||
subject = subject % (data['name'],
|
subject = subject % (data['name'],
|
||||||
self.version.version if self.version else '')
|
self.version.version if self.version else '')
|
||||||
send_mail('editors/emails/%s.ltxt' % template, subject,
|
|
||||||
emails, Context(data), perm_setting='editor_reviewed')
|
message = loader.get_template(
|
||||||
|
'editors/emails/%s.ltxt' % template).render(
|
||||||
|
Context(data, autoescape=False))
|
||||||
|
if not waffle.switch_is_active('activity-email'):
|
||||||
|
emails = [a.email for a in self.addon.authors.all()]
|
||||||
|
amo_send_mail(
|
||||||
|
subject, message, recipient_list=emails,
|
||||||
|
from_email=settings.EDITORS_EMAIL, use_blacklist=False,
|
||||||
|
perm_setting=perm_setting)
|
||||||
|
else:
|
||||||
|
send_activity_mail(
|
||||||
|
subject, message, self.version, self.addon.authors.all(),
|
||||||
|
settings.EDITORS_EMAIL, perm_setting)
|
||||||
|
|
||||||
def get_context_data(self):
|
def get_context_data(self):
|
||||||
addon_url = self.addon.get_url_path(add_prefix=False)
|
addon_url = self.addon.get_url_path(add_prefix=False)
|
||||||
|
@ -744,30 +749,28 @@ class ReviewBase(object):
|
||||||
|
|
||||||
def request_information(self):
|
def request_information(self):
|
||||||
"""Send a request for information to the authors."""
|
"""Send a request for information to the authors."""
|
||||||
emails = [a.email for a in self.addon.authors.all()]
|
|
||||||
self.log_action(amo.LOG.REQUEST_INFORMATION)
|
self.log_action(amo.LOG.REQUEST_INFORMATION)
|
||||||
if self.version:
|
if self.version:
|
||||||
kw = {'has_info_request': True}
|
kw = {'has_info_request': True}
|
||||||
if not self.addon.is_listed and not self.version.reviewed:
|
if not self.addon.is_listed and not self.version.reviewed:
|
||||||
kw['reviewed'] = datetime.datetime.now()
|
kw['reviewed'] = datetime.datetime.now()
|
||||||
self.version.update(**kw)
|
self.version.update(**kw)
|
||||||
log.info(u'Sending request for information for %s to %s' %
|
log.info(u'Sending request for information for %s to authors' %
|
||||||
(self.addon, emails))
|
self.addon)
|
||||||
data = self.get_context_data()
|
subject = u'Mozilla Add-ons: %s %s'
|
||||||
subject = u'Mozilla Add-ons: %s %s' % (
|
self.notify_email('info', subject, perm_setting='individual_contact')
|
||||||
data['name'], self.version.version if self.version else '')
|
|
||||||
send_mail('editors/emails/info.ltxt', subject,
|
|
||||||
emails, Context(data),
|
|
||||||
perm_setting='individual_contact')
|
|
||||||
|
|
||||||
def send_super_mail(self):
|
def send_super_mail(self):
|
||||||
self.log_action(amo.LOG.REQUEST_SUPER_REVIEW)
|
self.log_action(amo.LOG.REQUEST_SUPER_REVIEW)
|
||||||
log.info(u'Super review requested for %s' % (self.addon))
|
log.info(u'Super review requested for %s' % (self.addon))
|
||||||
data = self.get_context_data()
|
data = self.get_context_data()
|
||||||
send_mail('editors/emails/super_review.ltxt',
|
message = (loader
|
||||||
u'Super review requested: %s' % (data['name']),
|
.get_template('editors/emails/super_review.ltxt')
|
||||||
[settings.SENIOR_EDITORS_EMAIL],
|
.render(Context(data, autoescape=False)))
|
||||||
Context(data))
|
amo_send_mail(u'Super review requested: %s' % (data['name']), message,
|
||||||
|
recipient_list=[settings.SENIOR_EDITORS_EMAIL],
|
||||||
|
from_email=settings.EDITORS_EMAIL,
|
||||||
|
use_blacklist=False)
|
||||||
|
|
||||||
def process_comment(self):
|
def process_comment(self):
|
||||||
if self.version:
|
if self.version:
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.files.storage import default_storage as storage
|
from django.core.files.storage import default_storage as storage
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
|
@ -11,6 +12,7 @@ from pyquery import PyQuery as pq
|
||||||
from waffle.testutils import override_flag, override_switch
|
from waffle.testutils import override_flag, override_switch
|
||||||
|
|
||||||
from olympia import amo
|
from olympia import amo
|
||||||
|
from olympia.activity.models import ActivityLogToken
|
||||||
from olympia.amo.tests import TestCase
|
from olympia.amo.tests import TestCase
|
||||||
from olympia.addons.models import Addon
|
from olympia.addons.models import Addon
|
||||||
from olympia.amo.urlresolvers import reverse
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
@ -466,9 +468,9 @@ class TestReviewHelper(TestCase):
|
||||||
self.helper.handler.log_action(amo.LOG.APPROVE_VERSION)
|
self.helper.handler.log_action(amo.LOG.APPROVE_VERSION)
|
||||||
assert self.check_log_count(amo.LOG.APPROVE_VERSION.id) == 1
|
assert self.check_log_count(amo.LOG.APPROVE_VERSION.id) == 1
|
||||||
|
|
||||||
def test_notify_email(
|
def test_notify_email(self):
|
||||||
self, base_fragment='reply to this email or join #amo-editors'):
|
|
||||||
self.helper.set_data(self.get_data())
|
self.helper.set_data(self.get_data())
|
||||||
|
base_fragment = 'reply to this email or join #amo-editors'
|
||||||
for template in ['nominated_to_nominated', 'nominated_to_preliminary',
|
for template in ['nominated_to_nominated', 'nominated_to_preliminary',
|
||||||
'nominated_to_public', 'nominated_to_sandbox',
|
'nominated_to_public', 'nominated_to_sandbox',
|
||||||
'pending_to_preliminary', 'pending_to_public',
|
'pending_to_preliminary', 'pending_to_public',
|
||||||
|
@ -483,8 +485,26 @@ class TestReviewHelper(TestCase):
|
||||||
|
|
||||||
@override_switch('activity-email', active=True)
|
@override_switch('activity-email', active=True)
|
||||||
def test_notify_email_activity_email(self):
|
def test_notify_email_activity_email(self):
|
||||||
self.test_notify_email(
|
self.helper.set_data(self.get_data())
|
||||||
base_fragment='If you need to send file attachments')
|
base_fragment = 'If you need to send file attachments'
|
||||||
|
user = self.addon.listed_authors[0]
|
||||||
|
ActivityLogToken.objects.create(version=self.version, user=user)
|
||||||
|
uuid = self.version.token.get(user=user).uuid.hex
|
||||||
|
reply_email = (
|
||||||
|
'reviewreply+%s@%s' % (uuid, settings.INBOUND_EMAIL_DOMAIN))
|
||||||
|
|
||||||
|
for template in ['nominated_to_nominated', 'nominated_to_preliminary',
|
||||||
|
'nominated_to_public', 'nominated_to_sandbox',
|
||||||
|
'pending_to_preliminary', 'pending_to_public',
|
||||||
|
'pending_to_sandbox', 'preliminary_to_preliminary',
|
||||||
|
'author_super_review', 'unlisted_to_reviewed',
|
||||||
|
'unlisted_to_reviewed_auto',
|
||||||
|
'unlisted_to_sandbox']:
|
||||||
|
mail.outbox = []
|
||||||
|
self.helper.handler.notify_email(template, 'Sample subject %s, %s')
|
||||||
|
assert len(mail.outbox) == 1
|
||||||
|
assert base_fragment in mail.outbox[0].body
|
||||||
|
assert mail.outbox[0].reply_to == [reply_email]
|
||||||
|
|
||||||
def test_email_links(self):
|
def test_email_links(self):
|
||||||
expected = {
|
expected = {
|
||||||
|
@ -1155,11 +1175,16 @@ def test_page_title_unicode():
|
||||||
|
|
||||||
|
|
||||||
def test_send_email_autoescape():
|
def test_send_email_autoescape():
|
||||||
# Make sure HTML is not auto-escaped.
|
mock_request = Mock()
|
||||||
|
mock_request.user = None
|
||||||
|
base = helpers.ReviewBase(mock_request, None, None, '')
|
||||||
s = 'woo&&<>\'""'
|
s = 'woo&&<>\'""'
|
||||||
ctx = dict(name=s, review_url=s, reviewer=s, comments=s, SITE_URL=s)
|
ctx = dict(name=s, review_url=s, reviewer=s, comments=s, SITE_URL=s)
|
||||||
helpers.send_mail('editors/emails/super_review.ltxt',
|
base.get_context_data = Mock(name='get_context_data', return_value=ctx)
|
||||||
'aww yeah', ['xx'], ctx)
|
base.data = {'comments': ''}
|
||||||
|
|
||||||
|
# Make sure HTML is not auto-escaped.
|
||||||
|
base.send_super_mail()
|
||||||
assert len(mail.outbox) == 1
|
assert len(mail.outbox) == 1
|
||||||
assert mail.outbox[0].body.count(s) == len(ctx)
|
assert mail.outbox[0].body.count(s) == len(ctx)
|
||||||
|
|
||||||
|
|
|
@ -212,6 +212,15 @@ MOBILE_DOMAIN = 'm.%s' % DOMAIN
|
||||||
# The full url of the mobile site.
|
# The full url of the mobile site.
|
||||||
MOBILE_SITE_URL = 'http://%s' % MOBILE_DOMAIN
|
MOBILE_SITE_URL = 'http://%s' % MOBILE_DOMAIN
|
||||||
|
|
||||||
|
# Filter IP addresses of the allowed clients that can post email
|
||||||
|
# through the API.
|
||||||
|
ALLOWED_CLIENTS_EMAIL_API = env.list('ALLOWED_CLIENTS_EMAIL_API', default=[])
|
||||||
|
|
||||||
|
# Auth token required to authorize inbound email.
|
||||||
|
INBOUND_EMAIL_SECRET_KEY = env('INBOUND_EMAIL_SECRET_KEY', default='')
|
||||||
|
|
||||||
|
INBOUND_EMAIL_DOMAIN = DOMAIN
|
||||||
|
|
||||||
# Absolute path to the directory that holds media.
|
# Absolute path to the directory that holds media.
|
||||||
# Example: "/home/media/media.lawrence.com/"
|
# Example: "/home/media/media.lawrence.com/"
|
||||||
MEDIA_ROOT = path('user-media')
|
MEDIA_ROOT = path('user-media')
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
INSERT INTO waffle_switch (name, active, note, created, modified)
|
||||||
|
VALUES ('activity-email', 0, 'Review emails have "reviewreply+...@" reply-to headers.', NOW(), NOW());
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE `log_activity_tokens` (
|
||||||
|
`id` int(11) unsigned AUTO_INCREMENT NOT NULL PRIMARY KEY,
|
||||||
|
`created` datetime NOT NULL,
|
||||||
|
`modified` datetime NOT NULL,
|
||||||
|
`version_id` int(11) NOT NULL,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`uuid` char(32) NOT NULL UNIQUE,
|
||||||
|
`use_count` integer UNSIGNED NOT NULL
|
||||||
|
) DEFAULT CHARSET=utf8;
|
||||||
|
|
||||||
|
ALTER TABLE `log_activity_tokens` ADD CONSTRAINT `log_activity_tokens_user`
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`);
|
||||||
|
ALTER TABLE `log_activity_tokens` ADD CONSTRAINT `log_activity_tokens_version`
|
||||||
|
FOREIGN KEY (`version_id`) REFERENCES `versions` (`id`);
|
Загрузка…
Ссылка в новой задаче