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

636 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-29 04:01 +0000

1""" 

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

3""" 

4 

5import re 

6from abc import ABC, abstractmethod 

7from dataclasses import dataclass, replace 

8from datetime import UTC, date, datetime 

9from typing import Self, assert_never 

10 

11from couchers import urls 

12from couchers.email.rendering import ( 

13 EmailBlock, 

14 EmailBlocksBuilder, 

15 ParaBlock, 

16 UserInfo, 

17 get_emails_i18next, 

18) 

19from couchers.i18n import LocalizationContext 

20from couchers.i18n.i18next import SubstitutionDict 

21from couchers.i18n.localize import format_phone_number 

22from couchers.notifications.quick_links import generate_quick_decline_link 

23from couchers.proto import conversations_pb2, notification_data_pb2 

24 

25 

26@dataclass 

27class EmailBase(ABC): 

28 """ 

29 Base class for email data models, which capture all the data required to render 

30 an email's subject line and body as HTML or plaintext, in any locale. 

31 """ 

32 

33 user_name: str 

34 

35 @property 

36 @abstractmethod 

37 def string_key_prefix(self) -> str: ... 

38 

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

40 """Gets the subject line header of the email.""" 

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

42 

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

44 """Gets the line that gets shown as a preview next to the title in users' inboxes.""" 

45 return None 

46 

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

48 """Gets the blocks that form the body of the email.""" 

49 

50 # Delegate to build_body, but wrap with greetings and closing lines common to all emails. 

51 i18next = get_emails_i18next() 

52 builder = EmailBlocksBuilder(locale=loc_context.locale, string_key_prefix=self.string_key_prefix) 

53 builder.block( 

54 ParaBlock(text=i18next.localize("generic.greeting_line", loc_context.locale, {"name": self.user_name})) 

55 ) 

56 self.build_body(builder, loc_context) 

57 builder.block(ParaBlock(text=i18next.localize("generic.closing_line", loc_context.locale))) 

58 return builder.blocks 

59 

60 @abstractmethod 

61 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: ... 

62 

63 @classmethod 

64 @abstractmethod 

65 def dummy_data(cls) -> Self: 

66 """Returns an instance filled with dummy data that can be used for testing.""" 

67 ... 

68 

69 @classmethod 

70 def dummy_variants(cls) -> list[Self]: 

71 """ 

72 Returns dummy instances covering every distinct rendering variant of this email. 

73 

74 Emails whose subject or body depends on internal state (e.g. a status enum or a 

75 boolean) build their localization keys dynamically, so a single dummy instance only 

76 exercises one branch. Such emails override this to return one instance per branch, 

77 ensuring the rendering tests resolve every localization key the class can produce. 

78 """ 

79 return [cls.dummy_data()] 

80 

81 # Helpers for localizing email-specific strings 

82 def _localize( 

83 self, loc_context: LocalizationContext, key: str, substitutions: SubstitutionDict | None = None 

84 ) -> str: 

85 key = f"{self.string_key_prefix}.{key}" 

86 return get_emails_i18next().localize(key, loc_context.locale, substitutions) 

87 

88 def _body_builder(self, loc_context: LocalizationContext) -> EmailBlocksBuilder: 

89 return EmailBlocksBuilder(locale=loc_context.locale, string_key_prefix=self.string_key_prefix) 

90 

91 

92# Specific email definitions 

93 

94 

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

96class AccountDeletionStartedEmail(EmailBase): 

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

98 

99 deletion_link: str 

100 

101 @property 

102 def string_key_prefix(self) -> str: 

103 return "account_deletion_started" 

104 

105 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

106 builder.para("request_description") 

107 builder.para("confirmation_instructions") 

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

109 builder.security_warning_para() 

110 

111 @classmethod 

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

113 return cls( 

114 user_name=user_name, 

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

116 ) 

117 

118 @classmethod 

119 def dummy_data(cls) -> AccountDeletionStartedEmail: 

120 return AccountDeletionStartedEmail( 

121 user_name="Alice", 

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

123 ) 

124 

125 

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

127class AccountDeletionCompletedEmail(EmailBase): 

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

129 

130 undelete_link: str 

131 days: int 

132 

