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

1061 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 22:46 +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 

33from typing import Self, assert_never 

34 

35from markupsafe import Markup, escape 

36 

37from couchers import urls 

38from couchers.config import config 

39from couchers.constants import LATEST_RELEASE_BLOG_URL 

40from couchers.email.blocks import ( 

41 ActionBlock, 

42 EmailBase, 

43 EmailBlock, 

44 EmailBlocksBuilder, 

45 ParaBlock, 

46 QuoteBlock, 

47 UserInfo, 

48) 

49from couchers.email.locales import get_emails_i18next 

50from couchers.i18n import LocalizationContext 

51from couchers.i18n.localize import format_phone_number 

52from couchers.markup import html_link, html_mailto_link, markdown_to_plaintext 

53from couchers.notifications.quick_links import generate_quick_decline_link 

54from couchers.proto import events_pb2, messages_pb2, notification_data_pb2 

55from couchers.utils import now, to_aware_datetime 

56 

57# Common string keys 

58_do_not_reply_request_string_key = "generic.do_not_reply_request" 

59 

60# Specific email definitions 

61 

62 

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

64class AccountDeletionStartedEmail(EmailBase): 

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

66 

67 deletion_link: str 

68 

69 @property 

70 def string_key_base(self) -> str: 

71 return "account_deletion.started" 

72 

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

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

75 builder.para(".purpose") 

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

77 return builder.build() 

78 

79 @classmethod 

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

81 return cls( 

82 user_name=user_name, 

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

84 ) 

85 

86 @classmethod 

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

88 return [ 

89 cls( 

90 user_name="Alice", 

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

92 ) 

93 ] 

94 

95 

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

97class AccountDeletionCompletedEmail(EmailBase): 

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

99 

100 undelete_link: str 

101 days: int 

102 

103 @property 

104 def string_key_base(self) -> str: 

105 return "account_deletion.completed" 

106 

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

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

109 builder.para(".purpose") 

110 builder.para(".farewell") 

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

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

113 return builder.build() 

114 

115 @classmethod 

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

117 return cls( 

118 user_name=user_name, 

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

120 days=data.undelete_days, 

121 ) 

122 

123 @classmethod 

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

125 return [ 

126 cls( 

127 user_name="Alice", 

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

129 days=30, 

130 ) 

131 ] 

132 

133 

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

135class AccountDeletionRecoveredEmail(EmailBase): 

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

137 

138 @property 

139 def string_key_base(self) -> str: 

140 return "account_deletion.recovered" 

141 

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

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

144 builder.para(".confirmation") 

145 builder.para(".login_instructions") 

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

147 builder.para(".redelete_instructions") 

148 return builder.build() 

149 

150 @classmethod 

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

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

153 

154 

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

156class ActivenessProbeEmail(EmailBase): 

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

158 

159 days_left: int 

160 

161 @property 

162 def string_key_base(self) -> str: 

163 return "activeness_probe" 

164 

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

166 builder = self._body_builder(loc_context) 

167 builder.para(".purpose") 

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

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

170 builder.para(".encouragement") 

171 

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

173 version = config.VERSION 

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

175 version = version_match[1] 

176 

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

178 return builder.build() 

179 

180 @classmethod 

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

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

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

184 

185 @classmethod 

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

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

188 

189 

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

191class APIKeyIssuedEmail(EmailBase): 

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

193 

194 api_key: str 

195 expiry: datetime 

196 

197 @property 

198 def string_key_base(self) -> str: 

199 return "api_key_issued" 

200 

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

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

203 builder.para(".header") 

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

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

206 builder.para(".usage_warning") 

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

208 return builder.build() 

209 

210 @classmethod 

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

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

213 

214 @classmethod 

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

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

217 

218 

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

220class BadgeChangedEmail(EmailBase): 

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

222 

223 badge_name: str 

224 added: bool 

225 

226 @property 

227 def string_key_base(self) -> str: 

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

229 

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

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

232 

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

234 builder = self._body_builder(loc_context) 

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

236 return builder.build() 

237 

238 @classmethod 

239 def from_notification( 

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

241 ) -> Self: 

242 return cls( 

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

244 ) 

245 

246 @classmethod 

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

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

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

250 

251 

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

253class BirthdateChangedEmail(EmailBase): 

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

255 

256 new_birthdate: date 

257 

258 @property 

259 def string_key_base(self) -> str: 

260 return "birthdate_changed" 

261 

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

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

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

265 return builder.build() 

266 

267 @classmethod 

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

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

270 

271 @classmethod 

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

273 return [ 

274 cls( 

275 user_name="Alice", 

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

277 ) 

278 ] 

279 

280 

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

282class ChatMessageReceivedEmail(EmailBase): 

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

284 

285 group_chat_title: str | None # None if direct message 

286 author: UserInfo 

287 text: str 

288 view_url: str 

289 

290 @property 

291 def string_key_base(self) -> str: 

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

293 

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

295 return self._localize( 

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

297 ) 

298 

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

300 return self.text 

301 

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

303 builder = self._body_builder(loc_context) 

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

