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

1062 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 11:50 +0000

1""" 

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

3 

4Email writing style guidelines 

5 

6Subject line: 

7 Single sentence of the form "who did what" (avoid passive voice when possible) 

8 No punctuation*. 

9 Do not quote people, community, group or event names. 

10 No need to refer to "Couchers.org", the sender name already does. 

11 

12Preview line: 

13 Quoted user content (e.g. comment/reference text), otherwise none. 

14 Don't repeat or paraphrase the subject. 

15 

16Purpose line of the body (first paragraph after the greeting): 

17 Usually a single sentence stating the purpose of the email. 

18 Similar or identical to the subject but may include additional info (e.g. dates). 

19 Should be punctuated with a period*, or end with a colon (':') if we then quote user content. 

20 

21Other instructions for body text: 

22 A single purpose line is enough for day-to-day notifications, no need for further prose. 

23 Highlight key pieces of info (names, locations, dates) using <b> tags. 

24 Highlight important passages using <strong> tags. 

25 Provide a link or instructions if the user has follow-up actions. 

26 

27* Some key emails like new accounts might use an exclamation mark (limit to 1) and more personal prose. 

28""" 

29 

30import re 

31from dataclasses import dataclass, replace 

32from datetime import UTC, date, datetime, tzinfo 

33from typing import Self, assert_never 

34from zoneinfo import ZoneInfo 

35 

36from markupsafe import Markup, escape 

37 

38from couchers import urls 

39from couchers.config import config 

40from couchers.constants import LATEST_RELEASE_BLOG_URL 

41from couchers.email.blocks import ( 

42 ActionBlock, 

43 EmailBase, 

44 EmailBlock, 

45 EmailBlocksBuilder, 

46 ParaBlock, 

47 QuoteBlock, 

48 UserInfo, 

49) 

50from couchers.email.locales import get_emails_i18next 

51from couchers.i18n import LocalizationContext 

52from couchers.i18n.localize import format_phone_number 

53from couchers.markup import html_link, html_mailto_link, markdown_to_plaintext 

54from couchers.notifications.quick_links import generate_quick_decline_link 

55from couchers.proto import events_pb2, messages_pb2, notification_data_pb2 

56from couchers.utils import now, to_aware_datetime 

57 

58# Common string keys 

59_do_not_reply_request_string_key = "generic.do_not_reply_request" 

60 

61# Specific email definitions 

62 

63 

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

65class AccountDeletionStartedEmail(EmailBase): 

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

67 

68 deletion_link: str 

69 

70 @property 

71 def string_key_base(self) -> str: 

72 return "account_deletion.started" 

73 

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

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

76 builder.para(".purpose") 

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

78 return builder.build() 

79 

80 @classmethod 

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

82 return cls( 

83 user_name=user_name, 

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

85 ) 

86 

87 @classmethod 

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

89 return [ 

90 cls( 

91 user_name="Alice", 

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

93 ) 

94 ] 

95 

96 

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

98class AccountDeletionCompletedEmail(EmailBase): 

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

100 

101 undelete_link: str 

102 days: int 

103 

104 @property 

105 def string_key_base(self) -> str: 

106 return "account_deletion.completed" 

107 

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

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

110 builder.para(".purpose") 

111 builder.para(".farewell") 

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

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

114 return builder.build() 

115 

116 @classmethod 

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

118 return cls( 

119 user_name=user_name, 

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

121 days=data.undelete_days, 

122 ) 

123 

124 @classmethod 

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

126 return [ 

127 cls( 

128 user_name="Alice", 

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

130 days=30, 

131 ) 

132 ] 

133 

134 

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

136class AccountDeletionRecoveredEmail(EmailBase): 

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

138 

139 @property 

140 def string_key_base(self) -> str: 

141 return "account_deletion.recovered" 

142 

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

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

145 builder.para(".confirmation") 

146 builder.para(".login_instructions") 

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

148 builder.para(".redelete_instructions") 

149 return builder.build() 

150 

151 @classmethod 

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

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

154 

155 

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

157class ActivenessProbeEmail(EmailBase): 

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

159 

160 days_left: int 

161 

162 @property 

163 def string_key_base(self) -> str: 

164 return "activeness_probe" 

165 

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

167 builder = self._body_builder(loc_context) 

168 builder.para(".purpose") 

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

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

171 builder.para(".encouragement") 

172 

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

174 version = config.VERSION 

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

176 version = version_match[1] 

177 

178 builder.para(".latest_release", {"version": version, "blog_url": LATEST_RELEASE_BLOG_URL}) 

179 return builder.build() 

180 

181 @classmethod 

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

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

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

185 

186 @classmethod 

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

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

189 

190 

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

192class APIKeyIssuedEmail(EmailBase): 

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

194 

195 api_key: str 

196 expiry: datetime 

197 

198 @property 

199 def string_key_base(self) -> str: 

200 return "api_key_issued" 

201 

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

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

204 builder.para(".header") 

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

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

207 builder.para(".usage_warning") 

208 builder.para(".policy_warning", {"terms_url": urls.terms_of_service_url()}) 

209 return builder.build() 

210 

211 @classmethod 

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

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

214 

215 @classmethod 

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

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

218 

219 

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

221class BadgeChangedEmail(EmailBase): 

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

223 

224 badge_name: str 

225 added: bool 

226 

227 @property 

228 def string_key_base(self) -> str: 

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

230 

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

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

233 

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

235 builder = self._body_builder(loc_context) 

236 builder.para(".purpose", {"name": self.badge_name}) 

237 return builder.build() 

238 

239 @classmethod 

240 def from_notification( 

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

242 ) -> Self: 

243 return cls( 

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

245 ) 

246 

247 @classmethod 

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

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

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

251 

252 

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

254class BirthdateChangedEmail(EmailBase): 

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

256 