133 @property 

134 def string_key_prefix(self) -> str: 

135 return "account_deletion_completed" 

136 

137 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

138 builder.para("confirmation") 

139 builder.para("farewell") 

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

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

142 builder.security_warning_para() 

143 

144 @classmethod 

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

146 return cls( 

147 user_name=user_name, 

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

149 days=data.undelete_days, 

150 ) 

151 

152 @classmethod 

153 def dummy_data(cls) -> AccountDeletionCompletedEmail: 

154 return AccountDeletionCompletedEmail( 

155 user_name="Alice", 

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

157 days=30, 

158 ) 

159 

160 

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

162class AccountDeletionRecoveredEmail(EmailBase): 

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

164 

165 @property 

166 def string_key_prefix(self) -> str: 

167 return "account_deletion_recovered" 

168 

169 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

170 builder.para("confirmation") 

171 builder.para("login_instructions") 

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

173 builder.para("redelete_instructions") 

174 builder.security_warning_para() 

175 

176 @classmethod 

177 def dummy_data(cls) -> AccountDeletionRecoveredEmail: 

178 return AccountDeletionRecoveredEmail(user_name="Alice") 

179 

180 

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

182class APIKeyIssuedEmail(EmailBase): 

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

184 

185 api_key: str 

186 expiry: datetime 

187 

188 @property 

189 def string_key_prefix(self) -> str: 

190 return "api_key_issued" 

191 

192 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

193 builder.para("header") 

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

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

196 builder.para("usage_warning") 

197 builder.para("policy_warning") 

198 builder.security_warning_para() 

199 

200 @classmethod 

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

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

203 

204 @classmethod 

205 def dummy_data(cls) -> APIKeyIssuedEmail: 

206 return APIKeyIssuedEmail( 

207 user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC) 

208 ) 

209 

210 

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

212class BadgeChangedEmail(EmailBase): 

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

214 

215 badge_name: str 

216 added: bool 

217 

218 @property 

219 def string_key_prefix(self) -> str: 

220 return "badge_added" if self.added else "badge_removed" 

221 

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

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

224 

225 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

227 

228 @classmethod 

229 def from_notification( 

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

231 ) -> Self: 

232 return cls( 

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

234 ) 

235 

236 @classmethod 

237 def dummy_data(cls) -> BadgeChangedEmail: 

238 return BadgeChangedEmail(user_name="Alice", badge_name="Founder", added=True) 

239 

240 @classmethod 

241 def dummy_variants(cls) -> list[BadgeChangedEmail]: 

242 base = cls.dummy_data() 

243 return [replace(base, added=True), replace(base, added=False)] 

244 

245 

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

247class BirthdateChangedEmail(EmailBase): 

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

249 

250 new_birthdate: date 

251 

252 @property 

253 def string_key_prefix(self) -> str: 

254 return "birthdate_changed" 

255 

256 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

258 builder.security_warning_para() 

259 

260 @classmethod 

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

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

263 

264 @classmethod 

265 def dummy_data(cls) -> BirthdateChangedEmail: 

266 return BirthdateChangedEmail( 

267 user_name="Alice", 

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

269 ) 

270 

271 

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

273class ChatMessageReceivedEmail(EmailBase): 

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

275 

276 group_chat_title: str | None # None if direct message 

277 author: UserInfo 

278 text: str 

279 view_url: str 

280 

281 @property 

282 def string_key_prefix(self) -> str: 

283 return f"chat_message_received.{'direct' if self.group_chat_title is None else 'group'}" 

284 

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

286 return self._localize( 

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

288 ) 

289 

290 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

292 builder.user(self.author) 

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

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

295 

296 @classmethod 

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

298 group_chat_title: str | None = data.group_chat_title 

299 if not group_chat_title: 299 ↛ 307line 299 didn't jump to line 307 because the condition on line 299 was always true

300 # Backcompat (2026-05): The group name previously was formatted in the message string 

301 # msg = f"{message.author.name} sent a message in {group_chat.title}" 

302 if match := re.search(" sent a message in (.+)$", data.message or ""): 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

303 group_chat_title = match[1] 

304 else: 

305 group_chat_title = None 

306 