305 builder.user(self.author) 

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

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

308 return builder.build() 

309 

310 @classmethod 

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

312 return cls( 

313 user_name, 

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

315 text=data.text, 

316 group_chat_title=data.group_chat_title or None, 

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

318 ) 

319 

320 @classmethod 

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

322 prototype = cls( 

323 user_name="Alice", 

324 group_chat_title=None, 

325 author=UserInfo.dummy_bob(), 

326 text="Hi Alice!", 

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

328 ) 

329 return [ 

330 replace(prototype, group_chat_title=None), 

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

332 ] 

333 

334 

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

336class ChatMessagesMissedEmail(EmailBase): 

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

338 

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

340 class Entry: 

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

342 

343 group_chat_title: str | None # None if direct message 

344 missed_count: int 

345 latest_message_author: UserInfo 

346 latest_message_text: str 

347 view_url: str 

348 

349 entries: list[Entry] 

350 

351 @property 

352 def string_key_base(self) -> str: 

353 return "chat_messages.missed" 

354 

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

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

357 

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

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

360 return None 

361 return self.entries[0].latest_message_text 

362 

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

364 builder = self._body_builder(loc_context) 

365 builder.para(".purpose") 

366 for entry in self.entries: 

367 if entry.group_chat_title is None: 

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

369 else: 

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

371 builder.user(entry.latest_message_author) 

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

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

374 return builder.build() 

375 

376 @classmethod 

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

378 missed_entries = [ 

379 cls.Entry( 

380 group_chat_title=message.group_chat_title or None, 

381 missed_count=message.unseen_count, 

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

383 latest_message_text=message.text, 

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

385 ) 

386 for message in data.messages 

387 ] 

388 

389 return cls(user_name, entries=missed_entries) 

390 

391 @classmethod 

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

393 entry_prototype = ChatMessagesMissedEmail.Entry( 

394 group_chat_title=None, 

395 missed_count=1, 

396 latest_message_author=UserInfo.dummy_bob(), 

397 latest_message_text="Hello!", 

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

399 ) 

400 return [ 

401 cls( 

402 user_name="Alice", 

403 entries=[ 

404 replace(entry_prototype, group_chat_title=None), 

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

406 ], 

407 ) 

408 ] 

409 

410 

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

412class DiscussionCreatedEmail(EmailBase): 

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

414 

415 author: UserInfo 

416 title: str 

417 parent_context: str # Community or group name 

418 markdown_text: str 

419 view_link: str 

420 

421 @property 

422 def string_key_base(self) -> str: 

423 return "discussions.created" 

424 

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

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

427 

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

429 return markdown_to_plaintext(self.markdown_text) 

430 

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

432 builder = self._body_builder(loc_context) 

433 builder.para( 

434 ".purpose", 

435 { 

436 "author": self.author.name, 

437 "parent_context": self.parent_context, 

438 }, 

439 ) 

440 builder.user(self.author) 

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

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

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

444 return builder.build() 

445 

446 @classmethod 

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

448 discussion = data.discussion 

449 return cls( 

450 user_name=user_name, 

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

452 title=discussion.title, 

453 parent_context=discussion.owner_title, 

454 markdown_text=discussion.content, 

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

456 ) 

457 

458 @classmethod 

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

460 return [ 

461 cls( 

462 user_name="Alice", 

463 author=UserInfo.dummy_bob(), 

464 title="Best hiking trails near Berlin", 

465 parent_context="Berlin", 

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

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

468 ) 

469 ] 

470 

471 

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

473class DiscussionCommentEmail(EmailBase): 

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

475 

476 author: UserInfo 

477 discussion_title: str 

478 discussion_parent_context: str # Community or group name 

479 markdown_text: str 

480 view_link: str 

481 

482 @property 

483 def string_key_base(self) -> str: 

484 return "discussions.comment" 

485 

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

487 return self._localize( 

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

489 ) 

490 

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

492 return markdown_to_plaintext(self.markdown_text) 

493 

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

495 builder = self._body_builder(loc_context) 

496 builder.para( 

497 ".purpose", 

498 { 

499 "author": self.author.name, 

500 "discussion_title": self.discussion_title, 

501 "parent_context": self.discussion_parent_context, 

502 }, 

503 ) 

504 builder.user(self.author) 

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

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

507 return builder.build() 

508 

509 @classmethod 

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

511 discussion = data.discussion 

512 return cls( 

513 user_name=user_name, 

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

515 discussion_title=discussion.title, 

516 discussion_parent_context=discussion.owner_title, 

517 markdown_text=data.reply.content, 

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

519 ) 

520 

521 @classmethod 

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

523 return [ 

524 cls( 

525 user_name="Alice", 

526 author=UserInfo.dummy_bob(), 

527 discussion_title="Best hiking trails near Berlin", 

528 discussion_parent_context="Berlin", 

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

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

531 ) 

532 ] 

533 

534 

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

536class DonationReceivedEmail(EmailBase): 

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

538 

539 amount: int 

540 receipt_url: str 

541 

542 @property 

543 def string_key_base(self) -> str: 

544 return "donation_received" 

