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

1029 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-25 12:46 +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[str] 

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 # TODO(#8875): Localize the updated items 

752 updated_items_text = ", ".join(self.updated_items) 

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

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

755 builder.user(self.updating_user) 

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

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

758 return builder.build() 

759 

760 @classmethod 

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

762 return cls( 

763 user_name=user_name, 

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

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

766 updated_items=list(data.updated_items), 

767 ) 

768 

769 @classmethod 

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

771 return [ 

772 cls( 

773 user_name="Alice", 

774 updating_user=UserInfo.dummy_bob(), 

775 event_info=EventInfo.dummy(), 

776 updated_items=["time", "location"], 

777 ) 

778 ] 

779 

780 

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

782class EventOrganizerInvitedEmail(EmailBase): 

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

784 

785 inviting_user: UserInfo 

786 event_info: EventInfo 

787 

788 @property 

789 def string_key_base(self) -> str: 

790 return "events.organizer_invited" 

791 

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

793 return self._localize( 

794 loc_context, 

795 ".subject", 

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

797 ) 

798 

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

800 builder = self._body_builder(loc_context) 

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

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

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

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

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

806 builder.para(_do_not_reply_request_string_key) 

807 return builder.build() 

808 

809 @classmethod 

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

811 return cls( 

812 user_name=user_name, 

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

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

815 ) 

816 

817 @classmethod 

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

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

820 

821 

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

823class EventCommentEmail(EmailBase): 

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

825 

826 author: UserInfo 

827 event_info: EventInfo 

828 comment_markdown: str 

829 

830 @property 

831 def string_key_base(self) -> str: 

832 return "events.comment" 

833 

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

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

836 

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

838 builder = self._body_builder(loc_context) 

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

840 builder.user(self.author) 

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

842 builder.para(".event_details") 

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

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

845 return builder.build() 

846 

847 @classmethod 

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

849 return cls( 

850 user_name=user_name, 

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

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

853 comment_markdown=data.reply.content, 

854 ) 

855 

856 @classmethod 

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

858 return [ 

859 cls( 

860 user_name="Alice", 

861 author=UserInfo.dummy_bob(), 

862 event_info=EventInfo.dummy(), 

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

864 ) 

865 ] 

866 

867 

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

869class EventReminderEmail(EmailBase): 

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

871 

872 event_info: EventInfo 

873 

874 @property 

875 def string_key_base(self) -> str: 

876 return "events.reminder" 

877 

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

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

880 

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

882 builder = self._body_builder(loc_context) 

883 builder.para(".body") 

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

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

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

887 return builder.build() 

888 

889 @classmethod 

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

891 return cls( 

892 user_name=user_name, 

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

894 ) 

895 

896 @classmethod 

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

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

899 

900 

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

902class EventCancelledEmail(EmailBase): 

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

904 

905 cancelling_user: UserInfo 

906 event_info: EventInfo 

907 

908 @property 

909 def string_key_base(self) -> str: 

910 return "events.cancel" 

911 

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

913 return self._localize( 

914 loc_context, 

915 ".subject", 

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

917 ) 

918 

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

920 builder = self._body_builder(loc_context) 

921 builder.para(".body") 

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

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

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

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

926 return builder.build() 

927 

928 @classmethod 

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

930 return cls( 

931 user_name=user_name, 

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

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

934 ) 

935 

936 @classmethod 

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

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

939 

940 

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

942class EventDeletedEmail(EmailBase): 

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

944 

945 event_info: EventInfo 

946 

947 @property 

948 def string_key_base(self) -> str: 

949 return "events.deleted" 

950 

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

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

953 

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

955 builder = self._body_builder(loc_context) 

956 builder.para(".body") 

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

958 return builder.build() 

959 

960 @classmethod 

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

962 return cls( 

963 user_name=user_name, 

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

965 ) 

966 

967 @classmethod 

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

969 return [ 

970 cls( 

971 user_name="Alice", 

972 event_info=EventInfo.dummy(), 

973 ) 

974 ] 

975 

976 

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

978class FriendReferenceReceivedEmail(EmailBase): 

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

980 

981 from_user: UserInfo 

982 text: str 

983 

984 @property 

985 def string_key_base(self) -> str: 

986 return "references.received.friend" 

987 

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

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