307 return cls( 

308 user_name, 

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

310 text=data.text, 

311 group_chat_title=group_chat_title, 

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

313 ) 

314 

315 @classmethod 

316 def dummy_data(cls) -> ChatMessageReceivedEmail: 

317 return ChatMessageReceivedEmail( 

318 user_name="Alice", 

319 group_chat_title=None, 

320 author=UserInfo.dummy_bob(), 

321 text="Hi Alice!", 

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

323 ) 

324 

325 @classmethod 

326 def dummy_variants(cls) -> list[ChatMessageReceivedEmail]: 

327 base = cls.dummy_data() 

328 return [ 

329 replace(base, group_chat_title=None), 

330 replace(base, group_chat_title="Best friends"), 

331 ] 

332 

333 

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

335class ChatMessagesMissedEmail(EmailBase): 

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

337 

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

339 class Entry: 

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

341 

342 group_chat_title: str | None # None if direct message 

343 missed_count: int 

344 latest_message_author: UserInfo 

345 latest_message_text: str 

346 view_url: str 

347 

348 entries: list[Entry] 

349 

350 @property 

351 def string_key_prefix(self) -> str: 

352 return "chat_messages_missed" 

353 

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

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

356 

357 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

358 for entry in self.entries: 

359 if entry.group_chat_title: 

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

361 else: 

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

363 builder.user(entry.latest_message_author) 

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

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

366 

367 @classmethod 

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

369 missed_entries = [] 

370 for message in data.messages: 

371 group_chat_title: str | None = message.group_chat_title 

372 missed_count: int = message.unseen_count 

373 

374 # Backcompat (2026-05): The group name and unseen count were previously was formatted in the message string 

375 # msg = f"You missed {unseen_count} message(s) in {group_chat.title}" 

376 if not group_chat_title or not missed_count: 376 ↛ 387line 376 didn't jump to line 387 because the condition on line 376 was always true

377 if match := re.search(" message(s) in (.+)$", message.message or ""): 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true

378 group_chat_title = match[1] 

379 else: 

380 group_chat_title = None 

381 

382 if match := re.search(r"^You missed (\d+) message(s)", message.message or ""): 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 missed_count = int(match[1]) 

384 else: 

385 missed_count = 1 

386 

387 missed_entries.append( 

388 cls.Entry( 

389 group_chat_title=group_chat_title, 

390 missed_count=missed_count, 

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

392 latest_message_text=message.text, 

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

394 ) 

395 ) 

396 

397 return cls(user_name, entries=missed_entries) 

398 

399 @classmethod 

400 def dummy_data(cls) -> ChatMessagesMissedEmail: 

401 return ChatMessagesMissedEmail( 

402 user_name="Alice", 

403 entries=[ 

404 ChatMessagesMissedEmail.Entry( 

405 group_chat_title=None, 

406 missed_count=1, 

407 latest_message_author=UserInfo.dummy_bob(), 

408 latest_message_text="Hi Alice!", 

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

410 ), 

411 ChatMessagesMissedEmail.Entry( 

412 group_chat_title="Best friends", 

413 missed_count=2, 

414 latest_message_author=UserInfo.dummy_bob(), 

415 latest_message_text="Hi y'all!", 

416 view_url="https://couchers.org/messages/chats/124", 

417 ), 

418 ], 

419 ) 

420 

421 

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

423class DiscussionCreatedEmail(EmailBase): 

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

425 

426 author: UserInfo 

427 title: str 

428 parent_context: str # Community or group name 

429 markdown_text: str 

430 view_link: str 

431 

432 @property 

433 def string_key_prefix(self) -> str: 

434 return "discussion_created" 

435 

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

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

438 

439 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

440 builder.para( 

441 "body", 

442 { 

443 "author": self.author.name, 

444 "title": self.title, 

445 "parent_context": self.parent_context, 

446 }, 

447 ) 

448 builder.user(self.author) 

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

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

451 

452 @classmethod 

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

454 discussion = data.discussion 

455 return cls( 

456 user_name=user_name, 

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

458 title=discussion.title, 

459 parent_context=discussion.owner_title, 

460 markdown_text=discussion.content, 

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

462 ) 

463 

464 @classmethod 

