Coverage for app/backend/src/couchers/email/emails.py: 95%

1070 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 00:29 +0000

1""" 

2Defines data models for each email we sent out to users. 

3""" 

4 

5import re 

6from dataclasses import dataclass, replace 

7from datetime import UTC, date, datetime 

8from typing import Self, assert_never 

9 

10from markupsafe import Markup, escape 

11 

12from couchers import urls 

13from couchers.config import config 

14from couchers.constants import LATEST_RELEASE_BLOG_URL 

15from couchers.email.blocks import ( 

16 ActionBlock, 

17 EmailBase, 

18 EmailBlock, 

19 ParaBlock, 

20 QuoteBlock, 

21 UserInfo, 

22) 

23from couchers.email.locales import get_emails_i18next 

24from couchers.i18n import LocalizationContext 

25from couchers.i18n.localize import format_phone_number 

26from couchers.notifications.quick_links import generate_quick_decline_link 

27from couchers.proto import conversations_pb2, events_pb2, notification_data_pb2 

28from couchers.utils import now, to_aware_datetime 

29 

30# Common string keys 

31_do_not_reply_request_string_key = "generic.do_not_reply_request" 

32 

33# Specific email definitions 

34 

35 

36@dataclass(kw_only=True, slots=True) 

37class AccountDeletionStartedEmail(EmailBase): 

38 """Sent to a user to confirm their account deletion request.""" 

39 

40 deletion_link: str 

41 

42 @property 

43 def string_key_base(self) -> str: 

44 return "account_deletion.started" 

45 

46 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

47 builder = self._body_builder(loc_context, security_warning=True) 

48 builder.para(".request_description") 

49 builder.para(".confirmation_instructions") 

50 builder.action(self.deletion_link, ".confirm_action") 

51 return builder.build() 

52 

53 @classmethod 

54 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self: 

55 return cls( 

56 user_name=user_name, 

57 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token), 

58 ) 

59 

60 @classmethod 

61 def test_instances(cls) -> list[Self]: 

62 return [ 

63 cls( 

64 user_name="Alice", 

65 deletion_link="https://couchers.org/delete-account?token=xxx", 

66 ) 

67 ] 

68 

69 

70@dataclass(kw_only=True, slots=True) 

71class AccountDeletionCompletedEmail(EmailBase): 

72 """Sent to a user after their account has been deleted.""" 

73 

74 undelete_link: str 

75 days: int 

76 

77 @property 

78 def string_key_base(self) -> str: 

79 return "account_deletion.completed" 

80 

81 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

82 builder = self._body_builder(loc_context, security_warning=True) 

83 builder.para(".confirmation") 

84 builder.para(".farewell") 

85 builder.para(".recovery_instructions_days", {"count": self.days}) 

86 builder.action(self.undelete_link, ".recover_action") 

87 return builder.build() 

88 

89 @classmethod 

90 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self: 

91 return cls( 

92 user_name=user_name, 

93 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token), 

94 days=data.undelete_days, 

95 ) 

96 

97 @classmethod 

98 def test_instances(cls) -> list[Self]: 

99 return [ 

100 cls( 

101 user_name="Alice", 

102 undelete_link="https://couchers.org/recover-account?token=xxx", 

103 days=30, 

104 ) 

105 ] 

106 

107 

108@dataclass(kw_only=True, slots=True) 

109class AccountDeletionRecoveredEmail(EmailBase): 

110 """Sent to a user after their account deletion has been cancelled.""" 

111 

112 @property 

113 def string_key_base(self) -> str: 

114 return "account_deletion.recovered" 

115 

116 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

117 builder = self._body_builder(loc_context, security_warning=True) 

118 builder.para(".confirmation") 

119 builder.para(".login_instructions") 

120 builder.action(urls.app_link(), ".login_action") 

121 builder.para(".redelete_instructions") 

122 return builder.build() 

123 

124 @classmethod 

125 def test_instances(cls) -> list[Self]: 

126 return [cls(user_name="Alice")] 

127 

128 

129@dataclass(kw_only=True, slots=True) 

130class ActivenessProbeEmail(EmailBase): 

131 """Sent to a host to check if they are still open to hosting.""" 

132 

133 days_left: int 

134 

135 @property 

136 def string_key_base(self) -> str: 

137 return "activeness_probe" 

138 

139 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

140 builder = self._body_builder(loc_context) 

141 builder.para(".body") 

142 builder.para(".instructions_days", {"count": self.days_left}) 

143 builder.action(urls.app_link(), ".login_action") 

144 builder.para(".encouragement") 

145 

146 # Extract major.minor from the version string. "v1.3.18927" -> "1.3" 

147 version = config.VERSION 

148 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true

149 version = version_match[1] 

150 

151 builder.para(".latest_release", {"version": version}) 

152 builder.action(LATEST_RELEASE_BLOG_URL, ".read_blog_action") 

153 return builder.build() 

154 

155 @classmethod 

156 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self: 

157 days_left = (to_aware_datetime(data.deadline) - now()).days 

158 return cls(user_name=user_name, days_left=days_left) 

159 

160 @classmethod 

161 def test_instances(cls) -> list[Self]: 

162 return [cls(user_name="Alice", days_left=7)] 

163 

164 

165@dataclass(kw_only=True, slots=True) 

166class APIKeyIssuedEmail(EmailBase): 

167 """Sent to a user to notify them that their API key was issued.""" 

168 

169 api_key: str 

170 expiry: datetime 

171 

172 @property 

173 def string_key_base(self) -> str: 

174 return "api_key_issued" 

175 

176 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

177 builder = self._body_builder(loc_context, security_warning=True) 

178 builder.para(".header") 

179 builder.quote(self.api_key, markdown=False) 

180 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)}) 

181 builder.para(".usage_warning") 

182 builder.para(".policy_warning") 

183 return builder.build() 

184 

185 @classmethod 

186 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self: 

187 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC)) 

188 

189 @classmethod 

190 def test_instances(cls) -> list[Self]: 

191 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))] 

192 

193 

194@dataclass(kw_only=True, slots=True) 

195class BadgeChangedEmail(EmailBase): 

196 """Sent to a user to notify them that a badge was added or removed from their profile.""" 

197 

198 badge_name: str 

199 added: bool 

200 

201 @property 

202 def string_key_base(self) -> str: 

203 return "badges.added" if self.added else "badges.removed" 

204 

205 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

206 return self._localize(loc_context, ".subject", {"name": self.badge_name}) 

207 

208 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

209 builder = self._body_builder(loc_context) 

210 builder.para(".body", {"name": self.badge_name}) 

211 return builder.build() 

212 

213 @classmethod 

214 def from_notification( 

215 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str 

216 ) -> Self: 

217 return cls( 

218 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd) 

219 ) 

220 

221 @classmethod 

222 def test_instances(cls) -> list[Self]: 

223 prototype = cls(user_name="Alice", badge_name="Founder", added=True) 

224 return [replace(prototype, added=True), replace(prototype, added=False)] 

225 

226 

227@dataclass(kw_only=True, slots=True) 

228class BirthdateChangedEmail(EmailBase): 

229 """Sent to a user to notify them that their birthdate was changed.""" 

230 

231 new_birthdate: date 

232 

233 @property 

234 def string_key_base(self) -> str: 

235 return "birthdate_changed" 

236 

237 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

238 builder = self._body_builder(loc_context, security_warning=True) 

239 builder.para(".body", {"date": loc_context.localize_date(self.new_birthdate)}) 

240 return builder.build() 

241 

242 @classmethod 

243 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self: 

244 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate)) 

245 

246 @classmethod 

247 def test_instances(cls) -> list[Self]: 

248 return [ 

249 cls( 

250 user_name="Alice", 

251 new_birthdate=date(1990, 1, 1), 

252 ) 

253 ] 

254 

255 

256@dataclass(kw_only=True, slots=True) 

257class ChatMessageReceivedEmail(EmailBase): 

258 """Sent to a user when they receive a new chat message.""" 

259 

260 group_chat_title: str | None # None if direct message 

261 author: UserInfo 

262 text: str 

263 view_url: str 

264 

265 @property 

266 def string_key_base(self) -> str: 

267 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}" 

268 

269 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

270 return self._localize( 

271 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""} 

272 ) 

273 

274 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

275 builder = self._body_builder(loc_context) 

276 builder.para(".body", {"author": self.author.name, "group": self.group_chat_title or ""}) 

277 builder.user(self.author) 

278 builder.quote(self.text, markdown=False) 

279 builder.action(self.view_url, ".view_action") 

280 return builder.build() 

281 

282 @classmethod 

283 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self: 

284 return cls( 

285 user_name, 

286 author=UserInfo.from_protobuf(data.author), 

287 text=data.text, 

288 group_chat_title=data.group_chat_title or None, 

289 view_url=urls.chat_link(chat_id=data.group_chat_id), 

290 ) 