990 

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

992 builder = self._body_builder(loc_context) 

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

994 builder.user(self.from_user) 

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

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

997 return builder.build() 

998 

999 @classmethod 

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

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

1002 

1003 @classmethod 

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

1005 return [ 

1006 cls( 

1007 user_name="Alice", 

1008 from_user=UserInfo.dummy_bob(), 

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

1010 ) 

1011 ] 

1012 

1013 

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

1015class FriendRequestReceivedEmail(EmailBase): 

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

1017 

1018 befriender: UserInfo 

1019 

1020 @property 

1021 def string_key_base(self) -> str: 

1022 return "friend_requests.received" 

1023 

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

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

1026 

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

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

1029 

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

1031 builder = self._body_builder(loc_context) 

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

1033 builder.user(self.befriender) 

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

1035 builder.para(".closing") 

1036 builder.para(_do_not_reply_request_string_key) 

1037 return builder.build() 

1038 

1039 @classmethod 

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

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

1042 

1043 @classmethod 

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

1045 return [ 

1046 cls( 

1047 user_name="Alice", 

1048 befriender=UserInfo.dummy_bob(), 

1049 ) 

1050 ] 

1051 

1052 

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

1054class FriendRequestAcceptedEmail(EmailBase): 

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

1056 

1057 new_friend: UserInfo 

1058 

1059 @property 

1060 def string_key_base(self) -> str: 

1061 return "friend_requests.accepted" 

1062 

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

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

1065 

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

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

1068 

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

1070 builder = self._body_builder(loc_context) 

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

1072 builder.user(self.new_friend) 

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

1074 builder.para(".closing") 

1075 return builder.build() 

1076 

1077 @classmethod 

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

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

1080 

1081 @classmethod 

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

1083 return [ 

1084 cls( 

1085 user_name="Alice", 

1086 new_friend=UserInfo.dummy_bob(), 

1087 ) 

1088 ] 

1089 

1090 

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

1092class GenderChangedEmail(EmailBase): 

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

1094 

1095 new_gender: str 

1096 

1097 @property 

1098 def string_key_base(self) -> str: 

1099 return "gender_changed" 

1100 

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

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

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

1104 return builder.build() 

1105 

1106 @classmethod 

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

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

1109 

1110 @classmethod 

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

1112 return [ 

1113 cls( 

1114 user_name="Alice", 

1115 new_gender="Male", 

1116 ) 

1117 ] 

1118 

1119 

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

1121class HostRequestCreatedEmail(EmailBase): 

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

1123 

1124 surfer: UserInfo 

1125 from_date: date 

1126 to_date: date 

1127 text: str 

1128 quick_decline_link: str 

1129 view_link: str 

1130 

1131 @property 

1132 def string_key_base(self) -> str: 

1133 return "host_requests.created" 

1134 

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

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

1137 

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

1139 builder = self._body_builder(loc_context) 

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

1141 builder.user( 

1142 self.surfer, 

1143 "host_requests.generic.date_range", 

1144 { 

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

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

1147 }, 

1148 ) 

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

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

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

1152 builder.para(".respond_encouragement") 

1153 builder.para(_do_not_reply_request_string_key) 

1154 return builder.build() 

1155 

1156 @classmethod 

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

1158 return cls( 

1159 user_name, 

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

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

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

1163 text=data.text, 

1164 quick_decline_link=generate_quick_decline_link(data.host_request), 

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

1166 ) 

1167 

1168 @classmethod 

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

1170 return [ 

1171 cls( 

1172 user_name="Alice", 

1173 surfer=UserInfo.dummy_bob(), 

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

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

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

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

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

1179 ) 

1180 ] 

1181 

1182 

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

1184class HostRequestReminderEmail(EmailBase): 

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

1186 

1187 surfer: UserInfo 

1188 from_date: date 

1189 to_date: date 

1190 view_link: str 

1191 

1192 @property 

1193 def string_key_base(self) -> str: 

1194 return "host_requests.reminder" 

1195 

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

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

1198 

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

1200 builder = self._body_builder(loc_context) 

1201 builder.para(".body") 

1202 builder.user( 

1203 self.surfer, 

1204 "host_requests.generic.date_range", 

1205 { 

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

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

1208 }, 

1209 ) 

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