257 new_birthdate: date 

258 

259 @property 

260 def string_key_base(self) -> str: 

261 return "birthdate_changed" 

262 

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

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

265 builder.para(".purpose", {"date": loc_context.localize_date(self.new_birthdate)}) 

266 return builder.build() 

267 

268 @classmethod 

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

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

271 

272 @classmethod 

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

274 return [ 

275 cls( 

276 user_name="Alice", 

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

278 ) 

279 ] 

280 

281 

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

283class ChatMessageReceivedEmail(EmailBase): 

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

285 

286 group_chat_title: str | None # None if direct message 

287 author: UserInfo 

288 text: str 

289 view_url: str 

290 

291 @property 

292 def string_key_base(self) -> str: 

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

294 

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

296 return self._localize( 

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

298 ) 

299 

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

301 return self.text 

302 

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

304 builder = self._body_builder(loc_context) 

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

306 builder.user(self.author) 

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

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

309 return builder.build() 

310 

311 @classmethod 

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

313 return cls( 

314 user_name, 

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

316 text=data.text, 

317 group_chat_title=data.group_chat_title or None, 

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

319 ) 

320 

321 @classmethod 

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

323 prototype = cls( 

324 user_name="Alice", 

325 group_chat_title=None, 

326 author=UserInfo.dummy_bob(), 

327 text="Hi Alice!", 

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

329 ) 

330 return [ 

331 replace(prototype, group_chat_title=None), 

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

333 ] 

334 

335 

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

337class ChatMessagesMissedEmail(EmailBase): 

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

339 

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

341 class Entry: 

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

343 

344 group_chat_title: str | None # None if direct message 

345 missed_count: int 

346 latest_message_author: UserInfo 

347 latest_message_text: str 

348 view_url: str 

349 

350 entries: list[Entry] 

351 

352 @property 

353 def string_key_base(self) -> str: 

354 return "chat_messages.missed" 

355 

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

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

358 

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

360 if len(self.entries) != 1: 

361 return None 

362 return self.entries[0].latest_message_text 

363 

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

365 builder = self._body_builder(loc_context) 

366 builder.para(".purpose") 

367 for entry in self.entries: 

368 if entry.group_chat_title is None: 

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

370 else: 

371 builder.para(".count_in_group", {"count": entry.missed_count, "group": entry.group_chat_title}) 

372 builder.user(entry.latest_message_author) 

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

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

375 return builder.build() 

376 

377 @classmethod 

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

379 missed_entries = [ 

380 cls.Entry( 

381 group_chat_title=message.group_chat_title or None, 

382 missed_count=message.unseen_count, 

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

384 latest_message_text=message.text, 

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

386 ) 

387 for message in data.messages 

388 ] 

389 

390 return cls(user_name, entries=missed_entries) 

391 

392 @classmethod 

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

394 entry_prototype = ChatMessagesMissedEmail.Entry( 

395 group_chat_title=None, 

396 missed_count=1, 

397 latest_message_author=UserInfo.dummy_bob(), 

398 latest_message_text="Hello!", 

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

400 ) 

401 return [ 

402 cls( 

403 user_name="Alice", 

404 entries=[ 

405 replace(entry_prototype, group_chat_title=None), 

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

407 ], 

408 ) 

409 ] 

410 

411 

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

413class DiscussionCreatedEmail(EmailBase): 

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

415 

416 author: UserInfo 

417 title: str 

418 parent_context: str # Community or group name 

419 markdown_text: str 

420 view_link: str 

421 

422 @property 

423 def string_key_base(self) -> str: 

424 return "discussions.created" 

425 

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

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

428 

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

430 return markdown_to_plaintext(self.markdown_text) 

431 

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

433 builder = self._body_builder(loc_context) 

434 builder.para( 

435 ".purpose", 

436 { 

437 "author": self.author.name, 

438 "parent_context": self.parent_context, 

439 }, 

440 ) 

441 builder.user(self.author) 

442 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>"))) 

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

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

445 return builder.build() 

446 

447 @classmethod 

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

449 discussion = data.discussion 

450 return cls( 

451 user_name=user_name, 

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

453 title=discussion.title, 

454 parent_context=discussion.owner_title, 

455 markdown_text=discussion.content, 

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

457 ) 

458 

459 @classmethod 

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

461 return [ 

462 cls( 

463 user_name="Alice", 

464 author=UserInfo.dummy_bob(), 

465 title="Best hiking trails near Berlin", 

466 parent_context="Berlin", 

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

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

469 ) 

470 ] 

471 

472 

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

474class DiscussionCommentEmail(EmailBase): 

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

476 

477 author: UserInfo 

478 discussion_title: str 

479 discussion_parent_context: str # Community or group name 

480 markdown_text: str 

481 view_link: str 

482 

483 @property 

484 def string_key_base(self) -> str: 

485 return "discussions.comment" 

486 

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

488 return self._localize( 

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

490 ) 

491 

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

493 return markdown_to_plaintext(self.markdown_text) 

494 

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

496 builder = self._body_builder(loc_context) 

497 builder.para( 

498 ".purpose", 

499 { 

500 "author": self.author.name, 

501 "discussion_title": self.discussion_title, 

502 "parent_context": self.discussion_parent_context, 

503 }, 

504 ) 

505 builder.user(self.author) 

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

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

508 return builder.build() 

509 

510 @classmethod 

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

512 discussion = data.discussion 

513 return cls( 

514 user_name=user_name, 

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

516 discussion_title=discussion.title, 

517 discussion_parent_context=discussion.owner_title, 

518 markdown_text=data.reply.content, 

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

520 ) 

521 

522 @classmethod 

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

524 return [ 

525 cls( 

526 user_name="Alice", 

527 author=UserInfo.dummy_bob(), 

528 discussion_title="Best hiking trails near Berlin", 

529 discussion_parent_context="Berlin", 

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

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

532 ) 

533 ] 

