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

1072 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-13 21:43 +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 # The note's text, only set if the moderator chose to include it in the email. 

1666 markdown_text: str | None 

1667 

1668 @property 

1669 def string_key_base(self) -> str: 

1670 return "moderator_note" 

1671 

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

1673 return markdown_to_plaintext(self.markdown_text) if self.markdown_text else None 

1674 

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

1676 builder = self._body_builder(loc_context) 

1677 if self.markdown_text: 

1678 builder.para(".purpose_with_note") 

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

1680 builder.para(".acknowledge_request") 

1681 builder.para(".reply_instructions") 

1682 else: 

1683 builder.para(".purpose") 

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

1685 # letting them use the platform. 

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

1687 return builder.build() 

1688 

1689 @classmethod 

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

1691 return cls(user_name=user_name, markdown_text=data.markdown_text or None) 

1692 

1693 @classmethod 

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

1695 return [ 

1696 cls(user_name="Alice", markdown_text=None), 

1697 cls( 

1698 user_name="Alice", 

1699 markdown_text="Please **add** a profile photo so that your hosts can recognise you.", 

1700 ), 

1701 ] 

1702 

1703 

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

1705class NewBlogPostEmail(EmailBase): 

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

1707 

1708 title: str 

1709 blurb: str 

1710 url: str 

1711 

1712 @property 

1713 def string_key_base(self) -> str: 

1714 return "new_blog_post" 

1715 

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

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

1718 

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

1720 return self.blurb 

1721 

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

1723 builder = self._body_builder(loc_context) 

1724 builder.para(".purpose") 

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

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

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

1728 return builder.build() 

1729 

1730 @classmethod 

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

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

1733 

1734 @classmethod 

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

1736 return [ 

1737 cls( 

1738 user_name="Alice", 

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

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

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

1742 ) 

1743 ] 

1744 

1745 

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

1747class OnboardingReminderEmail(EmailBase): 

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

1749 

1750 initial: bool 

1751 

1752 @property 

1753 def string_key_base(self) -> str: 

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

1755 

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

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

1758 if self.initial: 

1759 self._build_body_initial(builder) 

1760 else: 

1761 self._build_body_followup(builder) 

1762 return builder.build() 

1763 

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

1765 builder.para(".welcome") 

1766 builder.para(".early_user_role") 

1767 builder.para(".complete_profile_request") 

1768 builder.para(".profile_importance") 

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

1770 builder.para(".share_request") 

1771 builder.para( 

1772 "generic.closing_lines.aapeli", 

1773 { 

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

1775 }, 

1776 ) 

1777 

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

1779 builder.para(".intro") 

1780 builder.para(".request") 

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

1782 builder.para("generic.thanks") 

1783 builder.para( 

1784 "generic.closing_lines.emily", 

1785 { 

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

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

1788 }, 

1789 ) 

1790 

1791 @classmethod 

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

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

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

1795 

1796 

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

1798class PasswordChangedEmail(EmailBase): 

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

1800 

1801 @property 

1802 def string_key_base(self) -> str: 

1803 return "password_changed" 

1804 

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

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

1807 builder.para(".purpose") 

1808 return builder.build() 

1809 

1810 @classmethod 

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

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

1813 

1814 

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

1816class PasswordResetCompletedEmail(EmailBase): 

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

1818 

1819 @property 

1820 def string_key_base(self) -> str: 

1821 return "password_reset.completed" 

1822 

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

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

1825 builder.para(".purpose") 

1826 return builder.build() 

1827 

1828 @classmethod 

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

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

1831 

1832 

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

1834class PasswordResetStartedEmail(EmailBase): 

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

1836 

1837 password_reset_link: str 

1838 

1839 @property 

1840 def string_key_base(self) -> str: 

1841 return "password_reset.started" 

1842 

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

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

1845 builder.para(".purpose") 

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

1847 return builder.build() 

1848 

1849 @classmethod 

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

1851 return cls( 

1852 user_name=user_name, 

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

1854 ) 

1855 

1856 @classmethod 

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

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

1859 

1860 

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

1862class PhoneNumberChangeEmail(EmailBase): 

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

1864 

1865 new_phone_number: str 

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

1867 

1868 @property 

1869 def string_key_base(self) -> str: 

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

1871 

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

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

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

1875 return builder.build() 

1876 

1877 @classmethod 

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

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

1880 

1881 @classmethod 

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

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

1884 

1885 @classmethod 

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

1887 prototype = cls( 

1888 user_name="Alice", 

1889 new_phone_number="+12223334444", 

1890 completed=False, 

1891 ) 

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

1893 

1894 

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

1896class PostalVerificationFailedEmail(EmailBase): 

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

1898 

1899 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1900 

1901 @property 

1902 def string_key_base(self) -> str: 

1903 return "postal_verification.failed" 

1904 

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

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

1907 match self.reason: 

1908 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1909 purpose_string_key = ".purpose.code_expired" 

