Skip to content

Commit 9ea09b4

Browse files
authored
Add support for sending mass emails (#7528)
* Add support for sending mass emails * Accept a User.id instead of email address
1 parent 9a97225 commit 9ea09b4

File tree

5 files changed

+192
-9
lines changed

5 files changed

+192
-9
lines changed

tests/unit/admin/test_routes.py

+1
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def test_includeme():
116116
"admin.blacklist.remove", "/admin/blacklist/remove/", domain=warehouse
117117
),
118118
pretend.call("admin.emails.list", "/admin/emails/", domain=warehouse),
119+
pretend.call("admin.emails.mass", "/admin/emails/mass/", domain=warehouse),
119120
pretend.call(
120121
"admin.emails.detail", "/admin/emails/{email_id}/", domain=warehouse
121122
),

tests/unit/admin/views/test_emails.py

+126-1
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,18 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import csv
14+
import io
1315
import uuid
1416

1517
import pretend
1618
import pytest
1719

18-
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
20+
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
1921

2022
from warehouse.admin.views import emails as views
2123

24+
from ....common.db.accounts import EmailFactory, UserFactory
2225
from ....common.db.ses import EmailMessageFactory
2326

2427

@@ -73,6 +76,128 @@ def test_wildcard_query(self, db_request):
7376
assert result == {"emails": [emails[0]], "query": emails[0].to[:-1] + "%"}
7477

7578

79+
class TestEmailMass:
80+
def test_sends_emails(self, db_request):
81+
user1 = UserFactory.create()
82+
email1 = EmailFactory.create(user=user1, primary=True)
83+
user2 = UserFactory.create()
84+
email2 = EmailFactory.create(user=user2, primary=True)
85+
86+
input_file = io.BytesIO()
87+
wrapper = io.TextIOWrapper(input_file, encoding="utf-8")
88+
fieldnames = ["user_id", "subject", "body_text"]
89+
writer = csv.DictWriter(wrapper, fieldnames=fieldnames)
90+
writer.writeheader()
91+
writer.writerow(
92+
{
93+
"user_id": user1.id,
94+
"subject": "Test Subject 1",
95+
"body_text": "Test Body 1",
96+
}
97+
)
98+
writer.writerow(
99+
{
100+
"user_id": user2.id,
101+
"subject": "Test Subject 2",
102+
"body_text": "Test Body 2",
103+
}
104+
)
105+
wrapper.seek(0)
106+
107+
delay = pretend.call_recorder(lambda *a, **kw: None)
108+
db_request.params = {"csvfile": pretend.stub(file=input_file)}
109+
db_request.task = lambda a: pretend.stub(delay=delay)
110+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
111+
db_request.session = pretend.stub(
112+
flash=pretend.call_recorder(lambda *a, **kw: None)
113+
)
114+
115+
result = views.email_mass(db_request)
116+
117+
assert isinstance(result, HTTPSeeOther)
118+
assert db_request.route_path.calls == [pretend.call("admin.emails.list")]
119+
assert result.headers["Location"] == "/the-redirect"
120+
assert db_request.session.flash.calls == [
121+
pretend.call("Mass emails sent", queue="success")
122+
]
123+
assert delay.calls == [
124+
pretend.call(
125+
email1.email,
126+
{
127+
"subject": "Test Subject 1",
128+
"body_text": "Test Body 1",
129+
"body_html": None,
130+
},
131+
),
132+
pretend.call(
133+
email2.email,
134+
{
135+
"subject": "Test Subject 2",
136+
"body_text": "Test Body 2",
137+
"body_html": None,
138+
},
139+
),
140+
]
141+
142+
def test_user_without_email_sends_no_emails(self, db_request):
143+
user = UserFactory.create()
144+
145+
input_file = io.BytesIO()
146+
wrapper = io.TextIOWrapper(input_file, encoding="utf-8")
147+
fieldnames = ["user_id", "subject", "body_text"]
148+
writer = csv.DictWriter(wrapper, fieldnames=fieldnames)
149+
writer.writeheader()
150+
writer.writerow(
151+
{
152+
"user_id": user.id,
153+
"subject": "Test Subject 1",
154+
"body_text": "Test Body 1",
155+
}
156+
)
157+
wrapper.seek(0)
158+
159+
delay = pretend.call_recorder(lambda *a, **kw: None)
160+
db_request.params = {"csvfile": pretend.stub(file=input_file)}
161+
db_request.task = lambda a: pretend.stub(delay=delay)
162+
db_request.route_path = pretend.call_recorder(lambda *a, **kw: "/the-redirect")
163+
db_request.session = pretend.stub(
164+
flash=pretend.call_recorder(lambda *a, **kw: None)
165+
)
166+
167+
result = views.email_mass(db_request)
168+
169+
assert isinstance(result, HTTPSeeOther)
170+
assert db_request.route_path.calls == [pretend.call("admin.emails.list")]
171+
assert result.headers["Location"] == "/the-redirect"
172+
assert delay.calls == []
173+
174+
def test_no_rows_sends_no_emails(self):
175+
input_file = io.BytesIO()
176+
wrapper = io.TextIOWrapper(input_file, encoding="utf-8")
177+
fieldnames = ["user_id", "subject", "body_text"]
178+
writer = csv.DictWriter(wrapper, fieldnames=fieldnames)
179+
writer.writeheader()
180+
wrapper.seek(0)
181+
182+
delay = pretend.call_recorder(lambda *a, **kw: None)
183+
request = pretend.stub(
184+
params={"csvfile": pretend.stub(file=input_file)},
185+
task=lambda a: pretend.stub(delay=delay),
186+
route_path=pretend.call_recorder(lambda *a, **kw: "/the-redirect"),
187+
session=pretend.stub(flash=pretend.call_recorder(lambda *a, **kw: None)),
188+
)
189+
190+
result = views.email_mass(request)
191+
192+
assert isinstance(result, HTTPSeeOther)
193+
assert request.route_path.calls == [pretend.call("admin.emails.list")]
194+
assert result.headers["Location"] == "/the-redirect"
195+
assert request.session.flash.calls == [
196+
pretend.call("No emails to send", queue="error")
197+
]
198+
assert delay.calls == []
199+
200+
76201
class TestEmailDetail:
77202
def test_existing_email(self, db_session):
78203
em = EmailMessageFactory.create()