545 

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

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

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

549 builder.para(".contribution_impact") 

550 builder.para(".invoice_receipt_info") 

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

552 builder.para(".tax_acknowledgment") 

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

554 builder.para("generic.thanks") 

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

556 return builder.build() 

557 

558 @classmethod 

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

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

561 

562 @classmethod 

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

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

565 

566 

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

568class EmailChangedEmail(EmailBase): 

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

570 

571 new_email: str 

572 

573 @property 

574 def string_key_base(self) -> str: 

575 return "email_change.initiated" 

576 

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

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

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

580 return builder.build() 

581 

582 @classmethod 

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

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

585 

586 @classmethod 

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

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

589 

590 

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

592class EmailChangeConfirmationEmail(EmailBase): 

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

594 

595 old_email: str 

596 confirm_url: str 

597 

598 @property 

599 def string_key_base(self) -> str: 

600 return "email_change.confirmation" 

601 

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

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

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

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

606 return builder.build() 

607 

608 @classmethod 

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

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

611 

612 

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

614class EmailVerifiedEmail(EmailBase): 

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

616 

617 @property 

618 def string_key_base(self) -> str: 

619 return "email_change.verified" 

620 

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

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

623 builder.para(".purpose") 

624 return builder.build() 

625 

626 @classmethod 

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

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

629 

630 

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

632class EventInfo: 

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

634 

635 title: str 

636 start_time: datetime 

637 end_time: datetime 

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

639 view_url: str 

640 description_markdown: str 

641 

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

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

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

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

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

647 

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

649 html += "<br>" 

650 html += time_range_display 

651 if self.address: 

652 html += "<br>" 

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

654 

655 return ParaBlock(text=Markup(html)) 

656 

657 def get_description_block(self) -> EmailBlock: 

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

659 

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

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

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

663 

664 @classmethod 

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

666 return cls( 

667 title=event.title, 

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

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

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

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

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

673 description_markdown=event.content or "", 

674 ) 

675 

676 @staticmethod 

677 def dummy() -> EventInfo: 

678 return EventInfo( 

679 title="Berlin Meetup", 

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

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

682 address="Alexanderplatz, Berlin", 

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

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

685 ) 

686 

687 

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

689class EventCreatedEmail(EmailBase): 

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

691 

692 inviting_user: UserInfo 

693 event_info: EventInfo 

694 community_name: str | None 

695 community_url: str | None 

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

697 

698 @property 

699 def string_key_base(self) -> str: 

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

701 

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

703 return self._localize( 

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

705 ) 

706 

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

708 return markdown_to_plaintext(self.event_info.description_markdown) 

709 

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

711 builder = self._body_builder(loc_context) 

712 if self.community_name: 

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

714 else: 

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

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

717 builder.user(self.inviting_user) 

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

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

720 return builder.build() 

721 

722 @classmethod 

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

724 has_community = bool(data.in_community.community_id) 

725 community_url = ( 

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

727 if has_community 

728 else None 

729 ) 

730 return cls( 

731 user_name=user_name, 

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

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

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

735 community_url=community_url, 

736 is_invite=is_invite, 

737 ) 

738 

739 @classmethod 

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

741 prototype = cls( 

742 user_name="Alice", 

743 inviting_user=UserInfo.dummy_bob(), 

744 event_info=EventInfo.dummy(), 

745 community_name="Berlin", 

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

747 is_invite=True, 

748 ) 

749 return [ 

750 replace(prototype, is_invite=True), 

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

752 replace(prototype, is_invite=False), 

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

754 ] 

755 

756 

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

758class EventUpdatedEmail(EmailBase): 

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

760 

761 updating_user: UserInfo 

762 event_info: EventInfo 

763 updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] 

764 

765 @property 

766 def string_key_base(self) -> str: 

767 return "events.updated" 

768 

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

770 return self._localize( 

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

772 ) 

773 

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

775 builder = self._body_builder(loc_context) 

776 

777 updated_items_string_keys = list( 

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

779 ) 

780 if updated_items_string_keys: 

781 updated_items_text = loc_context.localize_list( 

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

783 ) 

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

785 else: 

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

787 

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

789 builder.user(self.updating_user) 

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

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

792 return builder.build() 

793 

794 @classmethod 

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

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

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

798 updated_items.extend(data.updated_enum_items) 

799 

800 return cls( 

801 user_name=user_name, 

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

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

804 updated_items=updated_items, 

805 ) 

806 

807 @staticmethod 

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

809 match value: 

810 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE: 

811 return ".item_names.title" 

812 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT: 

813 return ".item_names.content" 

814 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION: 

815 return ".item_names.location" 

816 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME: 

817 return ".item_names.start_time" 

818 case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME: 

819 return ".item_names.end_time" 

820 case _: 

821 return None 

822 

823 @classmethod 

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

825 prototype = cls( 

826 user_name="Alice", 

827 updating_user=UserInfo.dummy_bob(), 

828 event_info=EventInfo.dummy(), 

829 updated_items=[], 

830 ) 

831 return [ 

832 replace(prototype, updated_items=[]), 

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

834 ] 