291 

292 @classmethod 

293 def test_instances(cls) -> list[Self]: 

294 prototype = cls( 

295 user_name="Alice", 

296 group_chat_title=None, 

297 author=UserInfo.dummy_bob(), 

298 text="Hi Alice!", 

299 view_url="https://couchers.org/messages/chats/123", 

300 ) 

301 return [ 

302 replace(prototype, group_chat_title=None), 

303 replace(prototype, group_chat_title="Best friends"), 

304 ] 

305 

306 

307@dataclass(kw_only=True, slots=True) 

308class ChatMessagesMissedEmail(EmailBase): 

309 """Sent to a user after they've missed new chat messages.""" 

310 

311 @dataclass(kw_only=True, slots=True) 

312 class Entry: 

313 """Entry for each chat with missed messages.""" 

314 

315 group_chat_title: str | None # None if direct message 

316 missed_count: int 

317 latest_message_author: UserInfo 

318 latest_message_text: str 

319 view_url: str 

320 

321 entries: list[Entry] 

322 

323 @property 

324 def string_key_base(self) -> str: 

325 return "chat_messages.missed" 

326 

327 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

328 return self._localize(loc_context, ".subject") 

329 

330 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

331 builder = self._body_builder(loc_context) 

332 for entry in self.entries: 

333 if entry.group_chat_title is None: 

334 builder.para(".in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name}) 

335 else: 

336 builder.para(".in_group", {"count": entry.missed_count, "group": entry.group_chat_title}) 

337 builder.user(entry.latest_message_author) 

338 builder.quote(entry.latest_message_text, markdown=False) 

339 builder.action(entry.view_url, ".view_action") 

340 return builder.build() 

341 

342 @classmethod 

343 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self: 

344 missed_entries = [ 

345 cls.Entry( 

346 group_chat_title=message.group_chat_title or None, 

347 missed_count=message.unseen_count, 

348 latest_message_author=UserInfo.from_protobuf(message.author), 

349 latest_message_text=message.text, 

350 view_url=urls.chat_link(chat_id=message.group_chat_id), 

351 ) 

352 for message in data.messages 

353 ] 

354 

355 return cls(user_name, entries=missed_entries) 

356 

357 @classmethod 

358 def test_instances(cls) -> list[Self]: 

359 entry_prototype = ChatMessagesMissedEmail.Entry( 

360 group_chat_title=None, 

361 missed_count=1, 

362 latest_message_author=UserInfo.dummy_bob(), 

363 latest_message_text="Hello!", 

364 view_url="https://couchers.org/messages/chats/123", 

365 ) 

366 return [ 

367 cls( 

368 user_name="Alice", 

369 entries=[ 

370 replace(entry_prototype, group_chat_title=None), 

371 replace(entry_prototype, group_chat_title="Best friends"), 

372 ], 

373 ) 

374 ] 

375 

376 

377@dataclass(kw_only=True, slots=True) 

378class DiscussionCreatedEmail(EmailBase): 

379 """Sent to a user when a new discussion is created in a community they follow.""" 

380 

381 author: UserInfo 

382 title: str 

383 parent_context: str # Community or group name 

384 markdown_text: str 

385 view_link: str 

386 

387 @property 

388 def string_key_base(self) -> str: 

389 return "discussions.created" 

390 

391 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

392 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title}) 

393 

394 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

395 builder = self._body_builder(loc_context) 

396 builder.para( 

397 ".body", 

398 { 

399 "author": self.author.name, 

400 "title": self.title, 

401 "parent_context": self.parent_context, 

402 }, 

403 ) 

404 builder.user(self.author) 

405 builder.quote(self.markdown_text, markdown=True) 

406 builder.action(self.view_link, ".view_action") 

407 return builder.build() 

408 

409 @classmethod 

410 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self: 

411 discussion = data.discussion 

412 return cls( 

413 user_name=user_name, 

414 author=UserInfo.from_protobuf(data.author), 

415 title=discussion.title, 

416 parent_context=discussion.owner_title, 

417 markdown_text=discussion.content, 

418 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug), 

419 ) 

420 

421 @classmethod 

422 def test_instances(cls) -> list[Self]: 

423 return [ 

424 cls( 

425 user_name="Alice", 

426 author=UserInfo.dummy_bob(), 

427 title="Best hiking trails near Berlin", 

428 parent_context="Berlin", 

429 markdown_text="I've been exploring the area and found some **great** spots...", 

430 view_link="https://couchers.org/discussions/123", 

431 ) 

432 ] 

433 

434 

435@dataclass(kw_only=True, slots=True) 

436class DiscussionCommentEmail(EmailBase): 

437 """Sent to a user when someone comments on a discussion they follow.""" 

438 

439 author: UserInfo 

440 discussion_title: str 

441 discussion_parent_context: str # Community or group name 

442 markdown_text: str 

443 view_link: str 

444 

445 @property 

446 def string_key_base(self) -> str: 

447 return "discussions.comment" 

448 

449 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

450 return self._localize( 

451 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title} 

452 ) 

453 

454 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

455 builder = self._body_builder(loc_context) 

456 builder.para( 

457 ".body", 

458 { 

459 "author": self.author.name, 

460 "discussion_title": self.discussion_title, 

461 "parent_context": self.discussion_parent_context, 

462 }, 

463 ) 

464 builder.user(self.author) 

465 builder.quote(self.markdown_text, markdown=True) 

466 builder.action(self.view_link, ".view_action") 

467 return builder.build() 

468 

469 @classmethod 

470 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self: 

471 discussion = data.discussion 

472 return cls( 

473 user_name=user_name, 

474 author=UserInfo.from_protobuf(data.author), 

475 discussion_title=discussion.title, 

476 discussion_parent_context=discussion.owner_title, 

477 markdown_text=data.reply.content, 

478 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug), 

479 ) 

480 

481 @classmethod 

482 def test_instances(cls) -> list[Self]: 

483 return [ 

484 cls( 

485 user_name="Alice", 

486 author=UserInfo.dummy_bob(), 

487 discussion_title="Best hiking trails near Berlin", 

488 discussion_parent_context="Berlin", 

489 markdown_text="Great recommendations, I also **love** the Grünewald forest!", 

490 view_link="https://couchers.org/discussions/123", 

491 ) 

492 ] 

493 

494 

495@dataclass(kw_only=True, slots=True) 

496class DonationReceivedEmail(EmailBase): 

497 """Sent to a user to thank them for a donation.""" 

498 

499 amount: int 

500 receipt_url: str 

501 

502 @property 

503 def string_key_base(self) -> str: 

504 return "donation_received" 

505 

506 def get_preview_line(self, loc_context: LocalizationContext) -> str: 

507 return self._localize(loc_context, ".thanks_amount", {"amount": self.amount}) 

508 

509 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

510 builder = self._body_builder(loc_context, standard_closing=False) 

511 builder.para(".thanks_amount", {"amount": self.amount}) 

512 builder.para(".purpose") 

513 builder.para(".invoice_receipt_info") 

514 builder.action(self.receipt_url, ".download_invoice") 

515 builder.para(".tax_acknowledgment") 

516 builder.para(".questions_contact") 

517 builder.para(".generosity_helps") 

518 builder.para(".thank_you") 

519 builder.para("generic.founders_signature") 

520 return builder.build() 

521 

522 @classmethod 

523 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self: 

524 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url) 

525 

526 @classmethod 

527 def test_instances(cls) -> list[Self]: 

528 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")] 

529 

530 

531@dataclass(kw_only=True, slots=True) 

532class EmailChangedEmail(EmailBase): 

533 """Sent to a user to notify them that their email address was changed.""" 

534 

535 new_email: str 

536 

537 @property 

538 def string_key_base(self) -> str: 

539 return "email_change.initiated" 

540 

541 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

542 builder = self._body_builder(loc_context, security_warning=True) 

543 builder.para(".body", {"email_address": self.new_email}) 

544 return builder.build() 

545 

546 @classmethod 

547 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self: 

548 return cls(user_name=user_name, new_email=data.new_email) 

549 

550 @classmethod 

551 def test_instances(cls) -> list[Self]: 

552 return [cls(user_name="Alice", new_email="alice@example.com")] 

553 

554 

555@dataclass(kw_only=True, slots=True) 

556class EmailChangeConfirmationEmail(EmailBase): 

557 """Sent to a user to confirm their new email address.""" 

558 

559 old_email: str 

560 confirm_url: str 

561 

562 @property 

563 def string_key_base(self) -> str: 

564 return "email_change.confirmation" 

565 

566 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

567 builder = self._body_builder(loc_context, security_warning=True) 

568 builder.para(".context", {"old_email": self.old_email}) 