1211 builder.para(_do_not_reply_request_string_key) 

1212 return builder.build() 

1213 

1214 @classmethod 

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

1216 return cls( 

1217 user_name, 

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

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

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

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

1222 ) 

1223 

1224 @classmethod 

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

1226 return [ 

1227 cls( 

1228 user_name="Alice", 

1229 surfer=UserInfo.dummy_bob(), 

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

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

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

1233 ) 

1234 ] 

1235 

1236 

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

1238class HostRequestMessageEmail(EmailBase): 

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

1240 

1241 other_user: UserInfo 

1242 from_date: date 

1243 to_date: date 

1244 text: str 

1245 from_host: bool 

1246 view_link: str 

1247 

1248 @property 

1249 def string_key_base(self) -> str: 

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

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

1252 

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

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

1255 

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

1257 builder = self._body_builder(loc_context) 

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

1259 builder.user( 

1260 self.other_user, 

1261 "host_requests.generic.date_range", 

1262 { 

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

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

1265 }, 

1266 ) 

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

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

1269 builder.para(_do_not_reply_request_string_key) 

1270 return builder.build() 

1271 

1272 @classmethod 

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

1274 return cls( 

1275 user_name, 

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

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

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

1279 text=data.text, 

1280 from_host=not data.am_host, 

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

1282 ) 

1283 

1284 @classmethod 

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

1286 prototype = cls( 

1287 user_name="Alice", 

1288 other_user=UserInfo.dummy_bob(), 

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

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

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

1292 from_host=True, 

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

1294 ) 

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

1296 

1297 

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

1299class HostRequestMissedMessagesEmail(EmailBase): 

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

1301 

1302 other_user: UserInfo 

1303 from_date: date 

1304 to_date: date 

1305 from_host: bool 

1306 view_link: str 

1307 

1308 @property 

1309 def string_key_base(self) -> str: 

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

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

1312 

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

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

1315 

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

1317 builder = self._body_builder(loc_context) 

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

1319 builder.user( 

1320 self.other_user, 

1321 "host_requests.generic.date_range", 

1322 { 

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

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

1325 }, 

1326 ) 

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

1328 builder.para(_do_not_reply_request_string_key) 

1329 return builder.build() 

1330 

1331 @classmethod 

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

1333 return cls( 

1334 user_name, 

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

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

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

1338 from_host=not data.am_host, 

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

1340 ) 

1341 

1342 @classmethod 

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

1344 prototype = cls( 

1345 user_name="Alice", 

1346 other_user=UserInfo.dummy_bob(), 

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

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

1349 from_host=True, 

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

1351 ) 

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

1353 

1354 

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

1356class HostRequestStatusChangedEmail(EmailBase): 

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

1358 

1359 other_user: UserInfo 

1360 from_date: date 

1361 to_date: date 

1362 new_status: conversations_pb2.HostRequestStatus.ValueType 

1363 view_link: str 

1364 

1365 @property 

1366 def string_key_base(self) -> str: 

1367 base_key = "host_requests.status_changed" 

1368 match self.new_status: 

1369 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED: 

1370 return f"{base_key}.accepted_by_host" 

1371 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED: 

1372 return f"{base_key}.declined_by_host" 

1373 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED: 

1374 return f"{base_key}.confirmed_by_surfer" 

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

1376 return f"{base_key}.cancelled_by_surfer" 

1377 case _: 

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

1379 

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

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

1382 

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

1384 builder = self._body_builder(loc_context) 

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

1386 builder.user( 

1387 self.other_user, 

1388 "host_requests.generic.date_range", 

1389 { 

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

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

1392 }, 

1393 ) 

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

1395 builder.para(_do_not_reply_request_string_key) 

1396 return builder.build() 

1397 

1398 @classmethod 

1399 def from_notification( 

1400 cls, 

1401 data: notification_data_pb2.HostRequestAccept 

1402 | notification_data_pb2.HostRequestReject 

1403 | notification_data_pb2.HostRequestConfirm 

1404 | notification_data_pb2.HostRequestCancel, 

1405 *, 

1406 user_name: str, 

1407 ) -> Self: 

1408 other_user: UserInfo 

1409 new_status: conversations_pb2.HostRequestStatus.ValueType 

1410 match data: 