835 

836 

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

838class EventOrganizerInvitedEmail(EmailBase): 

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

840 

841 inviting_user: UserInfo 

842 event_info: EventInfo 

843 

844 @property 

845 def string_key_base(self) -> str: 

846 return "events.organizer_invited" 

847 

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

849 return self._localize( 

850 loc_context, 

851 ".subject", 

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

853 ) 

854 

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

856 builder = self._body_builder(loc_context) 

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

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

859 builder.user(self.inviting_user) 

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

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

862 builder.para(_do_not_reply_request_string_key, epilogue=True) 

863 return builder.build() 

864 

865 @classmethod 

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

867 return cls( 

868 user_name=user_name, 

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

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

871 ) 

872 

873 @classmethod 

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

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

876 

877 

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

879class EventCommentEmail(EmailBase): 

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

881 

882 author: UserInfo 

883 event_info: EventInfo 

884 comment_markdown: str 

885 

886 @property 

887 def string_key_base(self) -> str: 

888 return "events.comment" 

889 

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

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

892 

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

894 return markdown_to_plaintext(self.comment_markdown) 

895 

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

897 builder = self._body_builder(loc_context) 

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

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

900 builder.user(self.author) 

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

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

903 return builder.build() 

904 

905 @classmethod 

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

907 return cls( 

908 user_name=user_name, 

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

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

911 comment_markdown=data.reply.content, 

912 ) 

913 

914 @classmethod 

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

916 return [ 

917 cls( 

918 user_name="Alice", 

919 author=UserInfo.dummy_bob(), 

920 event_info=EventInfo.dummy(), 

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

922 ) 

923 ] 

924 

925 

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

927class EventReminderEmail(EmailBase): 

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

929 

930 event_info: EventInfo 

931 

932 @property 

933 def string_key_base(self) -> str: 

934 return "events.reminder" 

935 

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

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

938 

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

940 builder = self._body_builder(loc_context) 

941 builder.para(".purpose") 

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

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

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

945 return builder.build() 

946 

947 @classmethod 

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

949 return cls( 

950 user_name=user_name, 

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

952 ) 

953 

954 @classmethod 

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

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

957 

958 

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

960class EventCancelledEmail(EmailBase): 

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

962 

963 cancelling_user: UserInfo 

964 event_info: EventInfo 

965 

966 @property 

967 def string_key_base(self) -> str: 

968 return "events.cancel" 

969 

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

971 return self._localize( 

972 loc_context, 

973 ".subject", 

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

975 ) 

976 

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

978 builder = self._body_builder(loc_context) 

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

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

981 builder.user(self.cancelling_user) 

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

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

984 return builder.build() 

985 

986 @classmethod 

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

988 return cls( 

989 user_name=user_name, 

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

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

992 ) 

993 

994 @classmethod 

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

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

997 

998 

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

1000class EventDeletedEmail(EmailBase): 

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

1002 

1003 event_info: EventInfo 

1004 

1005 @property 

1006 def string_key_base(self) -> str: 

1007 return "events.deleted" 

1008 

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

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

1011 

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

1013 builder = self._body_builder(loc_context) 

1014 builder.para(".purpose") 

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

1016 return builder.build() 

1017 

1018 @classmethod 

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

1020 return cls( 

1021 user_name=user_name, 

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

1023 ) 

1024 

1025 @classmethod 

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

1027 return [ 

1028 cls( 

1029 user_name="Alice", 

1030 event_info=EventInfo.dummy(), 

1031 ) 

1032 ] 

1033 

1034 

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

1036class FriendReferenceReceivedEmail(EmailBase): 

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

1038 

1039 from_user: UserInfo 

1040 text: str 

1041 

1042 @property 

1043 def string_key_base(self) -> str: 

1044 return "references.received.friend" 

1045 

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

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

1048 

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

1050 return self.text 

1051 

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

1053 builder = self._body_builder(loc_context) 

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

1055 builder.user(self.from_user) 

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

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

1058 return builder.build() 

1059 

1060 @classmethod 

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

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

1063 

1064 @classmethod 

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

1066 return [ 

1067 cls( 

1068 user_name="Alice", 

1069 from_user=UserInfo.dummy_bob(), 

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

1071 ) 

1072 ] 

1073 

1074 

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

1076class FriendRequestReceivedEmail(EmailBase): 

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

1078 

1079 befriender: UserInfo 

1080 

1081 @property 

1082 def string_key_base(self) -> str: 

1083 return "friend_requests.received" 

1084 

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

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

1087 

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

1089 builder = self._body_builder(loc_context) 

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

1091 builder.user(self.befriender) 

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

1093 builder.para(".closing") 

1094 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1095 return builder.build() 

1096 

1097 @classmethod 

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

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

1100 

1101 @classmethod 

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

1103 return [ 

1104 cls( 

1105 user_name="Alice", 

1106 befriender=UserInfo.dummy_bob(), 

1107 ) 

1108 ] 

1109 

1110 

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

1112class FriendRequestAcceptedEmail(EmailBase): 

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

1114 

1115 new_friend: UserInfo 