534 

535 

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

537class DonationReceivedEmail(EmailBase): 

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

539 

540 amount: int 

541 receipt_url: str 

542 

543 @property 

544 def string_key_base(self) -> str: 

545 return "donation_received" 

546 

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

548 builder = self._body_builder(loc_context, default_closing=False) 

549 builder.para(".purpose", {"amount_with_currency": f"${self.amount}"}) 

550 builder.para(".contribution_impact") 

551 builder.para(".invoice_receipt_info") 

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

553 builder.para(".tax_acknowledgment") 

554 builder.para(".questions_contact", {"email_link": html_mailto_link("donations@couchers.org")}) 

555 builder.para("generic.thanks") 

556 builder.para("generic.closing_lines.founders") 

557 return builder.build() 

558 

559 @classmethod 

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

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

562 

563 @classmethod 

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

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

566 

567 

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

569class EmailChangedEmail(EmailBase): 

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

571 

572 new_email: str 

573 

574 @property 

575 def string_key_base(self) -> str: 

576 return "email_change.initiated" 

577 

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

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

580 builder.para(".purpose", {"email_address": self.new_email}) 

581 return builder.build() 

582 

583 @classmethod 

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

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

586 

587 @classmethod 

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

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

590 

591 

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

593class EmailChangeConfirmationEmail(EmailBase): 

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

595 

596 old_email: str 

597 confirm_url: str 

598 

599 @property 

600 def string_key_base(self) -> str: 

601 return "email_change.confirmation" 

602 

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

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

605 builder.para(".purpose", {"old_email": self.old_email}) 

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

607 return builder.build() 

608 

609 @classmethod 

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

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

612 

613 

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

615class EmailVerifiedEmail(EmailBase): 

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

617 

618 @property 

619 def string_key_base(self) -> str: 

620 return "email_change.verified" 

621 

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

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

624 builder.para(".purpose") 

625 return builder.build() 

626 

627 @classmethod 

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

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

630 

631 

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

633class EventInfo: 

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

635 

636 title: str 

637 start_time: datetime 

638 end_time: datetime 

639 timezone: tzinfo 

640 address: str | None # The None case handles legacy online events 

641 view_url: str 

642 description_markdown: str 

643 

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

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

646 # Force using the start/end time timezone (that of the event), not the user's. 

647 start_time_display = loc_context.localize_datetime( 

648 self.start_time, display_timezone=self.timezone, with_year=False, with_day_of_week=True 

649 ) 

650 end_time_display = loc_context.localize_datetime( 

651 self.end_time, display_timezone=self.timezone, with_year=False, with_day_of_week=True 

652 ) 

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

654 

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

656 html += "<br>" 

657 html += time_range_display 

658 if self.address: 

659 html += "<br>" 

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

661 

662 return ParaBlock(text=Markup(html)) 

663 

664 def get_description_block(self) -> EmailBlock: 

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

666 

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

668 view_action_text = loc_context.localize_string("events.generic.view_action", i18next=get_emails_i18next()) 

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

670 

671 @classmethod 

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

673 return cls( 

674 title=event.title, 

675 start_time=to_aware_datetime(event.start_time), 

676 end_time=to_aware_datetime(event.end_time), 

677 timezone=ZoneInfo(event.timezone), 

678 # Backcompat (2026-06): We might still have queued notifications referencing events with online_information. 

679 address=(event.location.address or None) if event.HasField("location") else None, 

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

681 description_markdown=event.content or "", 

682 ) 

683 

684 @staticmethod 

685 def dummy() -> EventInfo: 

686 return EventInfo( 

687 title="Berlin Meetup", 

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

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

690 timezone=UTC, 

691 address="Alexanderplatz, Berlin", 

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

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

694 ) 

695 

696 

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

698class EventCreatedEmail(EmailBase): 

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

700 

701 inviting_user: UserInfo 

702 event_info: EventInfo 

703 community_name: str | None 

704 community_url: str | None 

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

706 

707 @property 

708 def string_key_base(self) -> str: 

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

710 

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

712 return self._localize( 

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

714 ) 

715 

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

717 return markdown_to_plaintext(self.event_info.description_markdown) 

718 

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

720 builder = self._body_builder(loc_context) 

721 if self.community_name: 

722 builder.para(".purpose_with_community", {"user": self.inviting_user.name, "community": self.community_name}) 

723 else: 

724 builder.para(".purpose_no_community", {"user": self.inviting_user.name}) 

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

726 builder.user(self.inviting_user) 

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

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

729 return builder.build() 

730 

731 @classmethod 

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

733 has_community = bool(data.in_community.community_id) 

734 community_url = ( 

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

736 if has_community 

737 else None 

738 ) 

739 return cls( 

740 user_name=user_name, 

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

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

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

744 community_url=community_url, 

745 is_invite=is_invite, 

746 ) 

747 

748 @classmethod 

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

750 prototype = cls( 

751 user_name="Alice", 

752 inviting_user=UserInfo.dummy_bob(), 

753 event_info=EventInfo.dummy(), 

754 community_name="Berlin", 

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

756 is_invite=True, 

757 ) 

758 return [ 

759 replace(prototype, is_invite=True), 

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

761 replace(prototype, is_invite=False), 

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

763 ] 

764 

765 

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

767class EventUpdatedEmail(EmailBase): 

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

769 

770 updating_user: UserInfo 

771 event_info: EventInfo 

772 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] 

773 

774 @property 

775 def string_key_base(self) -> str: 

776 return "events.updated" 

777 

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

779 return self._localize( 

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

781 ) 

782 

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

784 builder = self._body_builder(loc_context) 

785 

786 updated_items_string_keys = list( 

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

788 ) 

789 if updated_items_string_keys: 