1411 case notification_data_pb2.HostRequestAccept(): 

1412 other_user = UserInfo.from_protobuf(data.host) 

1413 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED 

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

1415 other_user = UserInfo.from_protobuf(data.host) 

1416 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED 

1417 case notification_data_pb2.HostRequestConfirm(): 

1418 other_user = UserInfo.from_protobuf(data.surfer) 

1419 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED 

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

1421 other_user = UserInfo.from_protobuf(data.surfer) 

1422 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED 

1423 case _: 

1424 # Enable mypy's exhaustiveness checking 

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

1426 

1427 return cls( 

1428 user_name, 

1429 other_user=other_user, 

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

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

1432 new_status=new_status, 

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

1434 ) 

1435 

1436 @classmethod 

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

1438 prototype = cls( 

1439 user_name="Alice", 

1440 other_user=UserInfo.dummy_bob(), 

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

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

1443 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

1445 ) 

1446 return [ 

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

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

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

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

1451 ] 

1452 

1453 

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

1455class HostReferenceReceivedEmail(EmailBase): 

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

1457 

1458 from_user: UserInfo 

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

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

1461 leave_reference_url: str 

1462 

1463 @property 

1464 def string_key_base(self) -> str: 

1465 return "references.received" 

1466 

1467 @property 

1468 def string_role_subkey(self) -> str: 

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

1470 

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

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

1473 

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

1475 builder = self._body_builder(loc_context) 

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

1477 builder.user(self.from_user) 

1478 if self.text: 

1479 builder.para(".before_quote") 

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

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

1482 else: 

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

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

1485 return builder.build() 

1486 

1487 @classmethod 

1488 def from_notification( 

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

1490 ) -> Self: 

1491 return cls( 

1492 user_name=user_name, 

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

1494 text=data.text or None, 

1495 surfed=surfed, 

1496 leave_reference_url=urls.leave_reference_link( 

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

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

1499 host_request_id=str(data.host_request_id), 

1500 ), 

1501 ) 

1502 

1503 @classmethod 

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

1505 prototype = cls( 

1506 user_name="Alice", 

1507 from_user=UserInfo.dummy_bob(), 

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

1509 surfed=True, 

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

1511 ) 

1512 return [ 

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

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

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

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

1517 ] 

1518 

1519 

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

1521class HostReferenceReminderEmail(EmailBase): 

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

1523 

1524 other_user: UserInfo 

1525 days_left: int 

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

1527 leave_reference_url: str 

1528 

1529 @property 

1530 def string_key_base(self) -> str: 

1531 return "references.reminder" 

1532 

1533 @property 

1534 def string_role_subkey(self) -> str: 

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

1536 

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

1538 return self._localize( 

1539 loc_context, 

1540 ".subject_days", 

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

1542 ) 

1543 

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

1545 builder = self._body_builder(loc_context) 

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

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

1548 builder.user(self.other_user) 

1549 builder.action( 

1550 self.leave_reference_url, 

1551 "references.write_action", 

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

1553 ) 

1554 builder.para(".via_messaging_note") 

1555 builder.para(".importance_note") 

1556 builder.para(".visibility_note") 

1557 return builder.build() 

1558 

1559 @classmethod 

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

1561 return cls( 

1562 user_name=user_name, 

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

1564 days_left=data.days_left, 

1565 surfed=surfed, 

1566 leave_reference_url=urls.leave_reference_link( 

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

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

1569 host_request_id=str(data.host_request_id), 

1570 ), 

1571 ) 

1572 

1573 @classmethod 

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

1575 prototype = cls( 

1576 user_name="Alice", 

1577 other_user=UserInfo.dummy_bob(), 

1578 days_left=7, 

1579 surfed=True, 

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

1581 ) 

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

1583 

1584 

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

1586class ModeratorNoteEmail(EmailBase): 

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

1588 

1589 @property 

1590 def string_key_base(self) -> str: 

1591 return "moderator_note" 

1592 

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

1594 builder = self._body_builder(loc_context) 

1595 builder.para(".body") 

1596 return builder.build() 

1597 

1598 @classmethod 

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

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

1601 

1602 

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

1604class NewBlogPostEmail(EmailBase): 

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

1606 

1607 title: str 

1608 blurb: str 