1116 

1117 @property 

1118 def string_key_base(self) -> str: 

1119 return "friend_requests.accepted" 

1120 

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

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

1123 

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

1125 builder = self._body_builder(loc_context) 

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

1127 builder.user(self.new_friend) 

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

1129 builder.para(".closing") 

1130 return builder.build() 

1131 

1132 @classmethod 

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

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

1135 

1136 @classmethod 

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

1138 return [ 

1139 cls( 

1140 user_name="Alice", 

1141 new_friend=UserInfo.dummy_bob(), 

1142 ) 

1143 ] 

1144 

1145 

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

1147class GenderChangedEmail(EmailBase): 

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

1149 

1150 new_gender: str 

1151 

1152 @property 

1153 def string_key_base(self) -> str: 

1154 return "gender_changed" 

1155 

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

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

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

1159 return builder.build() 

1160 

1161 @classmethod 

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

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

1164 

1165 @classmethod 

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

1167 return [ 

1168 cls( 

1169 user_name="Alice", 

1170 new_gender="Male", 

1171 ) 

1172 ] 

1173 

1174 

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

1176class HostRequestCreatedEmail(EmailBase): 

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

1178 

1179 surfer: UserInfo 

1180 from_date: date 

1181 to_date: date 

1182 text: str 

1183 view_link: str 

1184 quick_decline_link: str 

1185 

1186 @property 

1187 def string_key_base(self) -> str: 

1188 return "host_requests.created" 

1189 

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

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

1192 

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

1194 return self.text 

1195 

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

1197 builder = self._body_builder(loc_context) 

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

1199 builder.user( 

1200 self.surfer, 

1201 "host_requests.generic.date_range", 

1202 { 

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

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

1205 }, 

1206 ) 

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

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

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

1210 builder.para(".respond_encouragement") 

1211 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1212 return builder.build() 

1213 

1214 @classmethod 

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

1216 return cls( 

1217 user_name, 

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

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

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

1221 text=data.text, 

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

1223 quick_decline_link=generate_quick_decline_link(data.host_request), 

1224 ) 

1225 

1226 @classmethod 

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

1228 return [ 

1229 cls( 

1230 user_name="Alice", 

1231 surfer=UserInfo.dummy_bob(), 

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

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

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

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

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

1237 ) 

1238 ] 

1239 

1240 

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

1242class HostRequestReminderEmail(EmailBase): 

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

1244 

1245 surfer: UserInfo 

1246 from_date: date 

1247 to_date: date 

1248 view_link: str 

1249 quick_decline_link: str 

1250 

1251 @property 

1252 def string_key_base(self) -> str: 

1253 return "host_requests.reminder" 

1254 

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

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

1257 

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

1259 builder = self._body_builder(loc_context) 

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

1261 builder.user( 

1262 self.surfer, 

1263 "host_requests.generic.date_range", 

1264 { 

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

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

1267 }, 

1268 ) 

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

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

1271 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1272 return builder.build() 

1273 

1274 @classmethod 

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

1276 return cls( 

1277 user_name, 

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

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

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

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

1282 quick_decline_link=generate_quick_decline_link(data.host_request), 

1283 ) 

1284 

1285 @classmethod 

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

1287 return [ 

1288 cls( 

1289 user_name="Alice", 

1290 surfer=UserInfo.dummy_bob(), 

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

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

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

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

1295 ) 

1296 ] 

1297 

1298 

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

1300class HostRequestMessageEmail(EmailBase): 

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

1302 

1303 other_user: UserInfo 

1304 from_date: date 

1305 to_date: date 

1306 text: str 

1307 from_host: bool 

1308 view_link: str 

1309 

1310 @property 

1311 def string_key_base(self) -> str: 

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

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

1314 

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

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

1317 

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

1319 return self.text 

1320 

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

1322 builder = self._body_builder(loc_context) 

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

1324 builder.user( 

1325 self.other_user, 

1326 "host_requests.generic.date_range", 

1327 { 

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

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

1330 }, 

1331 ) 

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

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

1334 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1335 return builder.build() 

1336 

1337 @classmethod 

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

1339 return cls( 

1340 user_name, 

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

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

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

1344 text=data.text, 

1345 from_host=not data.am_host, 

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

1347 ) 

1348 

1349 @classmethod 

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

1351 prototype = cls( 

1352 user_name="Alice", 

1353 other_user=UserInfo.dummy_bob(), 

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

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

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

1357 from_host=True, 

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

1359 ) 

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

1361 

1362 

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

1364class HostRequestMissedMessagesEmail(EmailBase): 

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

1366 

1367 other_user: UserInfo 

1368 from_date: date 

1369 to_date: date 

1370 from_host: bool 

1371 view_link: str 

1372 

1373 @property 

1374 def string_key_base(self) -> str: 

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

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

1377 

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

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

1380 

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

1382 builder = self._body_builder(loc_context) 

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

1384 builder.user( 

1385 self.other_user, 

1386 "host_requests.generic.date_range", 

1387 { 

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

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

1390 }, 

1391 ) 

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