790 updated_items_text = loc_context.localize_list( 

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

792 ) 

793 builder.para(".purpose_with_items", {"user": self.updating_user.name, "items_list": updated_items_text}) 

794 else: 

795 builder.para(".purpose_generic", {"user": self.updating_user.name}) 

796 

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

798 builder.user(self.updating_user) 

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

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

801 return builder.build() 

802 

803 @classmethod 

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

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

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

807 updated_items.extend(data.updated_enum_items) 

808 

809 return cls( 

810 user_name=user_name, 

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

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

813 updated_items=updated_items, 

814 ) 

815 

816 @staticmethod 

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

818 match value: 

819 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE: 

820 return ".item_names.title" 

821 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT: 

822 return ".item_names.content" 

823 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION: 

824 return ".item_names.location" 

825 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME: 

826 return ".item_names.start_time" 

827 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME: 

828 return ".item_names.end_time" 

829 case _: 

830 return None 

831 

832 @classmethod 

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

834 prototype = cls( 

835 user_name="Alice", 

836 updating_user=UserInfo.dummy_bob(), 

837 event_info=EventInfo.dummy(), 

838 updated_items=[], 

839 ) 

840 return [ 

841 replace(prototype, updated_items=[]), 

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

843 ] 

844 

845 

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

847class EventOrganizerInvitedEmail(EmailBase): 

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

849 

850 inviting_user: UserInfo 

851 event_info: EventInfo 

852 

853 @property 

854 def string_key_base(self) -> str: 

855 return "events.organizer_invited" 

856 

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

858 return self._localize( 

859 loc_context, 

860 ".subject", 

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

862 ) 

863 

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

865 builder = self._body_builder(loc_context) 

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

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

868 builder.user(self.inviting_user) 

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

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

871 builder.para(_do_not_reply_request_string_key, epilogue=True) 

872 return builder.build() 

873 

874 @classmethod 

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

876 return cls( 

877 user_name=user_name, 

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

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

880 ) 

881 

882 @classmethod 

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

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

885 

886 

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

888class EventCommentEmail(EmailBase): 

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

890 

891 author: UserInfo 

892 event_info: EventInfo 

893 comment_markdown: str 

894 

895 @property 

896 def string_key_base(self) -> str: 

897 return "events.comment" 

898 

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

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

901 

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

903 return markdown_to_plaintext(self.comment_markdown) 

904 

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

906 builder = self._body_builder(loc_context) 

907 builder.para(".purpose", {"author": self.author.name}) 

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

909 builder.user(self.author) 

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

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

912 return builder.build() 

913 

914 @classmethod 

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

916 return cls( 

917 user_name=user_name, 

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

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

920 comment_markdown=data.reply.content, 

921 ) 

922 

923 @classmethod 

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

925 return [ 

926 cls( 

927 user_name="Alice", 

928 author=UserInfo.dummy_bob(), 

929 event_info=EventInfo.dummy(), 

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

931 ) 

932 ] 

933 

934 

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

936class EventReminderEmail(EmailBase): 

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

938 

939 event_info: EventInfo 

940 

941 @property 

942 def string_key_base(self) -> str: 

943 return "events.reminder" 

944 

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

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

947 

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

949 builder = self._body_builder(loc_context) 

950 builder.para(".purpose") 

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

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

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

954 return builder.build() 

955 

956 @classmethod 

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

958 return cls( 

959 user_name=user_name, 

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

961 ) 

962 

963 @classmethod 

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

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

966 

967 

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

969class EventCancelledEmail(EmailBase): 

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

971 

972 cancelling_user: UserInfo 

973 event_info: EventInfo 

974 

975 @property 

976 def string_key_base(self) -> str: 

977 return "events.cancel" 

978 

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

980 return self._localize( 

981 loc_context, 

982 ".subject", 

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

984 ) 

985 

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

987 builder = self._body_builder(loc_context) 

988 builder.para(".purpose", {"user": self.cancelling_user.name}) 

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

990 builder.user(self.cancelling_user) 

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

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

993 return builder.build() 

994 

995 @classmethod 

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

997 return cls( 

998 user_name=user_name, 

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

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

1001 ) 

1002 

1003 @classmethod 

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

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

1006 

1007 

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

1009class EventDeletedEmail(EmailBase): 

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

1011 

1012 event_info: EventInfo 

1013 

1014 @property 

1015 def string_key_base(self) -> str: 

1016 return "events.deleted" 

1017 

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

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

1020 

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

1022 builder = self._body_builder(loc_context) 

1023 builder.para(".purpose") 

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

1025 return builder.build() 

1026 

1027 @classmethod 

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

1029 return cls( 

1030 user_name=user_name, 

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

1032 ) 

1033 

1034 @classmethod 

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

1036 return [ 

1037 cls( 

1038 user_name="Alice", 

1039 event_info=EventInfo.dummy(), 

1040 ) 

1041 ] 

1042 

1043 

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

1045class FriendReferenceReceivedEmail(EmailBase): 

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

1047 

1048 from_user: UserInfo 

1049 text: str 

1050 

1051 @property 

1052 def string_key_base(self) -> str: 

1053 return "references.received.friend" 

1054 

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

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

1057 

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

1059 return self.text 

1060 

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

1062 builder = self._body_builder(loc_context) 

1063 builder.para(".purpose", {"name": self.from_user.name}) 

1064 builder.user(self.from_user) 

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

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

1067 return builder.build() 

1068 

1069 @classmethod 

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

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

1072 

1073 @classmethod 

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

1075 return [ 

1076 cls( 

1077 user_name="Alice", 

1078 from_user=UserInfo.dummy_bob(), 

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

1080 ) 

1081 ] 

1082 

1083 

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

1085class FriendRequestReceivedEmail(EmailBase): 

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

1087 

1088 befriender: UserInfo 

1089 

1090 @property 