465 def dummy_data(cls) -> DiscussionCreatedEmail: 

466 return DiscussionCreatedEmail( 

467 user_name="Alice", 

468 author=UserInfo.dummy_bob(), 

469 title="Best hiking trails near Berlin", 

470 parent_context="Berlin Community", 

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

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

473 ) 

474 

475 

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

477class DiscussionCommentEmail(EmailBase): 

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

479 

480 author: UserInfo 

481 discussion_title: str 

482 discussion_parent_context: str # Community or group name 

483 markdown_text: str 

484 view_link: str 

485 

486 @property 

487 def string_key_prefix(self) -> str: 

488 return "discussion_comment" 

489 

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

491 return self._localize( 

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

493 ) 

494 

495 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

496 builder.para( 

497 "body", 

498 { 

499 "author": self.author.name, 

500 "discussion_title": self.discussion_title, 

501 "parent_context": self.discussion_parent_context, 

502 }, 

503 ) 

504 builder.user(self.author) 

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

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

507 

508 @classmethod 

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

510 discussion = data.discussion 

511 return cls( 

512 user_name=user_name, 

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

514 discussion_title=discussion.title, 

515 discussion_parent_context=discussion.owner_title, 

516 markdown_text=data.reply.content, 

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

518 ) 

519 

520 @classmethod 

521 def dummy_data(cls) -> DiscussionCommentEmail: 

522 return DiscussionCommentEmail( 

523 user_name="Alice", 

524 author=UserInfo.dummy_bob(), 

525 discussion_title="Best hiking trails near Berlin", 

526 discussion_parent_context="Berlin Community", 

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

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

529 ) 

530 

531 

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

533class EmailAddressChangedEmail(EmailBase): 

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

535 

536 new_email: str 

537 

538 @property 

539 def string_key_prefix(self) -> str: 

540 return "email_address_change_initiated" 

541 

542 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

544 builder.security_warning_para() 

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 dummy_data(cls) -> EmailAddressChangedEmail: 

552 return EmailAddressChangedEmail(user_name="Alice", new_email="alice@example.com") 

553 

554 

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

556class EmailAddressVerifiedEmail(EmailBase): 

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

558 

559 @property 

560 def string_key_prefix(self) -> str: 

561 return "email_address_verified" 

562 

563 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

564 builder.para("body") 

565 builder.security_warning_para() 

566 

567 @classmethod 

568 def dummy_data(cls) -> EmailAddressVerifiedEmail: 

569 return EmailAddressVerifiedEmail(user_name="Alice") 

570 

571 

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

573class FriendRequestReceivedEmail(EmailBase): 

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

575 

576 befriender: UserInfo 

577 

578 @property 

579 def string_key_prefix(self) -> str: 

580 return "friend_request_received" 

581 

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

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

584 

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

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

587 

588 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

590 builder.user(self.befriender) 

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

592 builder.para("closing") 

593 builder.do_not_reply_request_para() 

594 

595 @classmethod 

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

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

598 

599 @classmethod 

600 def dummy_data(cls) -> FriendRequestReceivedEmail: 

601 return FriendRequestReceivedEmail( 

602 user_name="Alice", 

603 befriender=UserInfo.dummy_bob(), 

604 ) 

605 

606 

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

608class FriendRequestAcceptedEmail(EmailBase): 

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

610 

611 new_friend: UserInfo 

612 

613 @property 

614 def string_key_prefix(self) -> str: 

615 return "friend_request_accepted" 

616 

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

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

619 

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

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

622 

623 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

625 builder.user(self.new_friend) 

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

627 builder.para("closing") 

628 

629 @classmethod 

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

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

632 

633 @classmethod 

634 def dummy_data(cls) -> FriendRequestAcceptedEmail: 

635 return FriendRequestAcceptedEmail( 

636 user_name="Alice", 

637 new_friend=UserInfo.dummy_bob(), 

638 ) 

639 

640 

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

642class GenderChangedEmail(EmailBase): 

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

644 

645 new_gender: str 

646 

647 @property 

648 def string_key_prefix(self) -> str: 

649 return "gender_changed" 

650 

651 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

653 builder.security_warning_para() 

654 