1393 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1394 return builder.build() 

1395 

1396 @classmethod 

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

1398 return cls( 

1399 user_name, 

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

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

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

1403 from_host=not data.am_host, 

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

1405 ) 

1406 

1407 @classmethod 

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

1409 prototype = cls( 

1410 user_name="Alice", 

1411 other_user=UserInfo.dummy_bob(), 

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

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

1414 from_host=True, 

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

1416 ) 

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

1418 

1419 

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

1421class HostRequestStatusChangedEmail(EmailBase): 

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

1423 

1424 other_user: UserInfo 

1425 from_date: date 

1426 to_date: date 

1427 new_status: messages_pb2.HostRequestStatus.ValueType 

1428 view_link: str 

1429 

1430 @property 

1431 def string_key_base(self) -> str: 

1432 base_key = "host_requests.status_changed" 

1433 match self.new_status: 

1434 case messages_pb2.HOST_REQUEST_STATUS_ACCEPTED: 

1435 return f"{base_key}.accepted_by_host" 

1436 case messages_pb2.HOST_REQUEST_STATUS_REJECTED: 

1437 return f"{base_key}.declined_by_host" 

1438 case messages_pb2.HOST_REQUEST_STATUS_CONFIRMED: 

1439 return f"{base_key}.confirmed_by_surfer" 

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

1441 return f"{base_key}.cancelled_by_surfer" 

1442 case _: 

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

1444 

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

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

1447 

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

1449 builder = self._body_builder(loc_context) 

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

1451 builder.user( 

1452 self.other_user, 

1453 "host_requests.generic.date_range", 

1454 { 

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

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

1457 }, 

1458 ) 

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

1460 builder.para(_do_not_reply_request_string_key, epilogue=True) 

1461 return builder.build() 

1462 

1463 @classmethod 

1464 def from_notification( 

1465 cls, 

1466 data: notification_data_pb2.HostRequestAccept 

1467 | notification_data_pb2.HostRequestReject 

1468 | notification_data_pb2.HostRequestConfirm 

1469 | notification_data_pb2.HostRequestCancel, 

1470 *, 

1471 user_name: str, 

1472 ) -> Self: 

1473 other_user: UserInfo 

1474 new_status: messages_pb2.HostRequestStatus.ValueType 

1475 match data: 

1476 case notification_data_pb2.HostRequestAccept(): 

1477 other_user = UserInfo.from_protobuf(data.host) 

1478 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED 

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

1480 other_user = UserInfo.from_protobuf(data.host) 

1481 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED 

1482 case notification_data_pb2.HostRequestConfirm(): 

1483 other_user = UserInfo.from_protobuf(data.surfer) 

1484 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED 

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

1486 other_user = UserInfo.from_protobuf(data.surfer) 

1487 new_status = messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED 

1488 case _: 

1489 # Enable mypy's exhaustiveness checking 

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

1491 

1492 return cls( 

1493 user_name, 

1494 other_user=other_user, 

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

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

1497 new_status=new_status, 

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

1499 ) 

1500 

1501 @classmethod 

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

1503 prototype = cls( 

1504 user_name="Alice", 

1505 other_user=UserInfo.dummy_bob(), 

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

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

1508 new_status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED, 

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

1510 ) 

1511 return [ 

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

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

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

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

1516 ] 

1517 

1518 

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

1520class HostReferenceReceivedEmail(EmailBase): 

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

1522 

1523 from_user: UserInfo 

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

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

1526 leave_reference_url: str 

1527 

1528 @property 

1529 def string_key_base(self) -> str: 

1530 return "references.received" 

1531 

1532 @property 

1533 def string_role_subkey(self) -> str: 

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

1535 

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

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

1538 

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

1540 return self.text 

1541 

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

1543 builder = self._body_builder(loc_context) 

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

1545 builder.user(self.from_user) 

1546 if self.text: 

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

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

1549 else: 

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

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

1552 return builder.build() 

1553 

1554 @classmethod 

1555 def from_notification( 

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

1557 ) -> Self: 

1558 return cls( 

1559 user_name=user_name, 

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

1561 text=data.text or None, 

1562 surfed=surfed, 

1563 leave_reference_url=urls.leave_reference_link( 

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

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

1566 host_request_id=str(data.host_request_id), 

1567 ), 

1568 ) 

1569 

1570 @classmethod 

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

1572 prototype = cls( 

1573 user_name="Alice", 

1574 from_user=UserInfo.dummy_bob(), 

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

1576 surfed=True, 

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

1578 ) 

1579 return [ 

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

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

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

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

1584 ] 

1585 

1586 

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

1588class HostReferenceReminderEmail(EmailBase): 

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

1590 

1591 other_user: UserInfo 

1592 days_left: int 

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

1594 leave_reference_url: str 

1595 

1596 @property 

1597 def string_key_base(self) -> str: 

1598 return "references.reminder" 

1599 

1600 @property 

1601 def string_role_subkey(self) -> str: 

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

1603 

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

1605 return self._localize( 

1606 loc_context, 

1607 ".subject_days", 

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

1609 ) 

1610 

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