1091 def string_key_base(self) -> str: 

1092 return "friend_requests.received" 

1093 

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

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

1096 

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

1098 builder = self._body_builder(loc_context) 

1099 builder.para(".purpose", {"name": self.befriender.name}) 

1100 builder.user(self.befriender) 

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

1102 builder.para(".closing") 

1103 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1104 return builder.build() 

1105 

1106 @classmethod 

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

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

1109 

1110 @classmethod 

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

1112 return [ 

1113 cls( 

1114 user_name="Alice", 

1115 befriender=UserInfo.dummy_bob(), 

1116 ) 

1117 ] 

1118 

1119 

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

1121class FriendRequestAcceptedEmail(EmailBase): 

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

1123 

1124 new_friend: UserInfo 

1125 

1126 @property 

1127 def string_key_base(self) -> str: 

1128 return "friend_requests.accepted" 

1129 

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

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

1132 

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

1134 builder = self._body_builder(loc_context) 

1135 builder.para(".purpose", {"name": self.new_friend.name}) 

1136 builder.user(self.new_friend) 

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

1138 builder.para(".closing") 

1139 return builder.build() 

1140 

1141 @classmethod 

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

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

1144 

1145 @classmethod 

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

1147 return [ 

1148 cls( 

1149 user_name="Alice", 

1150 new_friend=UserInfo.dummy_bob(), 

1151 ) 

1152 ] 

1153 

1154 

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

1156class GenderChangedEmail(EmailBase): 

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

1158 

1159 new_gender: str 

1160 

1161 @property 

1162 def string_key_base(self) -> str: 

1163 return "gender_changed" 

1164 

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

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

1167 builder.para(".purpose", {"gender": self.new_gender}) 

1168 return builder.build() 

1169 

1170 @classmethod 

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

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

1173 

1174 @classmethod 

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

1176 return [ 

1177 cls( 

1178 user_name="Alice", 

1179 new_gender="Male", 

1180 ) 

1181 ] 

1182 

1183 

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

1185class HostRequestCreatedEmail(EmailBase): 

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

1187 

1188 surfer: UserInfo 

1189 from_date: date 

1190 to_date: date 

1191 text: str 

1192 view_link: str 

1193 quick_decline_link: str 

1194 

1195 @property 

1196 def string_key_base(self) -> str: 

1197 return "host_requests.created" 

1198 

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

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

1201 

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

1203 return self.text 

1204 

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

1206 builder = self._body_builder(loc_context) 

1207 builder.para(".purpose", {"surfer_name": self.surfer.name}) 

1208 builder.user( 

1209 self.surfer, 

1210 "host_requests.generic.date_range", 

1211 { 

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

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

1214 }, 

1215 ) 

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

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

1218 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action") 

1219 builder.para(".respond_encouragement") 

1220 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1221 return builder.build() 

1222 

1223 @classmethod 

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

1225 return cls( 

1226 user_name, 

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

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

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

1230 text=data.text, 

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

1232 quick_decline_link=generate_quick_decline_link(data.host_request), 

1233 ) 

1234 

1235 @classmethod 

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

1237 return [ 

1238 cls( 

1239 user_name="Alice", 

1240 surfer=UserInfo.dummy_bob(), 

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

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

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

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

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

1246 ) 

1247 ] 

1248 

1249 

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

1251class HostRequestReminderEmail(EmailBase): 

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

1253 

1254 surfer: UserInfo 

1255 from_date: date 

1256 to_date: date 

1257 view_link: str 

1258 quick_decline_link: str 

1259 

1260 @property 

1261 def string_key_base(self) -> str: 

1262 return "host_requests.reminder" 

1263 

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

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

1266 

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

1268 builder = self._body_builder(loc_context) 

1269 builder.para(".purpose", {"surfer_name": self.surfer.name}) 

1270 builder.user( 

1271 self.surfer, 

1272 "host_requests.generic.date_range", 

1273 { 

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

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

1276 }, 

1277 ) 

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

1279 builder.action(self.quick_decline_link, "host_requests.generic.quick_decline_action") 

1280 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1281 return builder.build() 

1282 

1283 @classmethod 

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

1285 return cls( 

1286 user_name, 

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

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

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

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

1291 quick_decline_link=generate_quick_decline_link(data.host_request), 

1292 ) 

1293 

1294 @classmethod 

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

1296 return [ 

1297 cls( 

1298 user_name="Alice", 

1299 surfer=UserInfo.dummy_bob(), 

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

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

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

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

1304 ) 

1305 ] 

1306 

1307 

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

1309class HostRequestMessageEmail(EmailBase): 

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

1311 

1312 other_user: UserInfo 

1313 from_date: date 

1314 to_date: date 

1315 text: str 

1316 from_host: bool 

1317 view_link: str 

1318 

1319 @property 

1320 def string_key_base(self) -> str: 

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

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

1323 

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

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

1326 

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

1328 return self.text 

1329 

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

1331 builder = self._body_builder(loc_context) 

1332 builder.para(".purpose", {"other_name": self.other_user.name}) 

1333 builder.user( 

1334 self.other_user, 

1335 "host_requests.generic.date_range", 

1336 { 

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

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

1339 }, 

1340 ) 

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

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

1343 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1344 return builder.build() 

1345 

1346 @classmethod 

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

1348 return cls( 

1349 user_name, 

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

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

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

1353 text=data.text, 

1354 from_host=not data.am_host, 

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

1356 ) 

1357 

1358 @classmethod 

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

1360 prototype = cls( 

1361 user_name="Alice", 

1362 other_user=UserInfo.dummy_bob(), 

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

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

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

1366 from_host=True, 

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

1368 ) 

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

1370 

1371 

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

1373class HostRequestMissedMessagesEmail(EmailBase): 

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

1375 

1376 other_user: UserInfo 