655 @classmethod 

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

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

658 

659 @classmethod 

660 def dummy_data(cls) -> GenderChangedEmail: 

661 return GenderChangedEmail( 

662 user_name="Alice", 

663 new_gender="Male", 

664 ) 

665 

666 

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

668class HostRequestCreatedEmail(EmailBase): 

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

670 

671 surfer: UserInfo 

672 from_date: date 

673 to_date: date 

674 text: str 

675 quick_decline_link: str 

676 view_link: str 

677 

678 @property 

679 def string_key_prefix(self) -> str: 

680 return "host_request_created" 

681 

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

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

684 

685 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

687 builder.user( 

688 self.surfer, 

689 "date_range", 

690 { 

691 "from_date": loc_context.localize_date(self.from_date), 

692 "to_date": loc_context.localize_date(self.to_date), 

693 }, 

694 ) 

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

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

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

698 builder.para("respond_encouragement") 

699 builder.do_not_reply_request_para() 

700 

701 @classmethod 

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

703 return cls( 

704 user_name, 

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

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

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

708 text=data.text, 

709 quick_decline_link=generate_quick_decline_link(data.host_request), 

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

711 ) 

712 

713 @classmethod 

714 def dummy_data(cls) -> HostRequestCreatedEmail: 

715 return HostRequestCreatedEmail( 

716 user_name="Alice", 

717 surfer=UserInfo.dummy_bob(), 

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

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

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

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

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

723 ) 

724 

725 

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

727class HostRequestReminderEmail(EmailBase): 

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

729 

730 surfer: UserInfo 

731 from_date: date 

732 to_date: date 

733 view_link: str 

734 

735 @property 

736 def string_key_prefix(self) -> str: 

737 return "host_request_reminder" 

738 

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

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

741 

742 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

743 builder.para("body") 

744 builder.user( 

745 self.surfer, 

746 ".host_request_generic.date_range", 

747 { 

748 "from_date": loc_context.localize_date(self.from_date), 

749 "to_date": loc_context.localize_date(self.to_date), 

750 }, 

751 ) 

752 builder.action(self.view_link, ".host_request_generic.view_action") 

753 builder.do_not_reply_request_para() 

754 

755 @classmethod 

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

757 return cls( 

758 user_name, 

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

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

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

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

763 ) 

764 

765 @classmethod 

766 def dummy_data(cls) -> HostRequestReminderEmail: 

767 return HostRequestReminderEmail( 

768 user_name="Alice", 

769 surfer=UserInfo.dummy_bob(), 

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

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

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

773 ) 

774 

775 

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

777class HostRequestMessageEmail(EmailBase): 

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

779 

780 other_user: UserInfo 

781 from_date: date 

782 to_date: date 

783 text: str 

784 from_host: bool 

785 view_link: str 

786 

787 @property 

788 def string_key_prefix(self) -> str: 

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

790 return f"host_request_message.{variant}" 

791 

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

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

794 

795 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

797 builder.user( 

798 self.other_user, 

799 ".host_request_generic.date_range", 

800 { 

801 "from_date": loc_context.localize_date(self.from_date), 

802 "to_date": loc_context.localize_date(self.to_date), 

803 }, 

804 ) 

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

806 builder.action(self.view_link, ".host_request_generic.view_action") 

807 builder.do_not_reply_request_para() 

808 

809 @classmethod 

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

811 return cls( 

812 user_name, 

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

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

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

816 text=data.text, 

817 from_host=not data.am_host, 

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

819 ) 

820 

821 @classmethod 

822 def dummy_data(cls) -> HostRequestMessageEmail: 

823 return HostRequestMessageEmail( 

824 user_name="Alice", 

825 other_user=UserInfo.dummy_bob(), 

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

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

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

829 from_host=True, 

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

831 ) 

832 

833 @classmethod 

834 def dummy_variants(cls) -> list[HostRequestMessageEmail]: 

835 base = cls.dummy_data() 

836 return [replace(base, from_host=True), replace(base, from_host=False)] 

837 

838 

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

840class HostRequestMissedMessagesEmail(EmailBase): 

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

842 

843 other_user: UserInfo 

844 from_date: date 

845 to_date: date 

