Coverage for app / backend / src / tests / test_email.py: 100%

285 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-15 11:04 +0000

1from datetime import timedelta 

2from unittest.mock import patch 

3from urllib.parse import parse_qs, urlparse 

4 

5import pytest 

6from sqlalchemy import func, select, update 

7 

8import couchers.email 

9import couchers.jobs.handlers 

10from couchers.config import config 

11from couchers.crypto import b64decode, random_hex, urlsafe_secure_token 

12from couchers.db import session_scope 

13from couchers.models import ( 

14 ContentReport, 

15 Email, 

16 Reference, 

17 ReferenceType, 

18 SignupFlow, 

19 User, 

20) 

21from couchers.models.notifications import NotificationTopicAction 

22from couchers.notifications.notify import notify 

23from couchers.proto import api_pb2, auth_pb2, editor_pb2, events_pb2, notification_data_pb2, notifications_pb2 

24from couchers.tasks import ( 

25 enforce_community_memberships, 

26 maybe_send_reference_report_email, 

27 send_content_report_email, 

28 send_email_changed_confirmation_to_new_email, 

29 send_signup_email, 

30) 

31from couchers.utils import Timestamp_from_datetime, now 

32from tests.fixtures.db import generate_user, get_friend_relationship, make_friends 

33from tests.fixtures.misc import Moderator, email_fields, mock_notification_email, process_jobs 

34from tests.fixtures.sessions import ( 

35 api_session, 

36 auth_api_session, 

37 events_session, 

38 notifications_session, 

39 real_editor_session, 

40) 

41from tests.test_communities import create_community 

42 

43 

44@pytest.fixture(autouse=True) 

45def _(testconfig): 

46 pass 

47 

48 

49def test_signup_verification_email(db): 

50 request_email = f"{random_hex(12)}@couchers.org.invalid" 

51 

52 flow = SignupFlow(name="Frodo", email=request_email, flow_token="") 

53 

54 with session_scope() as session: 

55 with mock_notification_email() as mock: 

56 send_signup_email(session, flow) 

57 

58 assert mock.call_count == 1 

59 e = email_fields(mock) 

60 assert e.recipient == request_email 

61 assert flow.email_token 

62 assert flow.email_token in e.html 

63 assert flow.email_token in e.html 

64 

65 

66def test_report_email(db): 

67 user_reporter, api_token_author = generate_user() 

68 user_author, api_token_reported = generate_user() 

69 

70 with session_scope() as session: 

71 report = ContentReport( 

72 reporting_user_id=user_reporter.id, 

73 reason="spam", 

74 description="I think this is spam and does not belong on couchers", 

75 content_ref="comment/123", 

76 author_user_id=user_author.id, 

77 user_agent="n/a", 

78 page="https://couchers.org/comment/123", 

79 ) 

80 session.add(report) 

81 session.flush() 

82 

83 with mock_notification_email() as mock: 

84 send_content_report_email(session, report) 

85 

86 # Load all data before session closes 

87 author_username = report.author_user.username 

88 author_id = report.author_user.id 

89 author_email = report.author_user.email 

90 reporting_username = report.reporting_user.username 

91 reporting_id = report.reporting_user.id 

92 reporting_email = report.reporting_user.email 

93 reason = report.reason 

94 description = report.description 

95 

96 assert mock.call_count == 1 

97 

98 e = email_fields(mock) 

99 assert e.recipient == "reports@couchers.org.invalid" 

100 assert author_username in e.plain 

101 assert str(author_id) in e.plain 

102 assert author_email in e.plain 

103 assert reporting_username in e.plain 

104 assert str(reporting_id) in e.plain 

105 assert reporting_email in e.plain 

106 assert reason in e.plain 

107 assert description in e.plain 

108 assert "report" in e.subject.lower() 

109 

110 

111def test_reference_report_email_not_sent(db): 

112 from_user, api_token_author = generate_user() 

113 to_user, api_token_reported = generate_user() 

114 

115 make_friends(from_user, to_user) 

116 

117 with session_scope() as session: 