1377 from_date: date 

1378 to_date: date 

1379 from_host: bool 

1380 view_link: str 

1381 

1382 @property 

1383 def string_key_base(self) -> str: 

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

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

1386 

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

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

1389 

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

1391 builder = self._body_builder(loc_context) 

1392 builder.para(".purpose", {"other_name": self.other_user.name}) 

1393 builder.user( 

1394 self.other_user, 

1395 "host_requests.generic.date_range", 

1396 { 

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

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

1399 }, 

1400 ) 

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

1402 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1403 return builder.build() 

1404 

1405 @classmethod 

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

1407 return cls( 

1408 user_name, 

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

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

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

1412 from_host=not data.am_host, 

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

1414 ) 

1415 

1416 @classmethod 

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

1418 prototype = cls( 

1419 user_name="Alice", 

1420 other_user=UserInfo.dummy_bob(), 

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

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

1423 from_host=True, 

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

1425 ) 

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

1427 

1428 

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

1430class HostRequestStatusChangedEmail(EmailBase): 

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

1432 

1433 other_user: UserInfo 

1434 from_date: date 

1435 to_date: date 

1436 new_status: messages_pb2.HostRequestStatus.ValueType 

1437 view_link: str 

1438 

1439 @property 

1440 def string_key_base(self) -> str: 

1441 base_key = "host_requests.status_changed" 

1442 match self.new_status: 

1443 case messages_pb2.HOST_REQUEST_STATUS_ACCEPTED: 

1444 return f"{base_key}.accepted_by_host" 

1445 case messages_pb2.HOST_REQUEST_STATUS_REJECTED: 

1446 return f"{base_key}.declined_by_host" 

1447 case messages_pb2.HOST_REQUEST_STATUS_CONFIRMED: 

1448 return f"{base_key}.confirmed_by_surfer" 

1449 case messages_pb2.HOST_REQUEST_STATUS_CANCELLED: 1449 ↛ 1451line 1449 didn't jump to line 1451 because the pattern on line 1449 always matched

1450 return f"{base_key}.cancelled_by_surfer" 

1451 case _: 

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

1453 

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

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

1456 

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

1458 builder = self._body_builder(loc_context) 

1459 builder.para(".purpose", {"other_name": self.other_user.name}) 

1460 builder.user( 

1461 self.other_user, 

1462 "host_requests.generic.date_range", 

1463 { 

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

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

1466 }, 

1467 ) 

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

1469 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1470 return builder.build() 

1471 

1472 @classmethod 

1473 def from_notification( 

1474 cls, 

1475 data: notification_data_pb2.HostRequestAccept 

1476 | notification_data_pb2.HostRequestReject 

1477 | notification_data_pb2.HostRequestConfirm 

1478 | notification_data_pb2.HostRequestCancel, 

1479 *, 

1480 user_name: str, 

1481 ) -> Self: 

1482 other_user: UserInfo 

1483 new_status: messages_pb2.HostRequestStatus.ValueType 

1484 match data: 

1485 case notification_data_pb2.HostRequestAccept(): 

1486 other_user = UserInfo.from_protobuf(data.host) 

1487 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED 

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

1489 other_user = UserInfo.from_protobuf(data.host) 

1490 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED 

1491 case notification_data_pb2.HostRequestConfirm(): 

1492 other_user = UserInfo.from_protobuf(data.surfer) 

1493 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED 

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

1495 other_user = UserInfo.from_protobuf(data.surfer) 

1496 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED 

1497 case _: 

1498 # Enable mypy's exhaustiveness checking 

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

1500 

1501 return cls( 

1502 user_name, 

1503 other_user=other_user, 

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

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

1506 new_status=new_status, 

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

1508 ) 

1509 

1510 @classmethod 

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

1512 prototype = cls( 

1513 user_name="Alice", 

1514 other_user=UserInfo.dummy_bob(), 

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

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

1517 new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

1519 ) 

1520 return [ 

1521 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED), 

1522 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_REJECTED), 

1523 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED), 

1524 replace(prototype, new_status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED), 

1525 ] 

1526 

1527 

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

1529class HostReferenceReceivedEmail(EmailBase): 

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

1531 

1532 from_user: UserInfo 

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

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

1535 leave_reference_url: str 

1536 

1537 @property 

1538 def string_key_base(self) -> str: 

1539 return "references.received" 

1540 

1541 @property 

1542 def string_role_subkey(self) -> str: 

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

1544 

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

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

1547 

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

1549 return self.text 

1550 

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

1552 builder = self._body_builder(loc_context) 

1553 builder.para(f".{self.string_role_subkey}.purpose", {"name": self.from_user.name}) 

1554 builder.user(self.from_user) 

1555 if self.text: 

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

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

1558 else: 

1559 builder.para(f".{self.string_role_subkey}.reciprocate_encouragement", {"name": self.from_user.name}) 

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

1561 return builder.build() 

1562 

1563 @classmethod 

1564 def from_notification( 

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

1566 ) -> Self: 

1567 return cls( 

1568 user_name=user_name, 

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

1570 text=data.text or None, 

1571 surfed=surfed, 

1572 leave_reference_url=urls.leave_reference_link( 

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

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

1575 host_request_id=str(data.host_request_id), 

1576 ), 

1577 ) 

1578 

1579 @classmethod 

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

1581 prototype = cls( 

1582 user_name="Alice", 

1583 from_user=UserInfo.dummy_bob(), 

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

1585 surfed=True, 

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

1587 ) 

1588 return [ 

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

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

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

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

1593 ] 

1594 

1595 

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

1597class HostReferenceReminderEmail(EmailBase): 

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

1599 

1600 other_user: UserInfo 

1601 days_left: int 

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

1603 leave_reference_url: str 

1604 

1605 @property 

1606 def string_key_base(self) -> str: 