1609 url: str 

1610 

1611 @property 

1612 def string_key_base(self) -> str: 

1613 return "new_blog_post" 

1614 

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

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

1617 

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

1619 return self.blurb 

1620 

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

1622 builder = self._body_builder(loc_context) 

1623 builder.para(".intro") 

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

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

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

1627 return builder.build() 

1628 

1629 @classmethod 

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

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

1632 

1633 @classmethod 

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

1635 return [ 

1636 cls( 

1637 user_name="Alice", 

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

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

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

1641 ) 

1642 ] 

1643 

1644 

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

1646class OnboardingReminderEmail(EmailBase): 

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

1648 

1649 initial: bool 

1650 

1651 @property 

1652 def string_key_base(self) -> str: 

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

1654 

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

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

1657 edit_profile_url = urls.edit_profile_link() 

1658 if self.initial: 

1659 builder.para(".welcome") 

1660 builder.para(".early_user_role") 

1661 builder.para(".fill_in_profile") 

1662 builder.para(".edit_profile_prompt") 

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

1664 builder.para(".share_with_friends") 

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

1666 builder.para(".platform_under_development") 

1667 builder.para(".thanks_for_joining") 

1668 builder.para(".signature") 

1669 else: 

1670 builder.para(".intro") 

1671 builder.para(".fill_in_profile") 

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

1673 builder.para(".no_empty_accounts") 

1674 builder.para(".profile_importance") 

1675 builder.para(".signature") 

1676 return builder.build() 

1677 

1678 @classmethod 

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

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

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

1682 

1683 

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

1685class PasswordChangedEmail(EmailBase): 

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

1687 

1688 @property 

1689 def string_key_base(self) -> str: 

1690 return "password_changed" 

1691 

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

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

1694 builder.para(".body") 

1695 return builder.build() 

1696 

1697 @classmethod 

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

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

1700 

1701 

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

1703class PasswordResetCompletedEmail(EmailBase): 

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

1705 

1706 @property 

1707 def string_key_base(self) -> str: 

1708 return "password_reset.completed" 

1709 

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

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

1712 builder.para(".body") 

1713 return builder.build() 

1714 

1715 @classmethod 

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

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

1718 

1719 

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

1721class PasswordResetStartedEmail(EmailBase): 

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

1723 

1724 password_reset_link: str 

1725 

1726 @property 

1727 def string_key_base(self) -> str: 

1728 return "password_reset.started" 

1729 

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

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

1732 builder.para(".request_description") 

1733 builder.para(".confirmation_instructions") 

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

1735 return builder.build() 

1736 

1737 @classmethod 

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

1739 return cls( 

1740 user_name=user_name, 

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

1742 ) 

1743 

1744 @classmethod 

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

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

1747 

1748 

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

1750class PhoneNumberChangeEmail(EmailBase): 

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

1752 

1753 new_phone_number: str 

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

1755 

1756 @property 

1757 def string_key_base(self) -> str: 

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

1759 

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

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

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

1763 return builder.build() 

1764 

1765 @classmethod 

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

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

1768 

1769 @classmethod 

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

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

1772 

1773 @classmethod 

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

1775 prototype = cls( 

1776 user_name="Alice", 

1777 new_phone_number="+12223334444", 

1778 completed=False, 

1779 ) 

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

1781 

1782 

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

1784class PostalVerificationFailedEmail(EmailBase): 

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

1786 

1787 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1788 

1789 @property 

1790 def string_key_base(self) -> str: 

1791 return "postal_verification.failed" 

1792 

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

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

1795 match self.reason: 

1796 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1797 reason_string_key = ".reason_code_expired" 

1798 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1799 reason_string_key = ".reason_too_many_attempts" 

1800 case _: 

1801 reason_string_key = ".reason_unknown" 

1802 builder.para(reason_string_key) 

1803 return builder.build() 

1804 

1805 @classmethod 

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

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

1808 

1809 @classmethod 

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

1811 prototype = cls( 

1812 user_name="Alice", 

1813 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1814 ) 

1815 return [ 

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

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

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

1819 ] 

1820 

1821 

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

1823class PostalVerificationPostcardSentEmail(EmailBase): 

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

1825 

1826 city: str 

1827 country: str 

1828 

1829 @property 