846 from_host: bool 

847 view_link: str 

848 

849 @property 

850 def string_key_prefix(self) -> str: 

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

852 return f"host_request_missed_messages.{variant}" 

853 

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

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

856 

857 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

859 builder.user( 

860 self.other_user, 

861 ".host_request_generic.date_range", 

862 { 

863 "from_date": loc_context.localize_date(self.from_date), 

864 "to_date": loc_context.localize_date(self.to_date), 

865 }, 

866 ) 

867 builder.action(self.view_link, ".host_request_generic.view_action") 

868 builder.do_not_reply_request_para() 

869 

870 @classmethod 

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

872 return cls( 

873 user_name, 

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

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

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

877 from_host=not data.am_host, 

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

879 ) 

880 

881 @classmethod 

882 def dummy_data(cls) -> HostRequestMissedMessagesEmail: 

883 return HostRequestMissedMessagesEmail( 

884 user_name="Alice", 

885 other_user=UserInfo.dummy_bob(), 

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

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

888 from_host=True, 

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

890 ) 

891 

892 @classmethod 

893 def dummy_variants(cls) -> list[HostRequestMissedMessagesEmail]: 

894 base = cls.dummy_data() 

895 return [replace(base, from_host=True), replace(base, from_host=False)] 

896 

897 

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

899class HostRequestStatusChangedEmail(EmailBase): 

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

901 

902 other_user: UserInfo 

903 from_date: date 

904 to_date: date 

905 new_status: conversations_pb2.HostRequestStatus.ValueType 

906 view_link: str 

907 

908 @property 

909 def string_key_prefix(self) -> str: 

910 base_key = "host_request_status_changed" 

911 match self.new_status: 

912 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED: 

913 return f"{base_key}.accepted_by_host" 

914 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED: 

915 return f"{base_key}.declined_by_host" 

916 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED: 

917 return f"{base_key}.confirmed_by_surfer" 

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

919 return f"{base_key}.cancelled_by_surfer" 

920 case _: 

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

922 

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

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

925 

926 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

928 builder.user( 

929 self.other_user, 

930 ".host_request_generic.date_range", 

931 { 

932 "from_date": loc_context.localize_date(self.from_date), 

933 "to_date": loc_context.localize_date(self.to_date), 

934 }, 

935 ) 

936 builder.action(self.view_link, ".host_request_generic.view_action") 

937 builder.do_not_reply_request_para() 

938 

939 @classmethod 

940 def from_notification( 

941 cls, 

942 data: notification_data_pb2.HostRequestAccept 

943 | notification_data_pb2.HostRequestReject 

944 | notification_data_pb2.HostRequestConfirm 

945 | notification_data_pb2.HostRequestCancel, 

946 *, 

947 user_name: str, 

948 ) -> Self: 

949 other_user: UserInfo 

950 new_status: conversations_pb2.HostRequestStatus.ValueType 

951 match data: 

952 case notification_data_pb2.HostRequestAccept(): 

953 other_user = UserInfo.from_protobuf(data.host) 

954 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED 

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

956 other_user = UserInfo.from_protobuf(data.host) 

957 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED 

958 case notification_data_pb2.HostRequestConfirm(): 

959 other_user = UserInfo.from_protobuf(data.surfer) 

960 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED 

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

962 other_user = UserInfo.from_protobuf(data.surfer) 

963 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED 

964 case _: 

965 # Enable mypy's exhaustiveness checking 

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

967 

968 return cls( 

969 user_name, 

970 other_user=other_user, 

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

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

973 new_status=new_status, 

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

975 ) 

976 

977 @classmethod 

978 def dummy_data(cls) -> HostRequestStatusChangedEmail: 

979 return HostRequestStatusChangedEmail( 

980 user_name="Alice", 

981 other_user=UserInfo.dummy_bob(), 

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

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

984 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

986 ) 

987 

988 @classmethod 

989 def dummy_variants(cls) -> list[HostRequestStatusChangedEmail]: 

990 base = cls.dummy_data() 

991 return [ 

992 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED), 

993 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED), 

994 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED), 

995 replace(base, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED), 

996 ] 

997 

998 

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

