pinecall-runtime 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,378 @@
1
+ """The letters the gateway sends, and the brand they are worded and framed in."""
2
+
3
+ import logging
4
+ import re
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from html import escape
8
+ from urllib.parse import urlsplit
9
+
10
+ from pinecall.domain.errors import DeclarationRefused
11
+ from pinecall.domain.names import JsonObject
12
+ from pinecall.postgres.pool import Pool
13
+ from pinecall.process import box_settings
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ NAME = "Pinecall"
18
+
19
+ ACCENT = "#5b3df5"
20
+
21
+ # Written into a style attribute, so six hex digits and nothing else.
22
+ A_BRAND_COLOUR = re.compile(r"^#[0-9a-fA-F]{6}$")
23
+
24
+ # The name goes into a subject line, where a line break would start another header.
25
+ LONGEST_NAME = 60
26
+
27
+ NOT_A_COLOUR = "{said!r} is not an accent: a colour is #rrggbb, six hex digits"
28
+
29
+ NOT_A_LOGO = (
30
+ "{said!r} is not a logo: an https:// URL of an image, since a mail client fetches it from "
31
+ "wherever the reader is and blocks plain http"
32
+ )
33
+
34
+ BRAND = "brand"
35
+
36
+ NOBODY_ASKED = "If you did not ask for this, nothing has changed and you can ignore this message."
37
+
38
+ CARD = "/invitations/{token}"
39
+
40
+ ONCE = "This link opens once."
41
+
42
+ FROM_THEM = "Sent by {org} through {brand}"
43
+
44
+ DIES_ON = "{sent} · this link opens once and dies on {date}"
45
+
46
+ OPEN_IT = "Open it in a browser"
47
+
48
+ # Inline styles only: mail clients read little CSS. No webfont and no image but the operator's
49
+ # logo, since a remote request tells the sender when and where a letter was opened.
50
+ WASH = "#f7f6fa"
51
+
52
+ INK = "#101014"
53
+
54
+ MUTED = "#6b6975"
55
+
56
+ HAIRLINE = "#eeedf2"
57
+
58
+ FONT = "Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
59
+
60
+ MONO = "SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace"
61
+
62
+ WIDTH = 560
63
+
64
+ LOGO_HEIGHT = 28
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Letter:
69
+ """A letter ready to go: to whom, its subject, and the same words as text and as HTML."""
70
+
71
+ to: str
72
+ subject: str
73
+ text: str
74
+ html: str
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Brand:
79
+ """What the letters are called and painted with."""
80
+
81
+ name: str = NAME
82
+ # None fetches no image at all.
83
+ logo_url: str | None = None
84
+ accent: str = ACCENT
85
+
86
+ def __post_init__(self) -> None:
87
+ if not self.name.strip() or len(self.name) > LONGEST_NAME or "\n" in self.name:
88
+ raise DeclarationRefused(NOT_A_NAME)
89
+ if not A_BRAND_COLOUR.match(self.accent):
90
+ raise DeclarationRefused(NOT_A_COLOUR.format(said=self.accent))
91
+ if self.logo_url is not None and not _an_https_url(self.logo_url):
92
+ raise DeclarationRefused(NOT_A_LOGO.format(said=self.logo_url))
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Wording:
97
+ """What a letter with a link says: its subject, its title, its sentences and its button."""
98
+
99
+ subject: str
100
+ title: str
101
+ said: tuple[str, ...]
102
+ label: str
103
+ # Nobody may have asked for it, so it says what to do then.
104
+ unbidden: bool = False
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Link:
109
+ """What a letter with a link says: the org, who sent it, the link, and when it dies."""
110
+
111
+ org: str
112
+ link: str
113
+ by: str | None = None
114
+ dies: datetime | None = None
115
+
116
+
117
+ NOT_A_NAME = f"a brand's name is one line of at most {LONGEST_NAME} characters"
118
+
119
+
120
+ def card_link(base: str, token: str) -> str:
121
+ """The console's card for a token, at the name this gateway answers to."""
122
+ return f"{base.rstrip('/')}{CARD.format(token=token)}"
123
+
124
+
125
+ def invitation_letter(to: str, link: Link, brand: Brand) -> Letter:
126
+ """The letter that invites a person into an org."""
127
+ wording = Wording(
128
+ subject=f"You're invited to {link.org} on {brand.name}",
129
+ title="Join your team",
130
+ said=(
131
+ f"{link.by} has invited you to join {link.org} on {brand.name}.",
132
+ "Choose a password and you are in.",
133
+ ),
134
+ label="Accept the invitation",
135
+ )
136
+ return _linked(to, link, brand, wording)
137
+
138
+
139
+ def reset_letter(to: str, link: Link, brand: Brand) -> Letter:
140
+ """The letter an admin's reset of a member's password sends."""
141
+ wording = Wording(
142
+ subject=f"Reset your {brand.name} password",
143
+ title="Set a new password",
144
+ said=(
145
+ f"{link.by} has reset your password for {link.org}.",
146
+ "Choose a new one to sign in again.",
147
+ ),
148
+ label="Choose a password",
149
+ )
150
+ return _linked(to, link, brand, wording)
151
+
152
+
153
+ def forgotten_password_letter(to: str, link: Link, brand: Brand) -> Letter:
154
+ """The letter a forgotten password sends, which nobody may have asked for."""
155
+ wording = Wording(
156
+ subject=f"Reset your {brand.name} password",
157
+ title="Reset your password",
158
+ said=(f"Somebody asked to reset the password for this address at {link.org}.",),
159
+ label="Choose a password",
160
+ unbidden=True,
161
+ )
162
+ return _linked(to, link, brand, wording)
163
+
164
+
165
+ def probe_letter(to: str, brand: Brand) -> Letter:
166
+ """The test letter that proves a mailbox sends."""
167
+ text = (
168
+ f"This is a test message from {brand.name}. Your mail server took it, so the letters "
169
+ "this gateway sends will reach you."
170
+ )
171
+ body = _heading("It works") + _paragraph(text)
172
+ return Letter(to, f"{brand.name} test message", text, _framed(text, body, brand.name, brand))
173
+
174
+
175
+ # A sign-up answers every address alike, so whether one has an account is said here, to its owner.
176
+ def account_kept_letter(to: str, brand: Brand) -> Letter:
177
+ """The letter a sign-up with an address that has an account sends instead of a code."""
178
+ return _notice(
179
+ to,
180
+ brand,
181
+ "You already have an account",
182
+ (
183
+ f"Somebody tried to sign up for {brand.name} with this address, which already has an "
184
+ "account. To make another org, sign up again with the password you have; if you "
185
+ "forgot it, reset it from the sign-in page."
186
+ ),
187
+ )
188
+
189
+
190
+ def invitation_waiting_letter(to: str, brand: Brand) -> Letter:
191
+ """The letter a sign-up with an address invited somewhere sends instead of a code."""
192
+ return _notice(
193
+ to,
194
+ brand,
195
+ "You have an invitation waiting",
196
+ (
197
+ f"Somebody tried to sign up for {brand.name} with this address, which was invited to "
198
+ "an org. Accept that invitation first, from the letter it came in; then sign up with "
199
+ "the password you chose there."
200
+ ),
201
+ )
202
+
203
+
204
+ # The code rides the preheader, never the subject: the outbox logs subjects, and a logged code is
205
+ # a code anybody reading the log could spend.
206
+ def signup_code_letter(to: str, code: str, person: str, brand: Brand) -> Letter:
207
+ """The letter that carries a sign-up's six digits."""
208
+ params = f"Hi {person}, enter this code to finish setting up your {brand.name} account:"
209
+ dies = "This code expires in 15 minutes."
210
+ unbidden = f"If you did not create a {brand.name} account, ignore this email."
211
+ footer = f"Sent by {brand.name}"
212
+ body = (
213
+ _heading("Confirm your email")
214
+ + _paragraph(params)
215
+ + _code(code)
216
+ + _small(dies)
217
+ + _small(unbidden)
218
+ )
219
+ text = f"Confirm your email\n\n{params}\n\n{code}\n\n{dies}\n\n{unbidden}\n\n{footer}"
220
+ preheader = f"Your {brand.name} verification code is {code}"
221
+ return Letter(
222
+ to, f"Confirm your {brand.name} email", text, _framed(preheader, body, footer, brand)
223
+ )
224
+
225
+
226
+ async def brand_of(pool: Pool) -> Brand:
227
+ """The box's brand; Pinecall's when nobody set one, or when the row does not read."""
228
+ async with pool.connection() as connection:
229
+ value = await box_settings.read(connection, BRAND)
230
+ if value is None:
231
+ return Brand()
232
+ logo = value.get("logo_url")
233
+ try:
234
+ return Brand(
235
+ name=str(value.get("name") or NAME),
236
+ logo_url=str(logo) if logo else None,
237
+ accent=str(value.get("accent") or ACCENT),
238
+ )
239
+ except DeclarationRefused:
240
+ logger.warning("the box's brand does not read: letters go out as Pinecall's")
241
+ return Brand()
242
+
243
+
244
+ async def put_brand(pool: Pool, brand: Brand) -> None:
245
+ """Keep the box's brand."""
246
+ value: JsonObject = {"name": brand.name, "logo_url": brand.logo_url, "accent": brand.accent}
247
+ async with pool.connection() as connection:
248
+ await box_settings.write(connection, BRAND, value)
249
+
250
+
251
+ # None keeps a field; an empty string sets it back to the default, the one way to clear a logo.
252
+ def apply_brand(
253
+ brand: Brand, *, name: str | None = None, logo_url: str | None = None, accent: str | None = None
254
+ ) -> Brand:
255
+ """The brand with what was named replaced."""
256
+ return Brand(
257
+ name=brand.name if name is None else (name.strip() or NAME),
258
+ logo_url=brand.logo_url if logo_url is None else (logo_url.strip() or None),
259
+ accent=brand.accent if accent is None else (accent.strip().lower() or ACCENT),
260
+ )
261
+
262
+
263
+ # Text and HTML come from the same sentences, so the two halves never disagree.
264
+ def _linked(to: str, link: Link, brand: Brand, wording: Wording) -> Letter:
265
+ quiet = [ONCE, *([NOBODY_ASKED] if wording.unbidden else [])]
266
+ body = (
267
+ _heading(wording.title)
268
+ + "".join(_paragraph(item) for item in wording.said)
269
+ + _button(wording.label, link.link, brand.accent)
270
+ + _fallback(link.link)
271
+ + "".join(_small(item) for item in quiet)
272
+ )
273
+ sent = FROM_THEM.format(org=link.org, brand=brand.name)
274
+ footer = sent if link.dies is None else DIES_ON.format(sent=sent, date=_day(link.dies))
275
+ opened_it = f"{OPEN_IT}:\n\n{link.link}"
276
+ text = "\n\n".join([wording.title, *wording.said, opened_it, *quiet, footer])
277
+ return Letter(to, wording.subject, text, _framed(wording.said[0], body, footer, brand))
278
+
279
+
280
+ def _day(moment: datetime) -> str:
281
+ return f"{moment.day} {moment.strftime('%B')} {moment.year}"
282
+
283
+
284
+ def _notice(to: str, brand: Brand, title: str, text: str) -> Letter:
285
+ unbidden = "If it was not you, ignore this email: nothing was changed."
286
+ footer = f"Sent by {brand.name}"
287
+ body = _heading(title) + _paragraph(text) + _small(unbidden)
288
+ plain = f"{title}\n\n{text}\n\n{unbidden}\n\n{footer}"
289
+ return Letter(to, f"{title} on {brand.name}", plain, _framed(text, body, footer, brand))
290
+
291
+
292
+ def _heading(text: str) -> str:
293
+ return (
294
+ f'<h1 style="margin:0 0 14px;font-family:{FONT};font-size:21px;line-height:1.3;'
295
+ f'font-weight:650;letter-spacing:-0.02em;color:{INK};">{escape(text)}</h1>'
296
+ )
297
+
298
+
299
+ def _paragraph(text: str) -> str:
300
+ return (
301
+ f'<p style="margin:0 0 16px;font-family:{FONT};font-size:15px;line-height:1.6;'
302
+ f'color:{INK};">{escape(text)}</p>'
303
+ )
304
+
305
+
306
+ def _small(text: str) -> str:
307
+ return (
308
+ f'<p style="margin:0 0 8px;font-family:{FONT};font-size:13px;line-height:1.5;'
309
+ f'color:{MUTED};">{escape(text)}</p>'
310
+ )
311
+
312
+
313
+ # A table: Outlook draws an inline-block link wrong.
314
+ def _button(label: str, href: str, accent: str) -> str:
315
+ return (
316
+ '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:6px 0 18px;">'
317
+ f'<tr><td><a href="{escape(href, quote=True)}" style="display:inline-block;'
318
+ f"background:{accent};color:#ffffff;font-family:{FONT};font-size:15px;font-weight:600;"
319
+ f'text-decoration:none;padding:12px 22px;line-height:18px;border-radius:9px;">'
320
+ f"{escape(label)}</a></td></tr></table>"
321
+ )
322
+
323
+
324
+ def _fallback(href: str) -> str:
325
+ return (
326
+ f'<p style="margin:0 0 16px;font-family:{FONT};font-size:13px;line-height:1.5;'
327
+ f'color:{MUTED};word-break:break-all;">{escape(href)}</p>'
328
+ )
329
+
330
+
331
+ # The left padding is the letter spacing, which also trails the last digit: the code stays centred.
332
+ def _code(code: str) -> str:
333
+ return (
334
+ '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
335
+ f'style="margin:8px 0 20px;"><tr><td align="center" style="background:{WASH};'
336
+ f'border:1px solid {HAIRLINE};border-radius:12px;padding:22px 16px;">'
337
+ f'<div style="font-family:{MONO};font-size:34px;line-height:1;font-weight:600;'
338
+ f'letter-spacing:10px;padding-left:10px;color:{INK};">{escape(code)}</div>'
339
+ "</td></tr></table>"
340
+ )
341
+
342
+
343
+ def _framed(preheader: str, body: str, footer: str, brand: Brand) -> str:
344
+ logo = (
345
+ ""
346
+ if brand.logo_url is None
347
+ else '<tr><td style="padding:0 6px 16px;">'
348
+ f'<img src="{escape(brand.logo_url, quote=True)}" alt="{escape(brand.name, quote=True)}" '
349
+ f'height="{LOGO_HEIGHT}" style="display:block;height:{LOGO_HEIGHT}px;width:auto;border:0;">'
350
+ "</td></tr>"
351
+ )
352
+ return (
353
+ '<!DOCTYPE html>\n<html lang="en"><head><meta charset="utf-8">'
354
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
355
+ '<meta name="color-scheme" content="light only"></head>'
356
+ f'<body style="margin:0;padding:0;background:{WASH};">'
357
+ '<div style="display:none;max-height:0;overflow:hidden;opacity:0;">'
358
+ f"{escape(preheader)}</div>"
359
+ '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
360
+ f'style="background:{WASH};padding:36px 14px;"><tr><td align="center">'
361
+ '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
362
+ f'style="max-width:{WIDTH}px;">{logo}'
363
+ f'<tr><td style="background:#ffffff;border:1px solid {HAIRLINE};border-radius:14px;'
364
+ f'padding:30px 30px 24px;">{body}</td></tr>'
365
+ f'<tr><td style="padding:18px 6px 0;font-family:{FONT};font-size:12px;line-height:1.6;'
366
+ f'color:{MUTED};">{escape(footer)}</td></tr>'
367
+ "</table></td></tr></table></body></html>"
368
+ )
369
+
370
+
371
+ def _an_https_url(written: str) -> bool:
372
+ """Whether the text is an https URL with a host and nothing an attribute would break on."""
373
+ parts = urlsplit(written)
374
+ return (
375
+ parts.scheme == "https"
376
+ and bool(parts.hostname)
377
+ and not any(char in written for char in " \"'<>\n")
378
+ )