1830 def string_key_base(self) -> str: 

1831 return "postal_verification.postcard_sent" 

1832 

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

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

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

1836 return builder.build() 

1837 

1838 @classmethod 

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

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

1841 

1842 @classmethod 

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

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

1845 

1846 

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

1848class PostalVerificationSucceededEmail(EmailBase): 

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

1850 

1851 @property 

1852 def string_key_base(self) -> str: 

1853 return "postal_verification.succeeded" 

1854 

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

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

1857 builder.para(".body") 

1858 return builder.build() 

1859 

1860 @classmethod 

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

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

1863 

1864 

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

1866class SignupVerifyEmail(EmailBase): 

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

1868 

1869 verify_url: str 

1870 

1871 @property 

1872 def string_key_base(self) -> str: 

1873 return "signup.verify" 

1874 

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

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

1877 

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

1879 builder = self._body_builder(loc_context) 

1880 builder.para(".thanks") 

1881 builder.para(".instructions") 

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

1883 builder.para("signup.closing") 

1884 return builder.build() 

1885 

1886 @classmethod 

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

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

1889 

1890 

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

1892class SignupContinueEmail(EmailBase): 

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

1894 

1895 continue_url: str 

1896 

1897 @property 

1898 def string_key_base(self) -> str: 

1899 return "signup.continue" 

1900 

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

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

1903 

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

1905 builder = self._body_builder(loc_context) 

1906 builder.para(".request") 

1907 builder.para(".instructions") 

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

1909 builder.para("signup.closing") 

1910 builder.para(".ignore_if_unexpected") 

1911 return builder.build() 

1912 

1913 @classmethod 

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

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

1916 

1917 

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

1919class StrongVerificationFailedEmail(EmailBase): 

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

1921 

1922 reason: notification_data_pb2.SVFailReason.ValueType 

1923 

1924 @property 

1925 def string_key_base(self) -> str: 

1926 return "strong_verification.failed" 

1927 

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

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

1930 match self.reason: 

1931 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

1932 reason_string_key = ".reason_wrong_birthdate_or_gender" 

1933 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

1934 reason_string_key = ".reason_not_a_passport" 

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

1936 reason_string_key = ".reason_duplicate" 

1937 case _: 

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

1939 builder.para(reason_string_key) 

1940 return builder.build() 

1941 

1942 @classmethod 

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

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

1945 

1946 @classmethod 

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

1948 prototype = cls( 

1949 user_name="Alice", 

1950 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

1951 ) 

1952 return [ 

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

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

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

1956 ] 

1957 

1958 

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

1960class StrongVerificationSucceededEmail(EmailBase): 

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

1962 

1963 @property 

1964 def string_key_base(self) -> str: 

1965 return "strong_verification.succeeded" 

1966 

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

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

1969 builder.para(".success_message") 

1970 builder.para(".thanks_message") 

1971 builder.para(".cost_explanation") 

1972 builder.para(".donation_request") 

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

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

1975 return builder.build() 

1976 

1977 @classmethod 

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

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

1980 

1981 

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

1983class ThreadReplyEmail(EmailBase): 

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

1985 

1986 author: UserInfo 

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

1988 markdown_text: str 

1989 view_link: str 

1990 

1991 @property 

1992 def string_key_base(self) -> str: 

1993 return "thread_reply" 

1994 

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

1996 return self._localize( 

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

1998 ) 

1999 

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

2001 builder = self._body_builder(loc_context) 

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

2003 builder.user(self.author) 

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

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

2006 return builder.build() 

2007 

2008 @classmethod 

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

2010 parent = data.WhichOneof("reply_parent") 

2011 if parent == "event": 

2012 parent_context = data.event.title 

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

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

2015 parent_context = data.discussion.title 

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

2017 else: 

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

2019 return cls( 

2020 user_name=user_name, 

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

2022 parent_context=parent_context, 

2023 markdown_text=data.reply.content, 

2024 view_link=view_link, 

2025 ) 

2026 

2027 @classmethod 

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

2029 return [ 

2030 cls( 

2031 user_name="Alice", 

2032 author=UserInfo.dummy_bob(), 

2033 parent_context="Best hiking trails near Berlin", 

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

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

2036 ) 

2037 ] 

2038 

2039 

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

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