1000class ModeratorNoteEmail(EmailBase): 

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

1002 

1003 @property 

1004 def string_key_prefix(self) -> str: 

1005 return "moderator_note" 

1006 

1007 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1008 builder.para("body") 

1009 

1010 @classmethod 

1011 def dummy_data(cls) -> ModeratorNoteEmail: 

1012 return ModeratorNoteEmail(user_name="Alice") 

1013 

1014 

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

1016class PasswordChangedEmail(EmailBase): 

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

1018 

1019 @property 

1020 def string_key_prefix(self) -> str: 

1021 return "password_changed" 

1022 

1023 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1024 builder.para("body") 

1025 builder.security_warning_para() 

1026 

1027 @classmethod 

1028 def dummy_data(cls) -> PasswordChangedEmail: 

1029 return PasswordChangedEmail(user_name="Alice") 

1030 

1031 

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

1033class PasswordResetCompletedEmail(EmailBase): 

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

1035 

1036 @property 

1037 def string_key_prefix(self) -> str: 

1038 return "password_reset_completed" 

1039 

1040 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1041 builder.para("body") 

1042 builder.security_warning_para() 

1043 

1044 @classmethod 

1045 def dummy_data(cls) -> PasswordResetCompletedEmail: 

1046 return PasswordResetCompletedEmail(user_name="Alice") 

1047 

1048 

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

1050class PasswordResetStartedEmail(EmailBase): 

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

1052 

1053 password_reset_link: str 

1054 

1055 @property 

1056 def string_key_prefix(self) -> str: 

1057 return "password_reset_started" 

1058 

1059 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1060 builder.para("request_description") 

1061 builder.para("confirmation_instructions") 

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

1063 builder.security_warning_para() 

1064 

1065 @classmethod 

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

1067 return cls( 

1068 user_name=user_name, 

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

1070 ) 

1071 

1072 @classmethod 

1073 def dummy_data(cls) -> PasswordResetStartedEmail: 

1074 return PasswordResetStartedEmail(user_name="Alice", password_reset_link="https://couchers.org/reset-password") 

1075 

1076 

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

1078class PhoneNumberChangeEmail(EmailBase): 

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

1080 

1081 new_phone_number: str 

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

1083 

1084 @property 

1085 def string_key_prefix(self) -> str: 

1086 return "phone_number_verified" if self.completed else "phone_number_verification_started" 

1087 

1088 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

1090 builder.security_warning_para() 

1091 

1092 @classmethod 

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

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

1095 

1096 @classmethod 

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

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

1099 

1100 @classmethod 

1101 def dummy_data(cls) -> PhoneNumberChangeEmail: 

1102 return PhoneNumberChangeEmail( 

1103 user_name="Alice", 

1104 new_phone_number="+12223334444", 

1105 completed=False, 

1106 ) 

1107 

1108 @classmethod 

1109 def dummy_variants(cls) -> list[PhoneNumberChangeEmail]: 

1110 base = cls.dummy_data() 

1111 return [replace(base, completed=False), replace(base, completed=True)] 

1112 

1113 

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

1115class PostalVerificationFailedEmail(EmailBase): 

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

1117 

1118 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1119 

1120 @property 

1121 def string_key_prefix(self) -> str: 

1122 return "postal_verification_failed" 

1123 

1124 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1125 match self.reason: 

1126 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1127 reason_string_key = "reason_code_expired" 

1128 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1129 reason_string_key = "reason_too_many_attempts" 

1130 case _: 

1131 reason_string_key = "reason_unknown" 

1132 builder.para(reason_string_key) 

1133 builder.security_warning_para() 

1134 

1135 @classmethod 

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

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

1138 

1139 @classmethod 

1140 def dummy_data(cls) -> PostalVerificationFailedEmail: 

1141 return PostalVerificationFailedEmail( 

1142 user_name="Alice", 

1143 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1144 ) 

1145 

1146 @classmethod 

1147 def dummy_variants(cls) -> list[PostalVerificationFailedEmail]: 

1148 base = cls.dummy_data() 

1149 return [ 

1150 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED), 

1151 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS), 

1152 replace(base, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN), 

1153 ] 

1154 

1155 

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