569 builder.para(".instructions") 

570 builder.action(self.confirm_url, ".confirm_action") 

571 return builder.build() 

572 

573 @classmethod 

574 def test_instances(cls) -> list[Self]: 

575 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")] 

576 

577 

578@dataclass(kw_only=True, slots=True) 

579class EmailVerifiedEmail(EmailBase): 

580 """Sent to a user to notify them that their new email address has been verified.""" 

581 

582 @property 

583 def string_key_base(self) -> str: 

584 return "email_change.verified" 

585 

586 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

587 builder = self._body_builder(loc_context, security_warning=True) 

588 builder.para(".body") 

589 return builder.build() 

590 

591 @classmethod 

592 def test_instances(cls) -> list[Self]: 

593 return [cls(user_name="Alice")] 

594 

595 

596@dataclass(kw_only=True, slots=True) 

597class EventInfo: 

598 """Common display fields for an event, extracted from its proto representation.""" 

599 

600 title: str 

601 start_time: datetime 

602 end_time: datetime 

603 online_link: str | None 

604 address: str | None 

605 view_url: str 

606 description_markdown: str 

607 

608 def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock: 

609 # TODO(#8695): Support localized time ranges 

610 start_time_display = loc_context.localize_datetime(self.start_time, with_year=False, with_day_of_week=True) 

611 end_time_display = loc_context.localize_datetime(self.end_time, with_year=False, with_day_of_week=True) 

612 time_range_display = f"{start_time_display} - {end_time_display}" 

613 

614 # Format the following. Only "Online" is translated and it's on its own line, 

615 # so the string concatenation is fine. 

616 # **<title>** 

617 # <datetime-range> 

618 # *<address> / [Online](<online_link>)* 

619 html = f"<b>{escape(self.title)}</b>" 

620 html += "<br>" 

621 html += time_range_display 

622 if self.online_link: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true

623 html += "<br>" 

624 online_link_text = get_emails_i18next().localize("events.generic.online_link", loc_context.locale) 

625 html += f'<i><a href="{escape(self.online_link)}">{escape(online_link_text)}</a></i>' 

626 elif self.address: 

627 html += "<br>" 

628 html += f"<i>{escape(self.address)}</i>" 

629 

630 return ParaBlock(text=Markup(html)) 

631 

632 def get_description_block(self) -> EmailBlock: 

633 return QuoteBlock(text=Markup(self.description_markdown), markdown=True) 

634 

635 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock: 

636 view_action_text = get_emails_i18next().localize("events.generic.view_action", loc_context.locale) 

637 return ActionBlock(text=view_action_text, target_url=self.view_url) 

638 

639 @classmethod 

640 def from_proto(cls, event: events_pb2.Event) -> EventInfo: 

641 return cls( 

642 title=event.title, 

643 start_time=event.start_time.ToDatetime(tzinfo=UTC), 

644 end_time=event.end_time.ToDatetime(tzinfo=UTC), 

645 online_link=event.online_information.link or None, 

646 address=event.offline_information.address or None, 

647 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug), 

648 description_markdown=event.content or "", 

649 ) 

650 

651 @staticmethod 

652 def dummy() -> EventInfo: 

653 return EventInfo( 

654 title="Berlin Meetup", 

655 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC), 

656 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC), 

657 online_link=None, 

658 address="Alexanderplatz, Berlin", 

659 view_url="https://couchers.org/events/123/berlin-community-meetup", 

660 description_markdown="Come join us for our monthly meetup!", 

661 ) 

662 

663 

664@dataclass(kw_only=True, slots=True) 

665class EventCreatedEmail(EmailBase): 