118 reference = Reference( 

119 from_user_id=from_user.id, 

120 to_user_id=to_user.id, 

121 reference_type=ReferenceType.friend, 

122 text="This person was very nice to me.", 

123 rating=0.9, 

124 was_appropriate=True, 

125 ) 

126 

127 # no email sent for a positive ref 

128 

129 with mock_notification_email() as mock: 

130 maybe_send_reference_report_email(session, reference) 

131 

132 assert mock.call_count == 0 

133 

134 

135def test_reference_report_email(db): 

136 from_user, api_token_author = generate_user() 

137 to_user, api_token_reported = generate_user() 

138 

139 make_friends(from_user, to_user) 

140 

141 with session_scope() as session: 

142 reference = Reference( 

143 from_user_id=from_user.id, 

144 to_user_id=to_user.id, 

145 reference_type=ReferenceType.friend, 

146 text="This person was not nice to me.", 

147 rating=0.3, 

148 was_appropriate=False, 

149 private_text="This is some private text for support", 

150 ) 

151 session.add(reference) 

152 session.flush() 

153 

154 with mock_notification_email() as mock: 

155 maybe_send_reference_report_email(session, reference) 

156 

157 assert mock.call_count == 1 

158 e = email_fields(mock) 

159 assert e.recipient == "reports@couchers.org.invalid" 

160 assert "report" in e.subject.lower() 

161 assert "reference" in e.subject.lower() 

162 assert from_user.username in e.plain 

163 assert str(from_user.id) in e.plain 

164 assert from_user.email in e.plain 

165 assert to_user.username in e.plain 

166 assert str(to_user.id) in e.plain 

167 assert to_user.email in e.plain 

168 assert reference.text in e.plain 

169 assert "friend" in e.plain.lower() 

170 assert reference.private_text 

171 assert reference.private_text in e.plain 

172 

173 

174def test_email_patching_fails(db): 

175 """ 

176 There was a problem where the mocking wasn't happening and the email dev 

177 printing function was called instead, this makes sure the patching is 

178 actually done 

179 """ 

180 to_user, to_token = generate_user() 

181 from_user, from_token = generate_user() 

182 # Need a moderator to approve the friend request since UMS defers notification 

183 mod_user, mod_token = generate_user(is_superuser=True) 

184 moderator = Moderator(mod_user, mod_token) 

185 

186 patched_msg = random_hex(64) 

187 

188 def mock_queue_email(session, **kwargs): 

189 raise Exception(patched_msg) 

190 

191 with api_session(from_token) as api: 

192 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=to_user.id)) 

193 

194 friend_relationship = get_friend_relationship(from_user, to_user) 

195 assert friend_relationship is not None 

196 moderator.approve_friend_request(friend_relationship.id) 

197 

198 with patch("couchers.email.queuing._queue_email", mock_queue_email): 

199 with pytest.raises(Exception) as e: 

200 process_jobs() 

201 

202 assert str(e.value) == patched_msg 

203 

204 

205def test_email_changed_confirmation_sent_to_new_email(db): 

206 confirmation_token = urlsafe_secure_token() 

207 user, user_token = generate_user() 

208 user.new_email = f"{random_hex(12)}@couchers.org.invalid" 

209 user.new_email_token = confirmation_token 

210 with session_scope() as session: 

211 with mock_notification_email() as mock: 

212 send_email_changed_confirmation_to_new_email(session, user) 

213 

214 assert mock.call_count == 1 

215 e = email_fields(mock) 

216 assert "new email" in e.subject 

217 assert e.recipient == user.new_email 

218 assert user.name in e.plain 

219 assert user.name in e.html 

220 assert user.email in e.plain 

221 assert user.email in e.html 

222 assert "Your old email address is" in e.plain 

223 assert "Your old email address is" in e.html 

224 assert f"http://localhost:3000/confirm-email?token={confirmation_token}" in e.plain 

225 assert f"http://localhost:3000/confirm-email?token={confirmation_token}" in e.html 

226 assert "support@couchers.org" in e.plain 

227 assert "support@couchers.org" in e.html 

228 

229 