1612 builder = self._body_builder(loc_context) 

1613 builder.para( 

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

1615 ) 

1616 builder.user(self.other_user) 

1617 builder.action( 

1618 self.leave_reference_url, 

1619 "references.write_action", 

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

1621 ) 

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

1623 builder.para(".visibility_note") 

1624 return builder.build() 

1625 

1626 @classmethod 

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

1628 return cls( 

1629 user_name=user_name, 

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

1631 days_left=data.days_left, 

1632 surfed=surfed, 

1633 leave_reference_url=urls.leave_reference_link( 

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

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

1636 host_request_id=str(data.host_request_id), 

1637 ), 

1638 ) 

1639 

1640 @classmethod 

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

1642 prototype = cls( 

1643 user_name="Alice", 

1644 other_user=UserInfo.dummy_bob(), 

1645 days_left=7, 

1646 surfed=True, 

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

1648 ) 

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

1650 

1651 

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

1653class ModeratorNoteEmail(EmailBase): 

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

1655 

1656 @property 

1657 def string_key_base(self) -> str: 

1658 return "moderator_note" 

1659 

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

1661 builder = self._body_builder(loc_context) 

1662 builder.para(".purpose") 

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

1664 # letting them use the platform. 

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

1666 return builder.build() 

1667 

1668 @classmethod 

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

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

1671 

1672 

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

1674class NewBlogPostEmail(EmailBase): 

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

1676 

1677 title: str 

1678 blurb: str 

1679 url: str 

1680 

1681 @property 

1682 def string_key_base(self) -> str: 

1683 return "new_blog_post" 

1684 

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

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

1687 

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

1689 return self.blurb 

1690 

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

1692 builder = self._body_builder(loc_context) 

1693 builder.para(".purpose") 

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

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

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

1697 return builder.build() 

1698 

1699 @classmethod 

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

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

1702 

1703 @classmethod 

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

1705 return [ 

1706 cls( 

1707 user_name="Alice", 

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

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

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

1711 ) 

1712 ] 

1713 

1714 

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

1716class OnboardingReminderEmail(EmailBase): 

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

1718 

1719 initial: bool 

1720 

1721 @property 

1722 def string_key_base(self) -> str: 

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

1724 

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

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

1727 if self.initial: 

1728 self._build_body_initial(builder) 

1729 else: 

1730 self._build_body_followup(builder) 

1731 return builder.build() 

1732 

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

1734 builder.para(".welcome") 

1735 builder.para(".early_user_role") 

1736 builder.para(".complete_profile_request") 

1737 builder.para(".profile_importance") 

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

1739 builder.para(".share_request") 

1740 builder.para( 

1741 "generic.closing_lines.aapeli", 

1742 { 

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

1744 }, 

1745 ) 

1746 

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

1748 builder.para(".intro") 

1749 builder.para(".request") 

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

1751 builder.para("generic.thanks") 

1752 builder.para( 

1753 "generic.closing_lines.emily", 

1754 { 

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

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

1757 }, 

1758 ) 

1759 

1760 @classmethod 

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

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

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

1764 

1765 

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

1767class PasswordChangedEmail(EmailBase): 

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

1769 

1770 @property 

1771 def string_key_base(self) -> str: 

1772 return "password_changed" 

1773 

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

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

1776 builder.para(".purpose") 

1777 return builder.build() 

1778 

1779 @classmethod 

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

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

1782 

1783 

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

1785class PasswordResetCompletedEmail(EmailBase): 

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

1787 

1788 @property 

1789 def string_key_base(self) -> str: 

1790 return "password_reset.completed" 

1791 

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

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

1794 builder.para(".purpose") 

1795 return builder.build() 

1796 

1797 @classmethod 

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

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

1800 

1801 

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

1803class PasswordResetStartedEmail(EmailBase): 

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

1805 

1806 password_reset_link: str 

1807 

1808 @property 

1809 def string_key_base(self) -> str: 

1810 return "password_reset.started" 

1811 

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

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

1814 builder.para(".purpose") 

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

1816 return builder.build() 

1817 

1818 @classmethod 

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

1820 return cls( 

1821 user_name=user_name, 

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

1823 ) 

1824 

1825 @classmethod 

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

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

1828 

1829 

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

1831class PhoneNumberChangeEmail(EmailBase): 

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

1833 

1834 new_phone_number: str 

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

1836 

1837 @property 

1838 def string_key_base(self) -> str: 

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

1840 

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

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

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

1844 return builder.build() 

1845 

1846 @classmethod 

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

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

1849 

1850 @classmethod 

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

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

1853 

1854 @classmethod 

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

1856 prototype = cls( 

1857 user_name="Alice", 

1858 new_phone_number="+12223334444", 

1859 completed=False, 

1860 ) 

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

1862 

1863 

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

1865class PostalVerificationFailedEmail(EmailBase): 

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

1867 

1868 reason: notification_data_pb2.PostalVerificationFailReason.ValueType 

1869 

1870 @property 

1871 def string_key_base(self) -> str: 

1872 return "postal_verification.failed" 

1873 

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

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