1910 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1911 purpose_string_key = ".purpose.too_many_attempts" 

1912 case _: 

1913 purpose_string_key = ".purpose.default" 

1914 builder.para(purpose_string_key) 

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

1916 return builder.build() 

1917 

1918 @classmethod 

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

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

1921 

1922 @classmethod 

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

1924 prototype = cls( 

1925 user_name="Alice", 

1926 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1927 ) 

1928 return [ 

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

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

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

1932 ] 

1933 

1934 

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

1936class PostalVerificationPostcardSentEmail(EmailBase): 

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

1938 

1939 city: str 

1940 country: str 

1941 

1942 @property 

1943 def string_key_base(self) -> str: 

1944 return "postal_verification.postcard_sent" 

1945 

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

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

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

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

1950 return builder.build() 

1951 

1952 @classmethod 

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

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

1955 

1956 @classmethod 

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

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

1959 

1960 

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

1962class PostalVerificationSucceededEmail(EmailBase): 

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

1964 

1965 @property 

1966 def string_key_base(self) -> str: 

1967 return "postal_verification.succeeded" 

1968 

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

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

1971 builder.para(".purpose") 

1972 return builder.build() 

1973 

1974 @classmethod 

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

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

1977 

1978 

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

1980class SignupVerifyEmail(EmailBase): 

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

1982 

1983 verify_url: str 

1984 

1985 @property 

1986 def string_key_base(self) -> str: 

1987 return "signup.verify" 

1988 

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

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

1991 

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

1993 builder = self._body_builder(loc_context) 

1994 builder.para(".thanks") 

1995 builder.para(".instructions") 

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

1997 builder.para("signup.closing") 

1998 return builder.build() 

1999 

2000 @classmethod 

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

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

2003 

2004 

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

2006class SignupContinueEmail(EmailBase): 

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

2008 

2009 continue_url: str 

2010 

2011 @property 

2012 def string_key_base(self) -> str: 

2013 return "signup.continue" 

2014 

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

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

2017 

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

2019 builder = self._body_builder(loc_context) 

2020 builder.para(".purpose") 

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

2022 builder.para("signup.closing") 

2023 builder.para(".ignore_if_unexpected") 

2024 return builder.build() 

2025 

2026 @classmethod 

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

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

2029 

2030 

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

2032class StrongVerificationFailedEmail(EmailBase): 

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

2034 

2035 reason: notification_data_pb2.SVFailReason.ValueType 

2036 

2037 @property 

2038 def string_key_base(self) -> str: 

2039 return "strong_verification.failed" 

2040 

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

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

2043 match self.reason: 

2044 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

2045 purpose_string_key = ".purpose.wrong_birthdate_or_gender" 

2046 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

2047 purpose_string_key = ".purpose.not_a_passport" 

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

2049 purpose_string_key = ".purpose.duplicate" 

2050 case _: 

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

2052 builder.para(purpose_string_key) 

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

2054 return builder.build() 

2055 

2056 @classmethod 

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

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

2059 

2060 @classmethod 

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

2062 prototype = cls( 

2063 user_name="Alice", 

2064 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

2065 ) 

2066 return [ 

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

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

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

2070 ] 

2071 

2072 

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

2074class StrongVerificationSucceededEmail(EmailBase): 

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

2076 

2077 @property 

2078 def string_key_base(self) -> str: 

2079 return "strong_verification.succeeded" 

2080 

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

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

2083 builder.para(".purpose") 

2084 builder.para(".thanks_message") 

2085 builder.para(".cost_explanation") 

2086 builder.para(".donation_request") 

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

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

2089 return builder.build() 

2090 

2091 @classmethod 

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

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

2094 

2095 

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

2097class ThreadReplyEmail(EmailBase): 

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

2099 

2100 author: UserInfo 

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

2102 markdown_text: str 

2103 view_link: str 

2104 

2105 @property 

2106 def string_key_base(self) -> str: 

2107 return "thread_reply" 

2108 

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

2110 return self._localize( 

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

2112 ) 

2113 

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

2115 return markdown_to_plaintext(self.markdown_text) 

2116 

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

2118 builder = self._body_builder(loc_context) 

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

2120 builder.user(self.author) 

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

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

2123 return builder.build() 

2124 

2125 @classmethod 

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

2127 parent = data.WhichOneof("reply_parent") 

2128 if parent == "event": 

2129 parent_context = data.event.title 

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

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

2132 parent_context = data.discussion.title 

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

2134 else: 

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

2136 return cls( 

2137 user_name=user_name, 

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

2139 parent_context=parent_context, 

2140 markdown_text=data.reply.content, 

2141 view_link=view_link, 

2142 ) 

2143 

2144 @classmethod 

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

2146 return [ 

2147 cls( 

2148 user_name="Alice", 

2149 author=UserInfo.dummy_bob(), 

2150 parent_context="Best hiking trails near Berlin", 

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

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

2153 ) 

2154 ] 

2155 

2156 

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

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