230def test_do_not_email_security(db): 

231 user, token = generate_user() 

232 

233 password_reset_token = urlsafe_secure_token() 

234 

235 with notifications_session(token) as notifications: 

236 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=True)) 

237 

238 # make sure we still get security emails 

239 

240 with mock_notification_email() as mock: 

241 with session_scope() as session: 

242 notify( 

243 session, 

244 user_id=user.id, 

245 topic_action=NotificationTopicAction.password_reset__start, 

246 key="", 

247 data=notification_data_pb2.PasswordResetStart( 

248 password_reset_token=password_reset_token, 

249 ), 

250 ) 

251 

252 assert mock.call_count == 1 

253 e = email_fields(mock) 

254 assert e.recipient == user.email 

255 assert "reset" in e.subject.lower() 

256 assert password_reset_token in e.plain 

257 assert password_reset_token in e.html 

258 unique_string = "You asked for your password to be reset on Couchers.org." 

259 assert unique_string in e.plain 

260 assert unique_string in e.html 

261 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in e.plain 

262 assert f"http://localhost:3000/complete-password-reset?token={password_reset_token}" in e.html 

263 assert "support@couchers.org" in e.plain 

264 assert "support@couchers.org" in e.html 

265 

266 assert "/quick-link?payload=" not in e.plain 

267 assert "/quick-link?payload=" not in e.html 

268 

269 

270def test_do_not_email_non_security(db): 

271 user, token1 = generate_user(complete_profile=True) 

272 from_user, token2 = generate_user(complete_profile=True) 

273 # Need a moderator to approve the friend request since UMS defers notification 

274 mod_user, mod_token = generate_user(is_superuser=True) 

275 moderator = Moderator(mod_user, mod_token) 

276 

277 with notifications_session(token1) as notifications: 

278 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=True)) 

279 

280 with api_session(token2) as api: 

281 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user.id)) 

282 

283 friend_relationship = get_friend_relationship(from_user, user) 

284 assert friend_relationship is not None 

285 moderator.approve_friend_request(friend_relationship.id) 

286 

287 with mock_notification_email() as mock: 

288 process_jobs() 

289 

290 assert mock.call_count == 0 

291 

292 

293def test_do_not_email_non_security_unsublink(db): 

294 user, _ = generate_user(complete_profile=True) 

295 from_user, token2 = generate_user(complete_profile=True) 

296 # Need a moderator to approve the friend request since UMS defers notification 

297 mod_user, mod_token = generate_user(is_superuser=True) 

298 moderator = Moderator(mod_user, mod_token) 

299 

300 with api_session(token2) as api: 

301 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user.id)) 

302 

303 friend_relationship = get_friend_relationship(from_user, user) 

304 assert friend_relationship is not None 

305 moderator.approve_friend_request(friend_relationship.id) 

306 

307 with mock_notification_email() as mock: 

308 process_jobs() 

309 

310 assert mock.call_count == 1 

311 e = email_fields(mock) 

312 

313 assert "/quick-link?payload=" in e.plain 

314 assert "/quick-link?payload=" in e.html 

315 

316 

317def test_email_prefix_config(db, monkeypatch): 

318 user, _ = generate_user() 

319 

320 with mock_notification_email() as mock: 

321 with session_scope() as session: 

322 notify( 

323 session, 

324 user_id=user.id, 

325 topic_action=NotificationTopicAction.donation__received, 

326 key="", 

327 data=notification_data_pb2.DonationReceived( 

328 amount=20, 

329 receipt_url="https://example.com/receipt/12345", 

330 ), 

331 ) 

332 

333 assert mock.call_count == 1 

334 e = email_fields(mock) 

335 assert e.sender_name == "Couchers.org" 

336 assert e.sender_email == "notify@couchers.org.invalid" 

337 assert e.subject == "[TEST] Thank you for your donation to Couchers.org!" 

338 

339 new_config = config.copy() 

340 new_config["NOTIFICATION_EMAIL_SENDER"] = "TestCo" 

341 new_config["NOTIFICATION_EMAIL_ADDRESS"] = "testco@testing.co.invalid" 