warehouse/admin/routes.py

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def includeme(config):
117117

118118
# Email related Admin pages
119119
config.add_route("admin.emails.list", "/admin/emails/", domain=warehouse)
120+
config.add_route("admin.emails.mass", "/admin/emails/mass/", domain=warehouse)
120121
config.add_route(
121122
"admin.emails.detail", "/admin/emails/{email_id}/", domain=warehouse
122123
)

warehouse/admin/templates/admin/emails/list.html

+28-7
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,34 @@
3636
{% endblock %}
3737

3838
{% block content %}
39-
<div class="box">
40-
<div class="box-body">
39+
<div class="box box-primary">
40+
<form method="POST" action={{ request.route_path('admin.emails.mass') }} enctype="multipart/form-data">
41+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
42+
<div class="box-header with-border">
43+
<h3 class="box-title">Send mass emails</h3>
44+
</div>
45+
<div class="box-body with-border">
46+
<p>
47+
Uploaded files should have the following header: <code>user_id,subject,body_text</code>, where <code>user_id</code> is a user's UUID, <code>subject</code> is the subject, and <code>body_text</code> is the plaintext message body.
48+
</p>
49+
<p>
50+
Including <code>body_html</code> is also supported, but optional.
51+
</p>
52+
<input name="csvfile" type="file">
53+
</div>
54+
<div class="box-footer">
55+
<div class="pull-right">
56+
<button type="submit" class="btn btn-primary">Send</button>
57+
</div>
58+
</div>
59+
</form>
60+
</div>
61+
62+
<div class="box box-primary">
63+
<div class="box-header with-border">
64+
<h3 class="box-title">Search</h3>
65+
</div>
66+
<div class="box-body with-border">
4167
<form>
4268
<div class="input-group input-group-lg">
4369
<input name="q" type="text" class="form-control input-lg" placeholder="Search"{% if query %} value="{{ query }}"{% endif %}>
@@ -46,11 +72,6 @@
4672
</div>
4773
</div>
4874
</form>
49-
</div>
50-
</div>
51-
52-
<div class="box">
53-
<div class="box-body table-responsive no-padding">
5475
<table class="table table-hover">
5576
<tr>
5677
<th></th>

warehouse/admin/views/emails.py

+36-1
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13+
import csv
14+
import io
1315
import shlex
1416

1517
from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage
16-
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound
18+
from pyramid.httpexceptions import HTTPBadRequest, HTTPNotFound, HTTPSeeOther
1719
from pyramid.view import view_config
1820
from sqlalchemy import or_
1921
from sqlalchemy.orm.exc import NoResultFound
2022

23+
from warehouse.accounts.models import User
24+
from warehouse.email import send_email
2125
from warehouse.email.ses.models import EmailMessage
2226
from warehouse.utils.paginate import paginate_url_factory
2327

@@ -60,6 +64,37 @@ def email_list(request):
6064
return {"emails": emails, "query": q}
6165

6266

67+
@view_config(
68+
route_name="admin.emails.mass",
69+
permission="admin",
70+
request_method="POST",
71+
uses_session=True,
72+
require_methods=False,
73+
)
74+
def email_mass(request):
75+
input_file = request.params["csvfile"].file
76+
wrapper = io.TextIOWrapper(input_file, encoding="utf-8")
77+
rows = list(csv.DictReader(wrapper))
78+
if rows:
79+
for row in rows:
80+
user = request.db.query(User).get(row["user_id"])
81+
email = user.primary_email
82+
83+
if email:
84+
request.task(send_email).delay(
85+
email.email,
86+
{
87+
"subject": row["subject"],
88+
"body_text": row["body_text"],
89+
"body_html": row.get("body_html"),
90+
},
91+
)
92+
request.session.flash("Mass emails sent", queue="success")
93+
else:
94+
request.session.flash("No emails to send", queue="error")
95+
return HTTPSeeOther(request.route_path("admin.emails.list"))
96+
97+
6398
@view_config(
6499
route_name="admin.emails.detail",
65100
renderer="admin/emails/detail.html",

0 commit comments

Comments
 (0)