1876 match self.reason: 

1877 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

1878 purpose_string_key = ".purpose.code_expired" 

1879 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

1880 purpose_string_key = ".purpose.too_many_attempts" 

1881 case _: 

1882 purpose_string_key = ".purpose.default" 

1883 builder.para(purpose_string_key) 

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

1885 return builder.build() 

1886 

1887 @classmethod 

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

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

1890 

1891 @classmethod 

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

1893 prototype = cls( 

1894 user_name="Alice", 

1895 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED, 

1896 ) 

1897 return [ 

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

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

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

1901 ] 

1902 

1903 

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

1905class PostalVerificationPostcardSentEmail(EmailBase): 

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

1907 

1908 city: str 

1909 country: str 

1910 

1911 @property 

1912 def string_key_base(self) -> str: 

1913 return "postal_verification.postcard_sent" 

1914 

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

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

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

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

1919 return builder.build() 

1920 

1921 @classmethod 

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

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

1924 

1925 @classmethod 

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

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

1928 

1929 

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

1931class PostalVerificationSucceededEmail(EmailBase): 

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

1933 

1934 @property 

1935 def string_key_base(self) -> str: 

1936 return "postal_verification.succeeded" 

1937 

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

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

1940 builder.para(".purpose") 

1941 return builder.build() 

1942 

1943 @classmethod 

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

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

1946 

1947 

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

1949class SignupVerifyEmail(EmailBase): 

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

1951 

1952 verify_url: str 

1953 

1954 @property 

1955 def string_key_base(self) -> str: 

1956 return "signup.verify" 

1957 

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

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

1960 

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

1962 builder = self._body_builder(loc_context) 

1963 builder.para(".thanks") 

1964 builder.para(".instructions") 

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

1966 builder.para("signup.closing") 

1967 return builder.build() 

1968 

1969 @classmethod 

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

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

1972 

1973 

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

1975class SignupContinueEmail(EmailBase): 

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

1977 

1978 continue_url: str 

1979 

1980 @property 

1981 def string_key_base(self) -> str: 

1982 return "signup.continue" 

1983 

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

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

1986 

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

1988 builder = self._body_builder(loc_context) 

1989 builder.para(".purpose") 

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

1991 builder.para("signup.closing") 

1992 builder.para(".ignore_if_unexpected") 

1993 return builder.build() 

1994 

1995 @classmethod 

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

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

1998 

1999 

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

2001class StrongVerificationFailedEmail(EmailBase): 

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

2003 

2004 reason: notification_data_pb2.SVFailReason.ValueType 

2005 

2006 @property 

2007 def string_key_base(self) -> str: 

2008 return "strong_verification.failed" 

2009 

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

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

2012 match self.reason: 

2013 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 

2014 purpose_string_key = ".purpose.wrong_birthdate_or_gender" 

2015 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

2016 purpose_string_key = ".purpose.not_a_passport" 

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

2018 purpose_string_key = ".purpose.duplicate" 

2019 case _: 

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

2021 builder.para(purpose_string_key) 

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

2023 return builder.build() 

2024 

2025 @classmethod 

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

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

2028 

2029 @classmethod 

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

2031 prototype = cls( 

2032 user_name="Alice", 

2033 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT, 

2034 ) 

2035 return [ 

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

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

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

2039 ] 

2040 

2041 

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

2043class StrongVerificationSucceededEmail(EmailBase): 

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

2045 

2046 @property 

2047 def string_key_base(self) -> str: 

2048 return "strong_verification.succeeded" 

2049 

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

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

2052 builder.para(".purpose") 

2053 builder.para(".thanks_message") 

2054 builder.para(".cost_explanation") 

2055 builder.para(".donation_request") 

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

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

2058 return builder.build() 

2059 

2060 @classmethod 

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

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

2063 

2064 

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

2066class ThreadReplyEmail(EmailBase): 

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

2068 

2069 author: UserInfo 

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

2071 markdown_text: str 

2072 view_link: str 

2073 

2074 @property 

2075 def string_key_base(self) -> str: 

2076 return "thread_reply" 

2077 

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

2079 return self._localize( 

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

2081 ) 

2082 

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

2084 return markdown_to_plaintext(self.markdown_text) 

2085 

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

2087 builder = self._body_builder(loc_context) 

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

2089 builder.user(self.author) 

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

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

2092 return builder.build() 

2093 

2094 @classmethod 

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

2096 parent = data.WhichOneof("reply_parent") 

2097 if parent == "event": 

2098 parent_context = data.event.title 

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

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

2101 parent_context = data.discussion.title 

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

2103 else: 

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

2105 return cls( 

2106 user_name=user_name, 

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

2108 parent_context=parent_context, 

2109 markdown_text=data.reply.content, 

2110 view_link=view_link, 

2111 ) 

2112 

2113 @classmethod 

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

2115 return [ 

2116 cls( 

2117 user_name="Alice", 

2118 author=UserInfo.dummy_bob(), 

2119 parent_context="Best hiking trails near Berlin", 

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

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

2122 ) 

2123 ] 

2124 

2125 

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

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