342 new_config["NOTIFICATION_PREFIX"] = "" 

343 

344 monkeypatch.setattr(couchers.notifications.background, "config", new_config) 

345 

346 with mock_notification_email() as mock: 

347 with session_scope() as session: 

348 notify( 

349 session, 

350 user_id=user.id, 

351 topic_action=NotificationTopicAction.donation__received, 

352 key="", 

353 data=notification_data_pb2.DonationReceived( 

354 amount=20, 

355 receipt_url="https://example.com/receipt/12345", 

356 ), 

357 ) 

358 

359 assert mock.call_count == 1 

360 e = email_fields(mock) 

361 assert e.sender_name == "TestCo" 

362 assert e.sender_email == "testco@testing.co.invalid" 

363 assert e.subject == "Thank you for your donation to Couchers.org!" 

364 

365 

366def test_send_donation_email(db, monkeypatch): 

367 user, _ = generate_user(name="Testy von Test", email="testing@couchers.org.invalid") 

368 

369 new_config = config.copy() 

370 new_config["ENABLE_EMAIL"] = True 

371 

372 monkeypatch.setattr(couchers.jobs.handlers, "config", new_config) 

373 

374 with session_scope() as session: 

375 notify( 

376 session, 

377 user_id=user.id, 

378 topic_action=NotificationTopicAction.donation__received, 

379 key="", 

380 data=notification_data_pb2.DonationReceived( 

381 amount=20, 

382 receipt_url="https://example.com/receipt/12345", 

383 ), 

384 ) 

385 

386 with patch("couchers.email.smtp.smtplib.SMTP"): 

387 process_jobs() 

388 

389 with session_scope() as session: 

390 email = session.execute(select(Email)).scalar_one() 

391 assert email.subject == "[TEST] Thank you for your donation to Couchers.org!" 

392 assert ( 

393 email.plain 

394 == """Dear Testy von Test, 

395 

396Thank you so much for your donation of $20 to Couchers.org. 

397 

398Your contribution will go towards building and sustaining the Couchers.org platform and community, and is vital for our goal of a completely free and non-profit generation of couch surfing. 

399 

400 

401You can download an invoice and receipt for the donation here: 

402 

403<https://example.com/receipt/12345> 

404 

405If you have any questions about your donation, please email us at <donations@couchers.org>. 

406 

407Your generosity will help deliver the platform for everyone. 

408 

409 

410Thank you! 

411 

412Aapeli and Itsi, 

413Couchers.org Founders 

414 

415 

416--- 

417 

418This is a security email, you cannot unsubscribe from it. 

419""" 

420 ) 

421 

422 assert "Thank you so much for your donation of $20 to Couchers.org." in email.html 

423 assert email.sender_name == "Couchers.org" 

424 assert email.sender_email == "notify@couchers.org.invalid" 

425 assert email.recipient == "testing@couchers.org.invalid" 

426 assert "https://example.com/receipt/12345" in email.html 

427 assert not email.list_unsubscribe_header 

428 assert email.source_data == "testing_version/donation_received" 

429 

430 

431def test_chat_missed_messages_list_unsubscribe_header(db): 

432 """ 

433 Regression test: chat__missed_messages has key="" (it's a summary, not tied to a single chat). 

434 The List-Unsubscribe header must use a topic_action unsubscribe link, not a topic_key link. 

435 """ 

436 user, _ = generate_user() 

437 

438 with mock_notification_email() as mock: 

439 with session_scope() as session: 

440 notify( 

441 session, 

442 user_id=user.id, 

443 topic_action=NotificationTopicAction.chat__missed_messages, 

444 key="", 

445 data=notification_data_pb2.ChatMissedMessages( 

446 messages=[ 

447 notification_data_pb2.ChatMessage( 

448 author=api_pb2.User(name="Test User", user_id=2, username="testuser"), 

449 message="You missed 1 message(s) from Test User", 

450 text="Hello!", 

451 group_chat_id=99, 

452 ), 

453 ], 

454 ), 

455 ) 

456 