666 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any).""" 

667 

668 inviting_user: UserInfo 

669 event_info: EventInfo 

670 community_name: str | None 

671 community_url: str | None 

672 is_invite: bool # True = create_approved (invitation), False = create_any 

673 

674 @property 

675 def string_key_base(self) -> str: 

676 return f"events.created.{'invitation' if self.is_invite else 'notification'}" 

677 

678 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

679 return self._localize( 

680 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title} 

681 ) 

682 

683 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

684 builder = self._body_builder(loc_context) 

685 if self.community_name: 

686 builder.para(".body_with_community", {"community": self.community_name}) 

687 else: 

688 builder.para(".body_no_community") 

689 builder.block(self.event_info.get_details_block(loc_context)) 

690 builder.user(self.inviting_user) 

691 builder.block(self.event_info.get_description_block()) 

692 builder.block(self.event_info.get_view_action_block(loc_context)) 

693 return builder.build() 

694 

695 @classmethod 

696 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self: 

697 has_community = bool(data.in_community.community_id) 

698 community_url = ( 

699 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug) 

700 if has_community 

701 else None 

702 ) 

703 return cls( 

704 user_name=user_name, 

705 inviting_user=UserInfo.from_protobuf(data.inviting_user), 

706 event_info=EventInfo.from_proto(data.event), 

707 community_name=data.in_community.name if has_community else None, 

708 community_url=community_url, 

709 is_invite=is_invite, 

710 ) 

711 

712 @classmethod 

713 def test_instances(cls) -> list[Self]: 

714 prototype = cls( 

715 user_name="Alice", 

716 inviting_user=UserInfo.dummy_bob(), 

717 event_info=EventInfo.dummy(), 

718 community_name="Berlin", 

719 community_url="https://couchers.org/community/1/berlin-community", 

720 is_invite=True, 

721 ) 

722 return [ 

723 replace(prototype, is_invite=True), 

724 replace(prototype, is_invite=True, community_name=None, community_url=None), 

725 replace(prototype, is_invite=False), 

726 replace(prototype, is_invite=False, community_name=None, community_url=None), 

727 ] 

728 

729 

730@dataclass(kw_only=True, slots=True) 

731class EventUpdatedEmail(EmailBase): 

732 """Sent to subscribers when an event is updated.""" 

733 

734 updating_user: UserInfo 

735 event_info: EventInfo 

736 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] 

737 

738 @property 

739 def string_key_base(self) -> str: 

740 return "events.updated" 

741 

742 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

743 return self._localize( 

744 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title} 

745 ) 

746 

747 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

748 builder = self._body_builder(loc_context) 

749 builder.para(".body") 

750 

751 updated_items_string_keys = list( 

752 filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items)) 

753 ) 

754 if updated_items_string_keys: 

755 updated_items_text = loc_context.localize_list( 

756 [self._localize(loc_context, key) for key in updated_items_string_keys] 

757 ) 

758 builder.para(".updated_items", {"items_list": updated_items_text}) 

759 else: 

760 builder.para(".updated_generic") 

761 

762 builder.block(self.event_info.get_details_block(loc_context)) 

763 builder.user(self.updating_user) 

764 builder.block(self.event_info.get_description_block()) 

765 builder.block(self.event_info.get_view_action_block(loc_context)) 

766 return builder.build() 

767 

768 @classmethod 

769 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self: 

770 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = [] 

771 if data.updated_enum_items: 771 ↛ 773line 771 didn't jump to line 773 because the condition on line 771 was always true

772 updated_items.extend(data.updated_enum_items) 

773 elif data.updated_str_items: 

774 for updated_str_item in data.updated_str_items: 

775 if updated_enum_item := cls._updated_item_str_to_enum(updated_str_item): 

776 updated_items.append(updated_enum_item) 

777 

778 return cls( 

779 user_name=user_name, 

780 updating_user=UserInfo.from_protobuf(data.updating_user), 

781 event_info=EventInfo.from_proto(data.event), 

782 updated_items=updated_items, 

783 ) 

784 

785 # TODO(#9117): Backcompat. Remove update_str_items fallback once known unused. 

786 @staticmethod 

787 def _updated_item_str_to_enum(value: str) -> notification_data_pb2.EventUpdateItem.ValueType | None: 

788 match value: 

789 case "title": 

790 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE 

791 case "content": 

792 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT 

793 case "location": 

794 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION 

795 case "start time": 

796 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME 

797 case "end time": 

798 return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME 

799 case _: 

800 return None 

801 

802 @staticmethod 

803 def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None: 

804 match value: 

805 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE: 

806 return ".item_names.title" 

807 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT: 

808 return ".item_names.content" 

809 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION: 

810 return ".item_names.location" 

811 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME: 

812 return ".item_names.start_time" 

813 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME: 

814 return ".item_names.end_time" 

815 case _: 

816 return None 

817 

818 @classmethod 

819 def test_instances(cls) -> list[Self]: 

820 prototype = cls( 

821 user_name="Alice", updating_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy(), updated_items=[] 

822 ) 

823 return [ 

824 replace(prototype, updated_items=[]), 

825 replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()), 

826 ] 

827 

828 

829@dataclass(kw_only=True, slots=True) 

830class EventOrganizerInvitedEmail(EmailBase): 

831 """Sent when a user is invited to co-organize an event.""" 

832 

833 inviting_user: UserInfo 

834 event_info: EventInfo 

835 

836 @property 

837 def string_key_base(self) -> str: 

838 return "events.organizer_invited" 

839 

840 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

841 return self._localize( 

842 loc_context, 

843 ".subject", 

844 {"user": self.inviting_user.name, "title": self.event_info.title}, 

845 ) 

846 

847 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

848 builder = self._body_builder(loc_context) 

849 builder.para(".body", {"user": self.inviting_user.name, "title": self.event_info.title}) 

850 builder.block(self.event_info.get_details_block(loc_context)) 

851 builder.user(self.inviting_user, comment_key=".user_card_text") 

852 builder.block(self.event_info.get_description_block()) 

853 builder.block(self.event_info.get_view_action_block(loc_context)) 

854 builder.para(_do_not_reply_request_string_key) 

855 return builder.build() 

856 

857 @classmethod 

858 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self: 

859 return cls( 

860 user_name=user_name, 

861 inviting_user=UserInfo.from_protobuf(data.inviting_user), 

862 event_info=EventInfo.from_proto(data.event), 

863 ) 

864 

865 @classmethod 

866 def test_instances(cls) -> list[Self]: 

867 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())] 

868 

869 

870@dataclass(kw_only=True, slots=True) 

871class EventCommentEmail(EmailBase): 

872 """Sent to subscribers when someone comments on an event.""" 

873 

874 author: UserInfo 

875 event_info: EventInfo 

876 comment_markdown: str 

877 

878 @property 

879 def string_key_base(self) -> str: 

880 return "events.comment" 

881 

882 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

883 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title}) 

884 

885 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

886 builder = self._body_builder(loc_context) 

887 builder.para(".body", {"author": self.author.name, "title": self.event_info.title}) 

888 builder.user(self.author) 

889 builder.quote(self.comment_markdown, markdown=True) 

890 builder.para(".event_details") 

891 builder.block(self.event_info.get_details_block(loc_context)) 

892 builder.block(self.event_info.get_view_action_block(loc_context)) 

893 return builder.build() 

894 

895 @classmethod 

896 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self: 

897 return cls( 

898 user_name=user_name, 

899 author=UserInfo.from_protobuf(data.author), 

900 event_info=EventInfo.from_proto(data.event), 

901 comment_markdown=data.reply.content, 

902 ) 

903 

904 @classmethod 

905 def test_instances(cls) -> list[Self]: 

906 return [ 

907 cls( 

908 user_name="Alice", 

909 author=UserInfo.dummy_bob(), 

910 event_info=EventInfo.dummy(), 

911 comment_markdown="Looking forward to it, see you all there!", 

912 ) 

913 ] 

914 

915 

916@dataclass(kw_only=True, slots=True) 

917class EventReminderEmail(EmailBase): 

918 """Sent to subscribers as a reminder that an event starts soon.""" 

919 

920 event_info: EventInfo 

921 

922 @property 

923 def string_key_base(self) -> str: 

924 return "events.reminder" 

925 

926 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

927 return self._localize(loc_context, ".subject", {"title": self.event_info.title}) 

928 

929 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

930 builder = self._body_builder(loc_context) 

931 builder.para(".body") 

932 builder.block(self.event_info.get_details_block(loc_context)) 

933 builder.block(self.event_info.get_description_block()) 

934 builder.block(self.event_info.get_view_action_block(loc_context)) 

935 return builder.build() 

936 

937 @classmethod 

938 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self: 

939 return cls( 

940 user_name=user_name, 

941 event_info=EventInfo.from_proto(data.event), 

942 ) 

943 

944 @classmethod 

945 def test_instances(cls) -> list[Self]: 

946 return [cls(user_name="Alice", event_info=EventInfo.dummy())] 

947 

948 

949@dataclass(kw_only=True, slots=True) 

950class EventCancelledEmail(EmailBase): 

951 """Sent to subscribers when an event is cancelled.""" 

952 

953 cancelling_user: UserInfo 

954 event_info: EventInfo 

955 

956 @property 

957 def string_key_base(self) -> str: 

958 return "events.cancel" 

959 

960 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

961 return self._localize( 

962 loc_context, 

963 ".subject", 

964 {"user": self.cancelling_user.name, "title": self.event_info.title}, 

965 ) 

966 

967 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

968 builder = self._body_builder(loc_context) 

969 builder.para(".body") 

970 builder.block(self.event_info.get_details_block(loc_context)) 

971 builder.user(self.cancelling_user, ".user_card_text") 

972 builder.quote(self.event_info.description_markdown, markdown=True) 

973 builder.block(self.event_info.get_view_action_block(loc_context)) 

974 return builder.build() 

975 

976 @classmethod 

977 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self: 

978 return cls( 

979 user_name=user_name, 

980 cancelling_user=UserInfo.from_protobuf(data.cancelling_user), 

981 event_info=EventInfo.from_proto(data.event), 

982 ) 

983 

984 @classmethod 

985 def test_instances(cls) -> list[Self]: 

986 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())] 

987 

988 

989@dataclass(kw_only=True, slots=True) 

990class EventDeletedEmail(EmailBase): 

991 """Sent to subscribers when a moderator deletes an event.""" 

992 

993 event_info: EventInfo 

994 

995 @property 

996 def string_key_base(self) -> str: 

997 return "events.deleted" 

998 

999 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1000 return self._localize(loc_context, ".subject", {"title": self.event_info.title}) 

1001 

1002 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1003 builder = self._body_builder(loc_context) 

1004 builder.para(".body") 

1005 builder.block(self.event_info.get_details_block(loc_context)) 

1006 return builder.build() 

1007 

1008 @classmethod 

1009 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self: 

1010 return cls( 

1011 user_name=user_name, 

1012 event_info=EventInfo.from_proto(data.event), 

1013 ) 

1014 

1015 @classmethod 

1016 def test_instances(cls) -> list[Self]: 

1017 return [ 

1018 cls( 

1019 user_name="Alice", 

1020 event_info=EventInfo.dummy(), 

1021 ) 

1022 ] 

1023 

1024 

1025@dataclass(kw_only=True, slots=True) 

1026class FriendReferenceReceivedEmail(EmailBase): 

1027 """Sent to a user when they receive a friend reference.""" 

1028 

1029 from_user: UserInfo 

1030 text: str 

1031 

1032 @property 

1033 def string_key_base(self) -> str: 

1034 return "references.received.friend" 

1035 

1036 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1037 return self._localize(loc_context, ".subject", {"name": self.from_user.name}) 

1038 

1039 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1040 builder = self._body_builder(loc_context) 

1041 builder.para(".body", {"name": self.from_user.name}) 

1042 builder.user(self.from_user) 

1043 builder.quote(self.text, markdown=False) 

1044 builder.action(urls.profile_references_link(), "references.received.view_action") 

1045 return builder.build() 

1046 

1047 @classmethod 

1048 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self: 

1049 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text) 

1050 

1051 @classmethod 

1052 def test_instances(cls) -> list[Self]: 

1053 return [ 

1054 cls( 

1055 user_name="Alice", 

1056 from_user=UserInfo.dummy_bob(), 

1057 text="Alice is a wonderful person and a great travel companion!", 

1058 ) 

1059 ] 

1060 

1061 

1062@dataclass(kw_only=True, slots=True) 

1063class FriendRequestReceivedEmail(EmailBase): 

1064 """Sent to a user when they receive a friend request.""" 

1065 

1066 befriender: UserInfo 

1067 

1068 @property 

1069 def string_key_base(self) -> str: 

1070 return "friend_requests.received" 

1071 

1072 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1073 return self._localize(loc_context, ".subject", {"name": self.befriender.name}) 

1074 

1075 def get_preview_line(self, loc_context: LocalizationContext) -> str: 

1076 return self._localize(loc_context, ".body", {"name": self.befriender.name}) 

1077 

1078 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1079 builder = self._body_builder(loc_context) 

1080 builder.para(".body", {"name": self.befriender.name}) 

1081 builder.user(self.befriender) 

1082 builder.action(urls.friend_requests_link(), ".view_action") 

1083 builder.para(".closing") 

1084 builder.para(_do_not_reply_request_string_key) 

1085 return builder.build() 

1086 

1087 @classmethod 

1088 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self: 

1089 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user)) 

1090 

1091 @classmethod 

1092 def test_instances(cls) -> list[Self]: 

1093 return [ 

1094 cls( 

1095 user_name="Alice", 

1096 befriender=UserInfo.dummy_bob(), 

1097 ) 

1098 ] 

1099 

1100 

1101@dataclass(kw_only=True, slots=True) 

1102class FriendRequestAcceptedEmail(EmailBase): 

1103 """Sent to a user when their friend request is accepted.""" 

1104 

1105 new_friend: UserInfo 

1106 

1107 @property 

1108 def string_key_base(self) -> str: 

1109 return "friend_requests.accepted" 

1110 

1111 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1112 return self._localize(loc_context, ".subject", {"name": self.new_friend.name}) 

1113 

1114 def get_preview_line(self, loc_context: LocalizationContext) -> str: 

1115 return self._localize(loc_context, ".body", {"name": self.new_friend.name}) 

1116 

1117 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1118 builder = self._body_builder(loc_context) 

1119 builder.para(".body", {"name": self.new_friend.name}) 

1120 builder.user(self.new_friend) 

1121 builder.action(self.new_friend.profile_url, ".view_action") 

1122 builder.para(".closing") 

1123 return builder.build() 

1124 

1125 @classmethod 

1126 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self: 

1127 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user)) 

1128 

1129 @classmethod 

1130 def test_instances(cls) -> list[Self]: 

1131 return [ 

1132 cls( 

1133 user_name="Alice", 

1134 new_friend=UserInfo.dummy_bob(), 

1135 ) 

1136 ] 

1137 

1138 

1139@dataclass(kw_only=True, slots=True) 

1140class GenderChangedEmail(EmailBase): 

1141 """Sent to a user to notify them that their gender was changed.""" 

1142 

1143 new_gender: str 

1144 

1145 @property 

1146 def string_key_base(self) -> str: 

1147 return "gender_changed" 

1148 

1149 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1150 builder = self._body_builder(loc_context, security_warning=True) 

1151 builder.para(".body", {"gender": self.new_gender}) 

1152 return builder.build() 

1153 

1154 @classmethod 

1155 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self: 

1156 return cls(user_name=user_name, new_gender=data.gender) 

1157 

1158 @classmethod 

1159 def test_instances(cls) -> list[Self]: 

1160 return [ 

1161 cls( 

1162 user_name="Alice", 

1163 new_gender="Male", 

1164 ) 

1165 ] 

1166 

1167 

1168@dataclass(kw_only=True, slots=True) 

1169class HostRequestCreatedEmail(EmailBase): 

1170 """Sent to a host when a surfer sends them a new host request.""" 

1171 

1172 surfer: UserInfo 

1173 from_date: date 

1174 to_date: date 

1175 text: str 

1176 quick_decline_link: str 

1177 view_link: str 

1178 

1179 @property 

1180 def string_key_base(self) -> str: 

1181 return "host_requests.created" 

1182 

1183 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1184 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name}) 

1185 

1186 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1187 builder = self._body_builder(loc_context) 

1188 builder.para(".body", {"surfer_name": self.surfer.name}) 

1189 builder.user( 

1190 self.surfer, 

1191 "host_requests.generic.date_range", 

1192 { 

1193 "from_date": _localize_host_request_date(self.from_date, loc_context), 

1194 "to_date": _localize_host_request_date(self.to_date, loc_context), 

1195 }, 

1196 ) 

1197 builder.quote(self.text, markdown=False) 

1198 builder.action(self.view_link, "host_requests.generic.view_action") 

1199 builder.action(self.quick_decline_link, ".quick_decline_action") 

1200 builder.para(".respond_encouragement") 

1201 builder.para(_do_not_reply_request_string_key) 

1202 return builder.build() 

1203 

1204 @classmethod 

1205 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self: 

1206 return cls( 

1207 user_name, 

1208 surfer=UserInfo.from_protobuf(data.surfer), 

1209 from_date=date.fromisoformat(data.host_request.from_date), 

1210 to_date=date.fromisoformat(data.host_request.to_date), 

1211 text=data.text, 

1212 quick_decline_link=generate_quick_decline_link(data.host_request), 

1213 view_link=urls.host_request(host_request_id=data.host_request.host_request_id), 

1214 ) 

1215 

1216 @classmethod 

1217 def test_instances(cls) -> list[Self]: 

1218 return [ 

1219 cls( 

1220 user_name="Alice", 

1221 surfer=UserInfo.dummy_bob(), 

1222 from_date=date(2025, 6, 1), 

1223 to_date=date(2025, 6, 7), 

1224 text="Hey, I'd love to stay for a few nights!", 

1225 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx", 

1226 view_link="https://couchers.org/requests/123", 

1227 ) 

1228 ] 

1229 

1230 

1231@dataclass(kw_only=True, slots=True) 

1232class HostRequestReminderEmail(EmailBase): 

1233 """Sent to a host as a reminder to respond to a pending host request.""" 

1234 

1235 surfer: UserInfo 

1236 from_date: date 

1237 to_date: date 

1238 view_link: str 

1239 

1240 @property 

1241 def string_key_base(self) -> str: 

1242 return "host_requests.reminder" 

1243 

1244 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1245 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name}) 

1246 

1247 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1248 builder = self._body_builder(loc_context) 

1249 builder.para(".body") 

1250 builder.user( 

1251 self.surfer, 

1252 "host_requests.generic.date_range", 

1253 { 

1254 "from_date": _localize_host_request_date(self.from_date, loc_context), 

1255 "to_date": _localize_host_request_date(self.to_date, loc_context), 

1256 }, 

1257 ) 

1258 builder.action(self.view_link, "host_requests.generic.view_action") 

1259 builder.para(_do_not_reply_request_string_key) 

1260 return builder.build() 

1261 

1262 @classmethod 

1263 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self: 

1264 return cls( 

1265 user_name, 

1266 surfer=UserInfo.from_protobuf(data.surfer), 

1267 from_date=date.fromisoformat(data.host_request.from_date), 

1268 to_date=date.fromisoformat(data.host_request.to_date), 

1269 view_link=urls.host_request(host_request_id=data.host_request.host_request_id), 

1270 ) 

1271 

1272 @classmethod 

1273 def test_instances(cls) -> list[Self]: 

1274 return [ 

1275 cls( 

1276 user_name="Alice", 

1277 surfer=UserInfo.dummy_bob(), 

1278 from_date=date(2025, 6, 1), 

1279 to_date=date(2025, 6, 7), 

1280 view_link="https://couchers.org/requests/123", 

1281 ) 

1282 ] 

1283 

1284 

1285@dataclass(kw_only=True, slots=True) 

1286class HostRequestMessageEmail(EmailBase): 

1287 """Sent when a user sends a message in an existing host request.""" 

1288 

1289 other_user: UserInfo 

1290 from_date: date 

1291 to_date: date 

1292 text: str 

1293 from_host: bool 

1294 view_link: str 

1295 

1296 @property 

1297 def string_key_base(self) -> str: 

1298 variant = "from_host" if self.from_host else "from_surfer" 

1299 return f"host_requests.message.{variant}" 

1300 

1301 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1302 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name}) 

1303 

1304 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1305 builder = self._body_builder(loc_context) 

1306 builder.para(".body", {"other_name": self.other_user.name}) 

1307 builder.user( 

1308 self.other_user, 

1309 "host_requests.generic.date_range", 

1310 { 

1311 "from_date": _localize_host_request_date(self.from_date, loc_context), 

1312 "to_date": _localize_host_request_date(self.to_date, loc_context), 

1313 }, 

1314 ) 

1315 builder.quote(self.text, markdown=False) 

1316 builder.action(self.view_link, "host_requests.generic.view_action") 

1317 builder.para(_do_not_reply_request_string_key) 

1318 return builder.build() 

1319 

1320 @classmethod 

1321 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self: 

1322 return cls( 

1323 user_name, 

1324 other_user=UserInfo.from_protobuf(data.user), 

1325 from_date=date.fromisoformat(data.host_request.from_date), 

1326 to_date=date.fromisoformat(data.host_request.to_date), 

1327 text=data.text, 

1328 from_host=not data.am_host, 

1329 view_link=urls.host_request(host_request_id=data.host_request.host_request_id), 

1330 ) 

1331 

1332 @classmethod 

1333 def test_instances(cls) -> list[Self]: 

1334 prototype = cls( 

1335 user_name="Alice", 

1336 other_user=UserInfo.dummy_bob(), 

1337 from_date=date(2025, 6, 1), 

1338 to_date=date(2025, 6, 7), 

1339 text="Looking forward to it, see you soon!", 

1340 from_host=True, 

1341 view_link="https://couchers.org/requests/123", 

1342 ) 

1343 return [replace(prototype, from_host=True), replace(prototype, from_host=False)] 

1344 

1345 

1346@dataclass(kw_only=True, slots=True) 

1347class HostRequestMissedMessagesEmail(EmailBase): 

1348 """Sent as a digest when a user has missed messages in a host request.""" 

1349 

1350 other_user: UserInfo 

1351 from_date: date 

1352 to_date: date 

1353 from_host: bool 

1354 view_link: str 

1355 

1356 @property 

1357 def string_key_base(self) -> str: 

1358 variant = "from_host" if self.from_host else "from_surfer" 

1359 return f"host_requests.missed_messages.{variant}" 

1360 

1361 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1362 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name}) 

1363 

1364 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1365 builder = self._body_builder(loc_context) 

1366 builder.para(".body", {"other_name": self.other_user.name}) 

1367 builder.user( 

1368 self.other_user, 

1369 "host_requests.generic.date_range", 

1370 { 

1371 "from_date": _localize_host_request_date(self.from_date, loc_context), 

1372 "to_date": _localize_host_request_date(self.to_date, loc_context), 

1373 }, 

1374 ) 

1375 builder.action(self.view_link, "host_requests.generic.view_action") 

1376 builder.para(_do_not_reply_request_string_key) 

1377 return builder.build() 

1378 

1379 @classmethod 

1380 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self: 

1381 return cls( 

1382 user_name, 

1383 other_user=UserInfo.from_protobuf(data.user), 

1384 from_date=date.fromisoformat(data.host_request.from_date), 

1385 to_date=date.fromisoformat(data.host_request.to_date), 

1386 from_host=not data.am_host, 

1387 view_link=urls.host_request(host_request_id=data.host_request.host_request_id), 

1388 ) 

1389 

1390 @classmethod 

1391 def test_instances(cls) -> list[Self]: 

1392 prototype = cls( 

1393 user_name="Alice", 

1394 other_user=UserInfo.dummy_bob(), 

1395 from_date=date(2025, 6, 1), 

1396 to_date=date(2025, 6, 7), 

1397 from_host=True, 

1398 view_link="https://couchers.org/requests/123", 

1399 ) 

1400 return [replace(prototype, from_host=True), replace(prototype, from_host=False)] 

1401 

1402 

1403@dataclass(kw_only=True, slots=True) 

1404class HostRequestStatusChangedEmail(EmailBase): 

1405 """Sent when a host request is accepted, declined, confirmed, or cancelled.""" 

1406 

1407 other_user: UserInfo 

1408 from_date: date 

1409 to_date: date 

1410 new_status: conversations_pb2.HostRequestStatus.ValueType 

1411 view_link: str 

1412 

1413 @property 

1414 def string_key_base(self) -> str: 

1415 base_key = "host_requests.status_changed" 

1416 match self.new_status: 

1417 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED: 

1418 return f"{base_key}.accepted_by_host" 

1419 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED: 

1420 return f"{base_key}.declined_by_host" 

1421 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED: 

1422 return f"{base_key}.confirmed_by_surfer" 

1423 case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: 1423 ↛ 1425line 1423 didn't jump to line 1425 because the pattern on line 1423 always matched

1424 return f"{base_key}.cancelled_by_surfer" 

1425 case _: 

1426 raise ValueError(f"Unexpected host request status: {self.new_status}") 

1427 

1428 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1429 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name}) 

1430 

1431 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1432 builder = self._body_builder(loc_context) 

1433 builder.para(".body", {"other_name": self.other_user.name}) 

1434 builder.user( 

1435 self.other_user, 

1436 "host_requests.generic.date_range", 

1437 { 

1438 "from_date": _localize_host_request_date(self.from_date, loc_context), 

1439 "to_date": _localize_host_request_date(self.to_date, loc_context), 

1440 }, 

1441 ) 

1442 builder.action(self.view_link, "host_requests.generic.view_action") 

1443 builder.para(_do_not_reply_request_string_key) 

1444 return builder.build() 

1445 

1446 @classmethod 

1447 def from_notification( 

1448 cls, 

1449 data: notification_data_pb2.HostRequestAccept 

1450 | notification_data_pb2.HostRequestReject 

1451 | notification_data_pb2.HostRequestConfirm 

1452 | notification_data_pb2.HostRequestCancel, 

1453 *, 

1454 user_name: str, 

1455 ) -> Self: 

1456 other_user: UserInfo 

1457 new_status: conversations_pb2.HostRequestStatus.ValueType 

1458 match data: 

1459 case notification_data_pb2.HostRequestAccept(): 

1460 other_user = UserInfo.from_protobuf(data.host) 

1461 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED 

1462 case notification_data_pb2.HostRequestReject(): 1462 ↛ 1463line 1462 didn't jump to line 1463 because the pattern on line 1462 never matched

1463 other_user = UserInfo.from_protobuf(data.host) 

1464 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED 

1465 case notification_data_pb2.HostRequestConfirm(): 

1466 other_user = UserInfo.from_protobuf(data.surfer) 

1467 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED 

1468 case notification_data_pb2.HostRequestCancel(): 1468 ↛ 1471line 1468 didn't jump to line 1471 because the pattern on line 1468 always matched

1469 other_user = UserInfo.from_protobuf(data.surfer) 

1470 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED 

1471 case _: 

1472 # Enable mypy's exhaustiveness checking 

1473 assert_never("Unexpected host request status changed notification data type.") 

1474 

1475 return cls( 

1476 user_name, 

1477 other_user=other_user, 

1478 from_date=date.fromisoformat(data.host_request.from_date), 

1479 to_date=date.fromisoformat(data.host_request.to_date), 

1480 new_status=new_status, 

1481 view_link=urls.host_request(host_request_id=data.host_request.host_request_id), 

1482 ) 

1483 

1484 @classmethod 

1485 def test_instances(cls) -> list[Self]: 

1486 prototype = cls( 

1487 user_name="Alice", 

1488 other_user=UserInfo.dummy_bob(), 

1489 from_date=date(2025, 6, 1), 

1490 to_date=date(2025, 6, 7), 

1491 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

1492 view_link="https://couchers.org/requests/123", 

1493 ) 

1494 return [ 

1495 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED), 

1496 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED), 

1497 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED), 

1498 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED), 

1499 ] 

1500 

1501 

1502@dataclass(kw_only=True, slots=True) 

1503class HostReferenceReceivedEmail(EmailBase): 

1504 """Sent to a user when they receive a reference from a past host or surfer.""" 

1505 

1506 from_user: UserInfo 

1507 text: str | None # None if hidden because receiver hasn't written their reference yet. 

1508 surfed: bool # True if I was the surfer, False if I was the host 

1509 leave_reference_url: str 

1510 

1511 @property 

1512 def string_key_base(self) -> str: 

1513 return "references.received" 

1514 

1515 @property 

1516 def string_role_subkey(self) -> str: 

1517 return "surfed" if self.surfed else "hosted" 

1518 

1519 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1520 return self._localize(loc_context, ".subject", {"name": self.from_user.name}) 

1521 

1522 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1523 builder = self._body_builder(loc_context) 

1524 builder.para(f".{self.string_role_subkey}.body", {"name": self.from_user.name}) 

1525 builder.user(self.from_user) 

1526 if self.text: 

1527 builder.para(".before_quote") 

1528 builder.quote(self.text, markdown=False) 

1529 builder.action(urls.profile_references_link(), ".view_action") 

1530 else: 

1531 builder.para(".reciprocate_encouragement", {"name": self.from_user.name}) 

1532 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name}) 

1533 return builder.build() 

1534 

1535 @classmethod 

1536 def from_notification( 

1537 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool 

1538 ) -> Self: 

1539 return cls( 

1540 user_name=user_name, 

1541 from_user=UserInfo.from_protobuf(data.from_user), 

1542 text=data.text or None, 

1543 surfed=surfed, 

1544 leave_reference_url=urls.leave_reference_link( 

1545 reference_type="surfed" if surfed else "hosted", 

1546 to_user_id=str(data.from_user.user_id), 

1547 host_request_id=str(data.host_request_id), 

1548 ), 

1549 ) 

1550 

1551 @classmethod 

1552 def test_instances(cls) -> list[Self]: 

1553 prototype = cls( 

1554 user_name="Alice", 

1555 from_user=UserInfo.dummy_bob(), 

1556 text="Alice was a fantastic guest!", 

1557 surfed=True, 

1558 leave_reference_url="https://couchers.org/leave-reference/123", 

1559 ) 

1560 return [ 

1561 replace(prototype, surfed=True, text="Alice was a fantastic guest!"), 

1562 replace(prototype, surfed=True, text=None), 

1563 replace(prototype, surfed=False, text="Bob was a wonderful host!"), 

1564 replace(prototype, surfed=False, text=None), 

1565 ] 

1566 

1567 

1568@dataclass(kw_only=True, slots=True) 

1569class HostReferenceReminderEmail(EmailBase): 

1570 """Sent as a reminder to write a reference after a stay.""" 

1571 

1572 other_user: UserInfo 

1573 days_left: int 

1574 surfed: bool # True if I was the surfer, False if I was the host 

1575 leave_reference_url: str 

1576 

1577 @property 

1578 def string_key_base(self) -> str: 

1579 return "references.reminder" 

1580 

1581 @property 

1582 def string_role_subkey(self) -> str: 

1583 return "surfed" if self.surfed else "hosted" 

1584 

1585 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1586 return self._localize( 

1587 loc_context, 

1588 ".subject_days", 

1589 {"name": self.other_user.name, "count": self.days_left}, 

1590 ) 

1591 

1592 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1593 builder = self._body_builder(loc_context) 

1594 builder.para(f".{self.string_role_subkey}.body_days", {"name": self.other_user.name, "count": self.days_left}) 

1595 builder.para(".no_meeting_note", {"name": self.other_user.name}) 

1596 builder.user(self.other_user) 

1597 builder.action( 

1598 self.leave_reference_url, 

1599 "references.write_action", 

1600 {"name": self.other_user.name}, 

1601 ) 

1602 builder.para(".via_messaging_note") 

1603 builder.para(".importance_note") 

1604 builder.para(".visibility_note") 

1605 return builder.build() 

1606 

1607 @classmethod 

1608 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self: 

1609 return cls( 

1610 user_name=user_name, 

1611 other_user=UserInfo.from_protobuf(data.other_user), 

1612 days_left=data.days_left, 

1613 surfed=surfed, 

1614 leave_reference_url=urls.leave_reference_link( 

1615 reference_type="surfed" if surfed else "hosted", 

1616 to_user_id=str(data.other_user.user_id), 

1617 host_request_id=str(data.host_request_id), 

1618 ), 

1619 ) 

1620 

1621 @classmethod 

1622 def test_instances(cls) -> list[Self]: 

1623 prototype = cls( 

1624 user_name="Alice", 

1625 other_user=UserInfo.dummy_bob(), 

1626 days_left=7, 

1627 surfed=True, 

1628 leave_reference_url="https://couchers.org/leave-reference/123", 

1629 ) 

1630 return [replace(prototype, surfed=True), replace(prototype, surfed=False)] 

1631 

1632 

1633@dataclass(kw_only=True, slots=True) 

1634class ModeratorNoteEmail(EmailBase): 

1635 """Sent to a user to notify them they have received a moderator note.""" 

1636 

1637 @property 

1638 def string_key_base(self) -> str: 

1639 return "moderator_note" 

1640 

1641 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1642 builder = self._body_builder(loc_context) 

1643 builder.para(".body") 

1644 return builder.build() 

1645 

1646 @classmethod 

1647 def test_instances(cls) -> list[Self]: 

1648 return [cls(user_name="Alice")] 

1649 

1650 

1651@dataclass(kw_only=True, slots=True) 

1652class NewBlogPostEmail(EmailBase): 

1653 """Sent to notify users of a new blog post.""" 

1654 

1655 title: str 

1656 blurb: str 

1657 url: str 

1658 

1659 @property 

1660 def string_key_base(self) -> str: 

1661 return "new_blog_post" 

1662 

1663 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1664 return self._localize(loc_context, ".subject", {"title": self.title}) 

1665 

1666 def get_preview_line(self, loc_context: LocalizationContext) -> str | None: 

1667 return self.blurb 

1668 

1669 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1670 builder = self._body_builder(loc_context) 

1671 builder.para(".intro") 

1672 builder.para(".post_title", {"title": self.title}) 

1673 builder.quote(self.blurb, markdown=False) 

1674 builder.action(self.url, ".read_action") 

1675 return builder.build() 

1676 

1677 @classmethod 

1678 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self: 

1679 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url) 

1680 

1681 @classmethod 

1682 def test_instances(cls) -> list[Self]: 

1683 return [ 

1684 cls( 

1685 user_name="Alice", 

1686 title="Exciting new features on Couchers.org", 

1687 blurb="We've launched some great new features including improved messaging and event discovery.", 

1688 url="https://couchers.org/blog/2025/01/01/new-features", 

1689 ) 

1690 ] 

1691 

1692 

1693@dataclass(kw_only=True, slots=True) 

1694class OnboardingReminderEmail(EmailBase): 

1695 """Onboarding email sent to new users; initial=True for the first email, False for the second.""" 

1696 

1697 initial: bool 

1698 

1699 @property 

1700 def string_key_base(self) -> str: 

1701 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}" 

1702 

1703 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1704 builder = self._body_builder(loc_context, standard_closing=False) 

1705 edit_profile_url = urls.edit_profile_link() 

1706 if self.initial: 

1707 builder.para(".welcome") 

1708 builder.para(".early_user_role") 

1709 builder.para(".fill_in_profile") 

1710 builder.para(".edit_profile_prompt") 

1711 builder.action(edit_profile_url, ".edit_profile_action") 

1712 builder.para(".share_with_friends") 

1713 builder.para(".link", {"url": urls.app_link()}) 

1714 builder.para(".platform_under_development") 

1715 builder.para(".thanks_for_joining") 

1716 builder.para(".signature") 

1717 else: 

1718 builder.para(".intro") 

1719 builder.para(".fill_in_profile") 

1720 builder.action(edit_profile_url, ".edit_profile_action") 

1721 builder.para(".no_empty_accounts") 

1722 builder.para(".profile_importance") 

1723 builder.para(".signature") 

1724 return builder.build() 

1725 

1726 @classmethod 

1727 def test_instances(cls) -> list[Self]: 

1728 prototype = cls(user_name="Alice", initial=True) 

1729 return [replace(prototype, initial=True), replace(prototype, initial=False)] 

1730 

1731 

1732@dataclass(kw_only=True, slots=True) 

1733class PasswordChangedEmail(EmailBase): 

1734 """Sent to a user to notify them that their login password was changed.""" 

1735 

1736 @property 

1737 def string_key_base(self) -> str: 

1738 return "password_changed" 

1739 

1740 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1741 builder = self._body_builder(loc_context, security_warning=True) 

1742 builder.para(".body") 

1743 return builder.build() 

1744 

1745 @classmethod 

1746 def test_instances(cls) -> list[Self]: 

1747 return [cls(user_name="Alice")] 

1748 

1749 

1750@dataclass(kw_only=True, slots=True) 

1751class PasswordResetCompletedEmail(EmailBase): 

1752 """Sent to a user to confirm their password was successfully reset.""" 

1753 

1754 @property 

1755 def string_key_base(self) -> str: 

1756 return "password_reset.completed" 

1757 

1758 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1759 builder = self._body_builder(loc_context, security_warning=True) 

1760 builder.para(".body") 

1761 return builder.build() 

1762 

1763 @classmethod 

1764 def test_instances(cls) -> list[Self]: 

1765 return [cls(user_name="Alice")] 

1766 

1767 

1768@dataclass(kw_only=True, slots=True) 

1769class PasswordResetStartedEmail(EmailBase): 

1770 """Sent to a user with a link to complete their password reset.""" 

1771 

1772 password_reset_link: str 

1773 

1774 @property 

1775 def string_key_base(self) -> str: 

1776 return "password_reset.started" 

1777 

1778 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1779 builder = self._body_builder(loc_context, security_warning=True) 

1780 builder.para(".request_description") 

1781 builder.para(".confirmation_instructions") 

1782 builder.action(self.password_reset_link, ".reset_action") 

1783 return builder.build() 

1784 

1785 @classmethod 

1786 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self: 

1787 return cls( 

1788 user_name=user_name, 

1789 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token), 

1790 ) 

1791 

1792 @classmethod 

1793 def test_instances(cls) -> list[Self]: 

1794 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")] 

1795 

1796 

1797@dataclass(kw_only=True, slots=True) 

1798class PhoneNumberChangeEmail(EmailBase): 

1799 """Sent to a user to notify them that their phone number verification status was changed.""" 

1800 

1801 new_phone_number: str 

1802 completed: bool # False = started, True = completed 

1803 

1804 @property 

1805 def string_key_base(self) -> str: 

1806 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started" 

1807 

1808 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1809 builder = self._body_builder(loc_context, security_warning=True) 

1810 builder.para(".body", {"phone_number": format_phone_number(self.new_phone_number)}) 

1811 return builder.build() 

1812 

1813 @classmethod 

1814 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self: 

1815 return cls(user_name=user_name, new_phone_number=data.phone, completed=False) 

1816 

1817 @classmethod 

1818 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self: 

1819 return cls(user_name=user_name, new_phone_number=data.phone, completed=True) 

1820 

1821 @classmethod 

1822 def test_instances(cls) -> list[Self]: 

1823 prototype = cls( 

1824 user_name="Alice", 

1825 new_phone_number="+12223334444", 

1826 completed=False, 

1827 ) 

1828 return [replace(prototype, completed=False), replace(prototype, completed=True)] 

1829 

1830 

1831@dataclass(kw_only=True, slots=True) 

1832class PostalVerificationFailedEmail(EmailBase): 

1833 """Sent to a user when their postal verification attempt has failed.""" 

1834 

1835 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1836 

1837 @property 

1838 def string_key_base(self) -> str: 

1839 return "postal_verification.failed" 

1840 

1841 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1842 builder = self._body_builder(loc_context, security_warning=True) 

1843 match self.reason: 

1844 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1845 reason_string_key = ".reason_code_expired" 

1846 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1847 reason_string_key = ".reason_too_many_attempts" 

1848 case _: 

1849 reason_string_key = ".reason_unknown" 

1850 builder.para(reason_string_key) 

1851 return builder.build() 

1852 

1853 @classmethod 

1854 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self: 

1855 return cls(user_name=user_name, reason=data.reason) 

1856 

1857 @classmethod 

1858 def test_instances(cls) -> list[Self]: 

1859 prototype = cls( 

1860 user_name="Alice", 

1861 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1862 ) 

1863 return [ 

1864 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED), 

1865 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS), 

1866 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN), 

1867 ] 

1868 

1869 

1870@dataclass(kw_only=True, slots=True) 

1871class PostalVerificationPostcardSentEmail(EmailBase): 

1872 """Sent to a user to notify them that their verification postcard has been sent.""" 

1873 

1874 city: str 

1875 country: str 

1876 

1877 @property 

1878 def string_key_base(self) -> str: 

1879 return "postal_verification.postcard_sent" 

1880 

1881 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1882 builder = self._body_builder(loc_context, security_warning=True) 

1883 builder.para(".body", {"city": self.city, "country": self.country}) 

1884 return builder.build() 

1885 

1886 @classmethod 

1887 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self: 

1888 return cls(user_name=user_name, city=data.city, country=data.country) 

1889 

1890 @classmethod 

1891 def test_instances(cls) -> list[Self]: 

1892 return [cls(user_name="Alice", city="New York", country="United States")] 

1893 

1894 

1895@dataclass(kw_only=True, slots=True) 

1896class PostalVerificationSucceededEmail(EmailBase): 

1897 """Sent to a user when their postal verification has succeeded.""" 

1898 

1899 @property 

1900 def string_key_base(self) -> str: 

1901 return "postal_verification.succeeded" 

1902 

1903 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1904 builder = self._body_builder(loc_context, security_warning=True) 

1905 builder.para(".body") 

1906 return builder.build() 

1907 

1908 @classmethod 

1909 def test_instances(cls) -> list[Self]: 

1910 return [cls(user_name="Alice")] 

1911 

1912 

1913@dataclass(kw_only=True, slots=True) 

1914class SignupVerifyEmail(EmailBase): 

1915 """Sent to a user to verify their email address.""" 

1916 

1917 verify_url: str 

1918 

1919 @property 

1920 def string_key_base(self) -> str: 

1921 return "signup.verify" 

1922 

1923 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1924 return self._localize(loc_context, "signup.subject") 

1925 

1926 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1927 builder = self._body_builder(loc_context) 

1928 builder.para(".thanks") 

1929 builder.para(".instructions") 

1930 builder.action(self.verify_url, ".confirm_action") 

1931 builder.para("signup.closing") 

1932 return builder.build() 

1933 

1934 @classmethod 

1935 def test_instances(cls) -> list[Self]: 

1936 return [cls(user_name="Alice", verify_url="https://example.com")] 

1937 

1938 

1939@dataclass(kw_only=True, slots=True) 

1940class SignupContinueEmail(EmailBase): 

1941 """Sent to a user to ask them to continue the signup process.""" 

1942 

1943 continue_url: str 

1944 

1945 @property 

1946 def string_key_base(self) -> str: 

1947 return "signup.continue" 

1948 

1949 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

1950 return self._localize(loc_context, "signup.subject") 

1951 

1952 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1953 builder = self._body_builder(loc_context) 

1954 builder.para(".request") 

1955 builder.para(".instructions") 

1956 builder.action(self.continue_url, ".continue_action") 

1957 builder.para("signup.closing") 

1958 builder.para(".ignore_if_unexpected") 

1959 return builder.build() 

1960 

1961 @classmethod 

1962 def test_instances(cls) -> list[Self]: 

1963 return [cls(user_name="Alice", continue_url="https://example.com")] 

1964 

1965 

1966@dataclass(kw_only=True, slots=True) 

1967class StrongVerificationFailedEmail(EmailBase): 

1968 """Sent to a user when their strong verification attempt has failed.""" 

1969 

1970 reason: notification_data_pb2.SVFailReason.ValueType 

1971 

1972 @property 

1973 def string_key_base(self) -> str: 

1974 return "strong_verification.failed" 

1975 

1976 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

1977 builder = self._body_builder(loc_context, security_warning=True) 

1978 match self.reason: 

1979 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

1980 reason_string_key = ".reason_wrong_birthdate_or_gender" 

1981 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

1982 reason_string_key = ".reason_not_a_passport" 

1983 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 1983 ↛ 1985line 1983 didn't jump to line 1985 because the pattern on line 1983 always matched

1984 reason_string_key = ".reason_duplicate" 

1985 case _: 

1986 raise Exception("Shouldn't get here") 

1987 builder.para(reason_string_key) 

1988 return builder.build() 

1989 

1990 @classmethod 

1991 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self: 

1992 return cls(user_name=user_name, reason=data.reason) 

1993 

1994 @classmethod 

1995 def test_instances(cls) -> list[Self]: 

1996 prototype = cls( 

1997 user_name="Alice", 

1998 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

1999 ) 

2000 return [ 

2001 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER), 

2002 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT), 

2003 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE), 

2004 ] 

2005 

2006 

2007@dataclass(kw_only=True, slots=True) 

2008class StrongVerificationSucceededEmail(EmailBase): 

2009 """Sent to a user when their strong verification has succeeded.""" 

2010 

2011 @property 

2012 def string_key_base(self) -> str: 

2013 return "strong_verification.succeeded" 

2014 

2015 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

2016 builder = self._body_builder(loc_context, security_warning=True) 

2017 builder.para(".success_message") 

2018 builder.para(".thanks_message") 

2019 builder.para(".cost_explanation") 

2020 builder.para(".donation_request") 

2021 donate_link = urls.donation_url() + "?utm_source=strong-verification-email" 

2022 builder.action(donate_link, ".donate_action") 

2023 return builder.build() 

2024 

2025 @classmethod 

2026 def test_instances(cls) -> list[Self]: 

2027 return [cls(user_name="Alice")] 

2028 

2029 

2030@dataclass(kw_only=True, slots=True) 

2031class ThreadReplyEmail(EmailBase): 

2032 """Sent to a user when someone replies in a comment thread they participated in.""" 

2033 

2034 author: UserInfo 

2035 parent_context: str # Title of the event or discussion being replied in 

2036 markdown_text: str 

2037 view_link: str 

2038 

2039 @property 

2040 def string_key_base(self) -> str: 

2041 return "thread_reply" 

2042 

2043 def get_subject_line(self, loc_context: LocalizationContext) -> str: 

2044 return self._localize( 

2045 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context} 

2046 ) 

2047 

2048 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]: 

2049 builder = self._body_builder(loc_context) 

2050 builder.para(".body", {"author": self.author.name, "parent_context": self.parent_context}) 

2051 builder.user(self.author) 

2052 builder.quote(self.markdown_text, markdown=True) 

2053 builder.action(self.view_link, ".view_action") 

2054 return builder.build() 

2055 

2056 @classmethod 

2057 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self: 

2058 parent = data.WhichOneof("reply_parent") 

2059 if parent == "event": 

2060 parent_context = data.event.title 

2061 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug) 

2062 elif parent == "discussion": 2062 ↛ 2066line 2062 didn't jump to line 2066 because the condition on line 2062 was always true

2063 parent_context = data.discussion.title 

2064 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug) 

2065 else: 

2066 raise Exception("Can only do replies to events and discussions") 

2067 return cls( 

2068 user_name=user_name, 

2069 author=UserInfo.from_protobuf(data.author), 

2070 parent_context=parent_context, 

2071 markdown_text=data.reply.content, 

2072 view_link=view_link, 

2073 ) 

2074 

2075 @classmethod 

2076 def test_instances(cls) -> list[Self]: 

2077 return [ 

2078 cls( 

2079 user_name="Alice", 

2080 author=UserInfo.dummy_bob(), 

2081 parent_context="Best hiking trails near Berlin", 

2082 markdown_text="I agree, the Grünewald is **amazing**!", 

2083 view_link="https://couchers.org/discussions/123", 

2084 ) 

2085 ] 

2086 

2087 

2088def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str: 

2089 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)