1607 return "references.reminder" 

1608 

1609 @property 

1610 def string_role_subkey(self) -> str: 

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

1612 

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

1614 return self._localize( 

1615 loc_context, 

1616 ".subject_days", 

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

1618 ) 

1619 

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

1621 builder = self._body_builder(loc_context) 

1622 builder.para( 

1623 f".{self.string_role_subkey}.purpose_days", {"name": self.other_user.name, "count": self.days_left} 

1624 ) 

1625 builder.user(self.other_user) 

1626 builder.action( 

1627 self.leave_reference_url, 

1628 "references.write_action", 

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

1630 ) 

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

1632 builder.para(".visibility_note") 

1633 return builder.build() 

1634 

1635 @classmethod 

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

1637 return cls( 

1638 user_name=user_name, 

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

1640 days_left=data.days_left, 

1641 surfed=surfed, 

1642 leave_reference_url=urls.leave_reference_link( 

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

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

1645 host_request_id=str(data.host_request_id), 

1646 ), 

1647 ) 

1648 

1649 @classmethod 

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

1651 prototype = cls( 

1652 user_name="Alice", 

1653 other_user=UserInfo.dummy_bob(), 

1654 days_left=7, 

1655 surfed=True, 

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

1657 ) 

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

1659 

1660 

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

1662class ModeratorNoteEmail(EmailBase): 

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

1664 

1665 @property 

1666 def string_key_base(self) -> str: 

1667 return "moderator_note" 

1668 

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

1670 builder = self._body_builder(loc_context) 

1671 builder.para(".purpose") 

1672 # Users with moderator notes are "jailed": any URL will show the note before 

1673 # letting them use the platform. 

1674 builder.action(urls.dashboard_link(), ".view_action") 

1675 return builder.build() 

1676 

1677 @classmethod 

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

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

1680 

1681 

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

1683class NewBlogPostEmail(EmailBase): 

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

1685 

1686 title: str 

1687 blurb: str 

1688 url: str 

1689 

1690 @property 

1691 def string_key_base(self) -> str: 

1692 return "new_blog_post" 

1693 

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

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

1696 

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

1698 return self.blurb 

1699 

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

1701 builder = self._body_builder(loc_context) 

1702 builder.para(".purpose") 

1703 builder.block(ParaBlock(text=Markup(f"<b>{escape(self.title)}</b>"))) 

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

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

1706 return builder.build() 

1707 

1708 @classmethod 

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

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

1711 

1712 @classmethod 

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

1714 return [ 

1715 cls( 

1716 user_name="Alice", 

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

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

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

1720 ) 

1721 ] 

1722 

1723 

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

1725class OnboardingReminderEmail(EmailBase): 

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

1727 

1728 initial: bool 

1729 

1730 @property 

1731 def string_key_base(self) -> str: 

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

1733 

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

1735 builder = self._body_builder(loc_context, default_closing=False) 

1736 if self.initial: 

1737 self._build_body_initial(builder) 

1738 else: 

1739 self._build_body_followup(builder) 

1740 return builder.build() 

1741 

1742 def _build_body_initial(self, builder: EmailBlocksBuilder) -> None: 

1743 builder.para(".welcome") 

1744 builder.para(".early_user_role") 

1745 builder.para(".complete_profile_request") 

1746 builder.para(".profile_importance") 

1747 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action") 

1748 builder.para(".share_request") 

1749 builder.para( 

1750 "generic.closing_lines.aapeli", 

1751 { 

1752 "profile_link": html_link("https://couchers.org/user/aapeli"), 

1753 }, 

1754 ) 

1755 

1756 def _build_body_followup(self, builder: EmailBlocksBuilder) -> None: 

1757 builder.para(".intro") 

1758 builder.para(".request") 

1759 builder.action(urls.edit_profile_link(), "onboarding_reminder.complete_profile_action") 

1760 builder.para("generic.thanks") 

1761 builder.para( 

1762 "generic.closing_lines.emily", 

1763 { 

1764 "email_link": html_mailto_link("community@couchers.org"), 

1765 "profile_link": html_link("https://couchers.org/user/emily"), 

1766 }, 

1767 ) 

1768 

1769 @classmethod 

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

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

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

1773 

1774 

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

1776class PasswordChangedEmail(EmailBase): 

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

1778 

1779 @property 

1780 def string_key_base(self) -> str: 

1781 return "password_changed" 

1782 

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

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

1785 builder.para(".purpose") 

1786 return builder.build() 

1787 

1788 @classmethod 

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

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

1791 

1792 

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

1794class PasswordResetCompletedEmail(EmailBase): 

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

1796 

1797 @property 

1798 def string_key_base(self) -> str: 

1799 return "password_reset.completed" 

1800 

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

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

1803 builder.para(".purpose") 

1804 return builder.build() 

1805 

1806 @classmethod 

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

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

1809 

1810 

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

1812class PasswordResetStartedEmail(EmailBase): 

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

1814 

1815 password_reset_link: str 

1816 

1817 @property 

1818 def string_key_base(self) -> str: 

1819 return "password_reset.started" 

1820 

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

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

1823 builder.para(".purpose") 

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

1825 return builder.build() 

1826 

1827 @classmethod 

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

1829 return cls( 

1830 user_name=user_name, 

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

1832 ) 

1833 

1834 @classmethod 

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

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

1837 

1838 

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

1840class PhoneNumberChangeEmail(EmailBase): 

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

1842 

1843 new_phone_number: str 

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

1845 

1846 @property 

1847 def string_key_base(self) -> str: 

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

1849 

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

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

1852 builder.para(".purpose", {"phone_number": format_phone_number(self.new_phone_number)}) 

1853 return builder.build() 

1854 

1855 @classmethod 

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

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

1858 

1859 @classmethod 

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

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

1862 

1863 @classmethod 

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