457 assert mock.call_count == 1 

458 e = email_fields(mock) 

459 

460 assert e.list_unsubscribe_header 

461 

462 # Extract the List-Unsubscribe URL and call the Unsubscribe endpoint 

463 url = e.list_unsubscribe_header.strip("<>") 

464 url_parts = urlparse(url) 

465 params = parse_qs(url_parts.query) 

466 

467 with auth_api_session() as (auth_api, metadata_interceptor): 

468 res = auth_api.Unsubscribe( 

469 auth_pb2.UnsubscribeReq( 

470 payload=b64decode(params["payload"][0]), 

471 sig=b64decode(params["sig"][0]), 

472 ) 

473 ) 

474 assert res.response 

475 

476 

477def test_email_deleted_users_regression(db, moderator: Moderator): 

478 """ 

479 We introduced a bug in notify v2 where we would email deleted/banned users. 

480 """ 

481 super_user, super_token = generate_user(is_superuser=True) 

482 creating_user, creating_token = generate_user(complete_profile=True) 

483 

484 normal_user, _ = generate_user() 

485 ban_user, _ = generate_user() 

486 delete_user, _ = generate_user() 

487 

488 with session_scope() as session: 

489 w = create_community(session, 0, 2, "Global Community", [super_user], [], None) 

490 mr = create_community(session, 0, 2, "Macroregion", [super_user], [], w) 

491 r = create_community(session, 0, 2, "Region", [super_user], [], mr) 

492 c_id = create_community( 

493 session, 

494 0, 

495 2, 

496 "Non-global Community", 

497 [super_user], 

498 [creating_user, normal_user, ban_user, delete_user], 

499 r, 

500 ).id 

501 

502 enforce_community_memberships() 

503 

504 start_time = now() + timedelta(hours=2) 

505 end_time = start_time + timedelta(hours=3) 

506 with events_session(creating_token) as api: 

507 res = api.CreateEvent( 

508 events_pb2.CreateEventReq( 

509 title="Dummy Title", 

510 content="Dummy content.", 

511 photo_key=None, 

512 parent_community_id=c_id, 

513 offline_information=events_pb2.OfflineEventInformation( 

514 address="Near Null Island", 

515 lat=0.1, 

516 lng=0.2, 

517 ), 

518 start_time=Timestamp_from_datetime(start_time), 

519 end_time=Timestamp_from_datetime(end_time), 

520 timezone="UTC", 

521 ) 

522 ) 

523 event_id = res.event_id 

524 assert not res.is_deleted 

525 

526 moderator.approve_event_occurrence(event_id) 

527 

528 with events_session(creating_token) as api: 

529 with mock_notification_email() as mock: 

530 api.RequestCommunityInvite(events_pb2.RequestCommunityInviteReq(event_id=event_id)) 

531 assert mock.call_count == 1 

532 

533 with real_editor_session(super_token) as editor: 

534 res = editor.ListEventCommunityInviteRequests(editor_pb2.ListEventCommunityInviteRequestsReq()) 

535 assert len(res.requests) == 1 

536 # this will count everyone 

537 assert res.requests[0].approx_users_to_notify == 5 

538 

539 with session_scope() as session: 

540 session.execute(update(User).where(User.id == ban_user.id).values(banned_at=func.now())) 

541 session.execute(update(User).where(User.id == delete_user.id).values(deleted_at=func.now())) 

542 

543 with real_editor_session(super_token) as editor: 

544 res = editor.ListEventCommunityInviteRequests(editor_pb2.ListEventCommunityInviteRequestsReq()) 

545 assert len(res.requests) == 1 

546 # should only notify creating_user, super_user and normal_user 

547 assert res.requests[0].approx_users_to_notify == 3 

548 

549 with mock_notification_email() as mock: 

550 editor.DecideEventCommunityInviteRequest( 

551 editor_pb2.DecideEventCommunityInviteRequestReq( 

552 event_community_invite_request_id=res.requests[0].event_community_invite_request_id, 

553 approve=True, 

554 ) 

555 ) 

556 

557 assert mock.call_count == 3