1157class PostalVerificationPostcardSentEmail(EmailBase): 

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

1159 

1160 city: str 

1161 country: str 

1162 

1163 @property 

1164 def string_key_prefix(self) -> str: 

1165 return "postal_verification_postcard_sent" 

1166 

1167 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

1169 builder.security_warning_para() 

1170 

1171 @classmethod 

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

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

1174 

1175 @classmethod 

1176 def dummy_data(cls) -> PostalVerificationPostcardSentEmail: 

1177 return PostalVerificationPostcardSentEmail(user_name="Alice", city="New York", country="United States") 

1178 

1179 

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

1181class PostalVerificationSucceededEmail(EmailBase): 

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

1183 

1184 @property 

1185 def string_key_prefix(self) -> str: 

1186 return "postal_verification_succeeded" 

1187 

1188 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1189 builder.para("body") 

1190 builder.security_warning_para() 

1191 

1192 @classmethod 

1193 def dummy_data(cls) -> PostalVerificationSucceededEmail: 

1194 return PostalVerificationSucceededEmail(user_name="Alice") 

1195 

1196 

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

1198class StrongVerificationFailedEmail(EmailBase): 

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

1200 

1201 reason: notification_data_pb2.SVFailReason.ValueType 

1202 

1203 @property 

1204 def string_key_prefix(self) -> str: 

1205 return "strong_verification_failed" 

1206 

1207 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1208 match self.reason: 

1209 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

1210 reason_string_key = "reason_wrong_birthdate_or_gender" 

1211 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

1212 reason_string_key = "reason_not_a_passport" 

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

1214 reason_string_key = "reason_duplicate" 

1215 case _: 

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

1217 builder.para(reason_string_key) 

1218 builder.security_warning_para() 

1219 

1220 @classmethod 

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

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

1223 

1224 @classmethod 

1225 def dummy_data(cls) -> StrongVerificationFailedEmail: 

1226 return StrongVerificationFailedEmail( 

1227 user_name="Alice", 

1228 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

1229 ) 

1230 

1231 @classmethod 

1232 def dummy_variants(cls) -> list[StrongVerificationFailedEmail]: 

1233 base = cls.dummy_data() 

1234 return [ 

1235 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER), 

1236 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT), 

1237 replace(base, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE), 

1238 ] 

1239 

1240 

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

1242class StrongVerificationSucceededEmail(EmailBase): 

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

1244 

1245 @property 

1246 def string_key_prefix(self) -> str: 

1247 return "strong_verification_succeeded" 

1248 

1249 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

1250 builder.para("success_message") 

1251 builder.para("thanks_message") 

1252 builder.para("cost_explanation") 

1253 builder.para("donation_request") 

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

1255 builder.action(donate_link, "donate_action") 

1256 builder.security_warning_para() 

1257 

1258 @classmethod 

1259 def dummy_data(cls) -> StrongVerificationSucceededEmail: 

1260 return StrongVerificationSucceededEmail(user_name="Alice") 

1261 

1262 

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

1264class ThreadReplyEmail(EmailBase): 

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

1266 

1267 author: UserInfo 

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

1269 markdown_text: str 

1270 view_link: str 

1271 

1272 @property 

1273 def string_key_prefix(self) -> str: 

1274 return "thread_reply" 

1275 

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

1277 return self._localize( 

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

1279 ) 

1280 

1281 def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: 

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

1283 builder.user(self.author) 

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

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

1286 

1287 @classmethod 

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

1289 parent = data.WhichOneof("reply_parent") 

1290 if parent == "event": 

1291 parent_context = data.event.title 

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

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

1294 parent_context = data.discussion.title 

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

1296 else: 

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

1298 return cls( 

1299 user_name=user_name, 

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

1301 parent_context=parent_context, 

1302 markdown_text=data.reply.content, 

1303 view_link=view_link, 

1304 ) 

1305 

1306 @classmethod 

1307 def dummy_data(cls) -> ThreadReplyEmail: 

1308 return ThreadReplyEmail( 

1309 user_name="Alice", 

1310 author=UserInfo.dummy_bob(), 

1311 parent_context="Best hiking trails near Berlin", 

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

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

1314 )