1865 prototype = cls( 

1866 user_name="Alice", 

1867 new_phone_number="+12223334444", 

1868 completed=False, 

1869 ) 

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

1871 

1872 

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

1874class PostalVerificationFailedEmail(EmailBase): 

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

1876 

1877 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1878 

1879 @property 

1880 def string_key_base(self) -> str: 

1881 return "postal_verification.failed" 

1882 

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

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

1885 match self.reason: 

1886 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1887 purpose_string_key = ".purpose.code_expired" 

1888 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1889 purpose_string_key = ".purpose.too_many_attempts" 

1890 case _: 

1891 purpose_string_key = ".purpose.default" 

1892 builder.para(purpose_string_key) 

1893 builder.action(urls.account_settings_link(), ".restart_action") 

1894 return builder.build() 

1895 

1896 @classmethod 

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

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

1899 

1900 @classmethod 

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

1902 prototype = cls( 

1903 user_name="Alice", 

1904 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1905 ) 

1906 return [ 

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

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

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

1910 ] 

1911 

1912 

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

1914class PostalVerificationPostcardSentEmail(EmailBase): 

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

1916 

1917 city: str 

1918 country: str 

1919 

1920 @property 

1921 def string_key_base(self) -> str: 

1922 return "postal_verification.postcard_sent" 

1923 

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

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

1926 builder.para(".purpose", {"city": self.city, "country": self.country}) 

1927 builder.action(urls.dashboard_link(), ".enter_code_action") 

1928 return builder.build() 

1929 

1930 @classmethod 

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

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

1933 

1934 @classmethod 

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

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

1937 

1938 

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

1940class PostalVerificationSucceededEmail(EmailBase): 

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

1942 

1943 @property 

1944 def string_key_base(self) -> str: 

1945 return "postal_verification.succeeded" 

1946 

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

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

1949 builder.para(".purpose") 

1950 return builder.build() 

1951 

1952 @classmethod 

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

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

1955 

1956 

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

1958class SignupVerifyEmail(EmailBase): 

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

1960 

1961 verify_url: str 

1962 

1963 @property 

1964 def string_key_base(self) -> str: 

1965 return "signup.verify" 

1966 

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

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

1969 

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

1971 builder = self._body_builder(loc_context) 

1972 builder.para(".thanks") 

1973 builder.para(".instructions") 

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

1975 builder.para("signup.closing") 

1976 return builder.build() 

1977 

1978 @classmethod 

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

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

1981 

1982 

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

1984class SignupContinueEmail(EmailBase): 

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

1986 

1987 continue_url: str 

1988 

1989 @property 

1990 def string_key_base(self) -> str: 

1991 return "signup.continue" 

1992 

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

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

1995 

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

1997 builder = self._body_builder(loc_context) 

1998 builder.para(".purpose") 

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

2000 builder.para("signup.closing") 

2001 builder.para(".ignore_if_unexpected") 

2002 return builder.build() 

2003 

2004 @classmethod 

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

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

2007 

2008 

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

2010class StrongVerificationFailedEmail(EmailBase): 

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

2012 

2013 reason: notification_data_pb2.SVFailReason.ValueType 

2014 

2015 @property 

2016 def string_key_base(self) -> str: 

2017 return "strong_verification.failed" 

2018 

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

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

2021 match self.reason: 

2022 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

2023 purpose_string_key = ".purpose.wrong_birthdate_or_gender" 

2024 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

2025 purpose_string_key = ".purpose.not_a_passport" 

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

2027 purpose_string_key = ".purpose.duplicate" 

2028 case _: 

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

2030 builder.para(purpose_string_key) 

2031 builder.action(urls.strong_verification_url(), ".restart_action") 

2032 return builder.build() 

2033 

2034 @classmethod 

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

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

2037 

2038 @classmethod 

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

2040 prototype = cls( 

2041 user_name="Alice", 

2042 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

2043 ) 

2044 return [ 

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

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

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

2048 ] 

2049 

2050 

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

2052class StrongVerificationSucceededEmail(EmailBase): 

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

2054 

2055 @property 

2056 def string_key_base(self) -> str: 

2057 return "strong_verification.succeeded" 

2058 

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

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

2061 builder.para(".purpose") 

2062 builder.para(".thanks_message") 

2063 builder.para(".cost_explanation") 

2064 builder.para(".donation_request") 

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

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

2067 return builder.build() 

2068 

2069 @classmethod 

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

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

2072 

2073 

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

2075class ThreadReplyEmail(EmailBase): 

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

2077 

2078 author: UserInfo 

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

2080 markdown_text: str 

2081 view_link: str 

2082 

2083 @property 

2084 def string_key_base(self) -> str: 

2085 return "thread_reply" 

2086 

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

2088 return self._localize( 

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

2090 ) 

2091 

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

2093 return markdown_to_plaintext(self.markdown_text) 

2094 

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

2096 builder = self._body_builder(loc_context) 

2097 builder.para(".purpose", {"author": self.author.name, "parent_context": self.parent_context}) 

2098 builder.user(self.author) 

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

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

2101 return builder.build() 

2102 

2103 @classmethod 

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

2105 parent = data.WhichOneof("reply_parent") 

2106 if parent == "event": 

2107 parent_context = data.event.title 

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

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

2110 parent_context = data.discussion.title 

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

2112 else: 

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

2114 return cls( 

2115 user_name=user_name, 

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

2117 parent_context=parent_context, 

2118 markdown_text=data.reply.content, 

2119 view_link=view_link, 

2120 ) 

2121 

2122 @classmethod 

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

2124 return [ 

2125 cls( 

2126 user_name="Alice", 

2127 author=UserInfo.dummy_bob(), 

2128 parent_context="Best hiking trails near Berlin", 

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

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

2131 ) 

2132 ] 

2133 

2134 

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

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