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,458 @@
1
+ """Mail: the mailboxes a letter goes out through, the box's brand, and SMTP."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import smtplib
7
+ import ssl
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from datetime import UTC, datetime
11
+ from email.message import EmailMessage
12
+ from email.utils import parseaddr
13
+ from typing import Literal
14
+ from urllib.parse import unquote, urlsplit
15
+
16
+ from cryptography.fernet import MultiFernet
17
+ from psycopg.types.json import Jsonb
18
+
19
+ from pinecall.domain.errors import DeclarationRefused, UpstreamFailed
20
+ from pinecall.domain.names import AN_ADDRESS, Json
21
+ from pinecall.postgres.pool import Pool
22
+ from pinecall.process import resolver
23
+ from pinecall.process.connections import Connections
24
+ from pinecall.tenancy.letters import Letter
25
+ from pinecall.tenancy.vault import opened, sealed
26
+
27
+ # starttls on 587, implicit TLS on 465; none only for a relay on this machine or a private network.
28
+ type Security = Literal["starttls", "tls", "none"]
29
+
30
+
31
+ # Where a mailbox was read from: the org's own, the one the operator stored, the environment's.
32
+ type Source = Literal["org", "stored", "environment"]
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # No spaces or slashes: it catches `smtp.example.com/`.
39
+ A_HOST = re.compile(r"^[A-Za-z0-9.\-_\[\]:]+$")
40
+
41
+
42
+ HIGHEST_PORT = 65535
43
+
44
+
45
+ TIMEOUT_S = 10.0
46
+
47
+
48
+ NOT_A_MAIL_URL = "a mail URL starts smtp:// (STARTTLS) or smtps:// (implicit TLS)"
49
+
50
+
51
+ NO_SERVER = "the mail URL names no mail server after its `@`"
52
+
53
+
54
+ NO_ADDRESS = "{written!r} carries no email address"
55
+
56
+
57
+ # Never the URL itself in a refusal: it holds the password.
58
+ REFUSED = "{host}:{port} refused it: {said}"
59
+
60
+
61
+ UNREACHABLE = "{host}:{port} did not answer: {said}"
62
+
63
+
64
+ TIMED_OUT = "{host}:{port} did not answer in {seconds:g}s"
65
+
66
+
67
+ NOT_WRITTEN = "the letter could not be written: {said}"
68
+
69
+
70
+ # The password travels in clear on a plain connection: never across the internet.
71
+ NOT_PRIVATE = (
72
+ "{host} is not on this machine or a private network: a mailbox with no TLS sends its "
73
+ "password in clear, so it reaches only a relay there; use starttls or tls"
74
+ )
75
+
76
+
77
+ # An org's server is on the internet: one inside the box's network is the box's, not the org's.
78
+ NOT_PUBLIC = (
79
+ "{host} resolves to no public address, or to one that is not: an org's mailbox is a mail "
80
+ "server on the internet"
81
+ )
82
+
83
+
84
+ NOT_PLAIN = (
85
+ "an org's mailbox is reached over TLS, starttls or tls: none is the box's own relay alone"
86
+ )
87
+
88
+
89
+ BOX_MAIL = "mail"
90
+
91
+
92
+ ORG_MAIL = """
93
+ SELECT host, port, security, username, sender, ciphertext, verified_at, last_error
94
+ FROM org_mail WHERE org = %(org)s
95
+ """
96
+
97
+
98
+ PUT_ORG_MAIL = """
99
+ INSERT INTO org_mail (org, host, port, security, username, ciphertext, sender)
100
+ VALUES (%(org)s, %(host)s, %(port)s, %(security)s, %(username)s, %(ciphertext)s, %(sender)s)
101
+ ON CONFLICT (org) DO UPDATE SET
102
+ host = excluded.host, port = excluded.port, security = excluded.security,
103
+ username = excluded.username, ciphertext = excluded.ciphertext, sender = excluded.sender,
104
+ verified_at = NULL, last_error = NULL, set_at = now()
105
+ """
106
+
107
+
108
+ DROP_ORG_MAIL = "DELETE FROM org_mail WHERE org = %(org)s RETURNING org"
109
+
110
+
111
+ ORG_MAIL_WENT = """
112
+ UPDATE org_mail SET last_error = %(error)s,
113
+ verified_at = CASE WHEN %(error)s::text IS NULL THEN now() ELSE verified_at END
114
+ WHERE org = %(org)s
115
+ """
116
+
117
+
118
+ SETTING = "SELECT value, ciphertext FROM box_settings WHERE name = %(name)s"
119
+
120
+
121
+ PUT_SETTING = """
122
+ INSERT INTO box_settings (name, value, ciphertext) VALUES (%(name)s, %(value)s, %(ciphertext)s)
123
+ ON CONFLICT (name) DO UPDATE SET value = excluded.value, ciphertext = excluded.ciphertext,
124
+ set_at = now()
125
+ """
126
+
127
+
128
+ DROP_SETTING = "DELETE FROM box_settings WHERE name = %(name)s RETURNING name"
129
+
130
+
131
+ # The outcome is kept inside the box's row, beside what it names.
132
+ BOX_MAIL_WENT = """
133
+ UPDATE box_settings SET value = value || %(went)s WHERE name = %(name)s
134
+ """
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class Mailbox:
139
+ """An SMTP account: the server, the credentials, and who the letters are from."""
140
+
141
+ host: str
142
+ port: int
143
+ security: Security
144
+ # Empty for a relay that asks for nothing. The password is kept sealed, never shown.
145
+ username: str
146
+ password: str
147
+ sender: str
148
+
149
+ def __post_init__(self) -> None:
150
+ if not A_HOST.match(self.host):
151
+ raise DeclarationRefused(f"{self.host!r} is not a host: a mail server is named bare")
152
+ if not 1 <= self.port <= HIGHEST_PORT:
153
+ raise DeclarationRefused(f"a port is 1 to {HIGHEST_PORT}, not {self.port}")
154
+ address_of(self.sender)
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class MailboxStatus:
159
+ """A mailbox, how its last letter went, and where it was read from."""
160
+
161
+ mailbox: Mailbox
162
+ source: Source
163
+ verified_at: datetime | None = None
164
+ last_error: str | None = None
165
+
166
+
167
+ class Outbox:
168
+ """Every letter the gateway sends, through the org's mailbox or the box's, in the background."""
169
+
170
+ def __init__(self, connections: Connections, environment: Mailbox | None) -> None:
171
+ """An outbox with nothing in flight."""
172
+ self.connections = connections
173
+ self.environment = environment
174
+ # Held so the loop cannot collect a letter halfway out.
175
+ self.in_flight: set[asyncio.Task[None]] = set()
176
+
177
+ async def mailbox_for(self, org: str | None) -> MailboxStatus | None:
178
+ """The org's mailbox, else the box's; with no org, only the box's."""
179
+ own = (
180
+ None
181
+ if org is None
182
+ else await mail_of(self.connections.pool, self.connections.vault, org)
183
+ )
184
+ return own or await box_mail_of(
185
+ self.connections.pool, self.connections.vault, self.environment
186
+ )
187
+
188
+ # Queued, not delivered: a request never waits on somebody's mail server.
189
+ async def post(self, org: str | None, letter: Letter) -> bool:
190
+ """Send the letter in the background; False when nothing here can send it."""
191
+ if await self.mailbox_for(org) is None:
192
+ return False
193
+ task = asyncio.create_task(self._posted(org, letter))
194
+ self.in_flight.add(task)
195
+ task.add_done_callback(self.in_flight.discard)
196
+ return True
197
+
198
+ async def sent(self, org: str | None, letter: Letter) -> str | None:
199
+ """Send and wait, as a test letter does: None when it went, else the server's reply."""
200
+ existing = await self.mailbox_for(org)
201
+ if existing is None:
202
+ return None
203
+ try:
204
+ if existing.source == "org":
205
+ await asyncio.to_thread(check_public, existing.mailbox)
206
+ await post(existing.mailbox, letter)
207
+ except UpstreamFailed as refused:
208
+ await self._went(org, existing.source, str(refused))
209
+ return str(refused)
210
+ await self._went(org, existing.source, None)
211
+ logger.info("mailed %s through %s", letter.subject, existing.mailbox.host)
212
+ return None
213
+
214
+ async def drained(self) -> None:
215
+ """Wait for every letter in flight."""
216
+ while self.in_flight:
217
+ await asyncio.gather(*self.in_flight, return_exceptions=True)
218
+
219
+ # A failure is said here: asyncio would say it only when the task is collected.
220
+ async def _posted(self, org: str | None, letter: Letter) -> None:
221
+ data = await self.sent(org, letter)
222
+ if data is not None:
223
+ logger.warning("could not mail %s: %s", letter.subject, data)
224
+
225
+ # Kept on the mailbox the letter went through, never on another.
226
+ async def _went(self, org: str | None, source: Source, error: str | None) -> None:
227
+ # independent: one of the two runs, on the mailbox the letter went through
228
+ async with self.connections.pool.connection() as connection:
229
+ if source == "org":
230
+ await connection.execute(ORG_MAIL_WENT, {"org": org, "error": error})
231
+ elif source == "stored":
232
+ went: dict[str, Json] = {"last_error": error}
233
+ if error is None:
234
+ went["verified_at"] = datetime.now(UTC).isoformat()
235
+ await connection.execute(BOX_MAIL_WENT, {"name": BOX_MAIL, "went": Jsonb(went)})
236
+
237
+
238
+ SCHEMES: Mapping[str, tuple[int, Security]] = {"smtp": (587, "starttls"), "smtps": (465, "tls")}
239
+
240
+
241
+ def address_of(written: str) -> str:
242
+ """The address in `Name <a@b.c>` or a bare one; refused when there is none."""
243
+ _, address = parseaddr(written)
244
+ if not AN_ADDRESS.match(address):
245
+ raise DeclarationRefused(NO_ADDRESS.format(written=written))
246
+ return address
247
+
248
+
249
+ # Split at the LAST `@`: an SES password is base64 and may hold a raw `/`, which would end a URL's
250
+ # authority for urlsplit.
251
+ def parse_mailbox_url(url: str, sender: str) -> Mailbox:
252
+ """`smtp://user:pass@host:587` or `smtps://…`, and who letters are from, as a mailbox."""
253
+ scheme, separated, rest = url.strip().partition("://")
254
+ if not separated or scheme not in SCHEMES:
255
+ raise DeclarationRefused(NOT_A_MAIL_URL)
256
+ port, security = SCHEMES[scheme]
257
+ userinfo, _, where = rest.rpartition("@")
258
+ parts = urlsplit(f"{scheme}://{where.split('/', 1)[0]}")
259
+ if not parts.hostname:
260
+ raise DeclarationRefused(NO_SERVER)
261
+ try:
262
+ named = parts.port
263
+ except ValueError:
264
+ raise DeclarationRefused("the mail URL's port is not a number") from None
265
+ username, _, password = userinfo.partition(":")
266
+ return Mailbox(
267
+ host=parts.hostname,
268
+ port=named or port,
269
+ security=security,
270
+ username=unquote(username),
271
+ password=unquote(password),
272
+ sender=sender,
273
+ )
274
+
275
+
276
+ async def mail_of(pool: Pool, vault: MultiFernet, org: str) -> MailboxStatus | None:
277
+ """The org's own mailbox and how its last letter went."""
278
+ async with pool.connection() as connection:
279
+ row = await (await connection.execute(ORG_MAIL, {"org": org})).fetchone()
280
+ if row is None:
281
+ return None
282
+ return _status_of(row, opened(vault, row["ciphertext"]), "org")
283
+
284
+
285
+ async def put_mail(pool: Pool, vault: MultiFernet, org: str, mailbox: Mailbox) -> None:
286
+ """Keep the org's own mailbox, the password sealed; how it went is forgotten."""
287
+ if mailbox.security == "none":
288
+ raise DeclarationRefused(NOT_PLAIN)
289
+ values = {**_fields(mailbox), "org": org, "ciphertext": sealed(vault, mailbox.password)}
290
+ async with pool.connection() as connection:
291
+ await connection.execute(PUT_ORG_MAIL, values)
292
+
293
+
294
+ async def drop_mail(pool: Pool, org: str) -> bool:
295
+ """Forget the org's own mailbox; whether it had one."""
296
+ async with pool.connection() as connection:
297
+ return await (await connection.execute(DROP_ORG_MAIL, {"org": org})).fetchone() is not None
298
+
299
+
300
+ async def box_mail_of(
301
+ pool: Pool, vault: MultiFernet, environment: Mailbox | None
302
+ ) -> MailboxStatus | None:
303
+ """The box's mailbox: the one the operator stored, else the environment's."""
304
+ async with pool.connection() as connection:
305
+ row = await (await connection.execute(SETTING, {"name": BOX_MAIL})).fetchone()
306
+ stored = None if row is None or row["ciphertext"] is None else row
307
+ if stored is not None:
308
+ found = _status_of(stored["value"], opened(vault, stored["ciphertext"]), "stored")
309
+ if found is not None:
310
+ return found
311
+ return None if environment is None else MailboxStatus(environment, "environment")
312
+
313
+
314
+ async def put_box_mail(pool: Pool, vault: MultiFernet, mailbox: Mailbox) -> None:
315
+ """Keep the mailbox the box sends through, over the environment's; how it went is forgotten."""
316
+ values = {
317
+ "name": BOX_MAIL,
318
+ "value": Jsonb(_fields(mailbox)),
319
+ "ciphertext": sealed(vault, mailbox.password),
320
+ }
321
+ async with pool.connection() as connection:
322
+ await connection.execute(PUT_SETTING, values)
323
+
324
+
325
+ async def drop_box_mail(pool: Pool) -> bool:
326
+ """Forget the box's stored mailbox, back to the environment's; whether one was stored."""
327
+ async with pool.connection() as connection:
328
+ dropped = await connection.execute(DROP_SETTING, {"name": BOX_MAIL})
329
+ return await dropped.fetchone() is not None
330
+
331
+
332
+ # smtplib gets a timeout of its own: cancelling the thread's future does not stop the thread.
333
+ async def post(mailbox: Mailbox, letter: Letter, *, within_s: float = TIMEOUT_S) -> None:
334
+ """Hand the letter to the mail server; its own reply when it refuses."""
335
+ try:
336
+ message = _message(mailbox, letter)
337
+ except ValueError as unwritable:
338
+ raise UpstreamFailed(NOT_WRITTEN.format(said=unwritable)) from unwritable
339
+ try:
340
+ await asyncio.wait_for(asyncio.to_thread(_handed, mailbox, message, within_s), within_s)
341
+ except TimeoutError:
342
+ text = TIMED_OUT.format(host=mailbox.host, port=mailbox.port, seconds=within_s)
343
+ raise UpstreamFailed(text) from None
344
+
345
+
346
+ # Every address the name resolves to is checked, and a name that does not resolve is refused: the
347
+ # server reached is on the internet, never a service inside the box's network.
348
+ def check_public(mailbox: Mailbox) -> None:
349
+ """Refuse an org's mailbox that is plain, or that its name puts anywhere but the internet."""
350
+ if mailbox.security == "none":
351
+ raise UpstreamFailed(NOT_PLAIN)
352
+ try:
353
+ addresses = resolver.addresses_of(mailbox.host)
354
+ except OSError:
355
+ raise UpstreamFailed(NOT_PUBLIC.format(host=mailbox.host)) from None
356
+ if not resolver.all_public(addresses):
357
+ raise UpstreamFailed(NOT_PUBLIC.format(host=mailbox.host))
358
+
359
+
360
+ def parse_security(word: str) -> Security:
361
+ """The word as how a mailbox is secured; refused when it is none of the three."""
362
+ match word:
363
+ case "starttls" | "tls" | "none":
364
+ return word
365
+ case _:
366
+ raise DeclarationRefused(f"a mailbox is secured by starttls, tls or none, not {word!r}")
367
+
368
+
369
+ def _status_of(
370
+ fields: Mapping[str, object], password: Json, source: Source
371
+ ) -> MailboxStatus | None:
372
+ if not isinstance(password, str):
373
+ return None
374
+ try:
375
+ mailbox = Mailbox(
376
+ host=str(fields["host"]),
377
+ port=int(str(fields["port"])),
378
+ security=parse_security(str(fields["security"])),
379
+ username=str(fields["username"]),
380
+ password=password,
381
+ sender=str(fields["sender"]),
382
+ )
383
+ except (KeyError, ValueError, DeclarationRefused):
384
+ logger.warning("a stored mailbox does not read: it is left unused")
385
+ return None
386
+ verified = fields.get("verified_at")
387
+ if isinstance(verified, str):
388
+ verified = datetime.fromisoformat(verified)
389
+ error = fields.get("last_error")
390
+ return MailboxStatus(
391
+ mailbox=mailbox,
392
+ source=source,
393
+ verified_at=verified if isinstance(verified, datetime) else None,
394
+ last_error=error if isinstance(error, str) else None,
395
+ )
396
+
397
+
398
+ def _fields(mailbox: Mailbox) -> dict[str, Json]:
399
+ return {
400
+ "host": mailbox.host,
401
+ "port": mailbox.port,
402
+ "security": mailbox.security,
403
+ "username": mailbox.username,
404
+ "sender": mailbox.sender,
405
+ }
406
+
407
+
408
+ def _handed(mailbox: Mailbox, message: EmailMessage, timeout: float) -> None:
409
+ try:
410
+ with _connected(mailbox, timeout) as server:
411
+ if mailbox.security == "starttls":
412
+ server.starttls(context=ssl.create_default_context())
413
+ if mailbox.username:
414
+ server.login(mailbox.username, mailbox.password)
415
+ server.send_message(message)
416
+ except smtplib.SMTPResponseException as text:
417
+ spoken = (
418
+ text.smtp_error.decode("utf-8", "replace")
419
+ if isinstance(text.smtp_error, bytes)
420
+ else str(text.smtp_error)
421
+ )
422
+ reply = f"{text.smtp_code} {spoken.strip()}"
423
+ raise UpstreamFailed(
424
+ REFUSED.format(host=mailbox.host, port=mailbox.port, said=reply)
425
+ ) from text
426
+ except (OSError, smtplib.SMTPException) as down:
427
+ text = f"{type(down).__name__}: {down}"
428
+ raise UpstreamFailed(
429
+ UNREACHABLE.format(host=mailbox.host, port=mailbox.port, said=text)
430
+ ) from down
431
+
432
+
433
+ def _connected(mailbox: Mailbox, timeout: float) -> smtplib.SMTP:
434
+ if mailbox.security == "tls":
435
+ context = ssl.create_default_context()
436
+ return smtplib.SMTP_SSL(mailbox.host, mailbox.port, timeout=timeout, context=context)
437
+ if mailbox.security == "none":
438
+ return smtplib.SMTP(_private_address(mailbox.host), mailbox.port, timeout=timeout)
439
+ return smtplib.SMTP(mailbox.host, mailbox.port, timeout=timeout)
440
+
441
+
442
+ # Resolved once and connected to by the address checked: a name cannot point elsewhere between.
443
+ def _private_address(host: str) -> str:
444
+ addresses = resolver.addresses_of(host)
445
+ if not addresses or not all(item.is_private or item.is_loopback for item in addresses):
446
+ raise UpstreamFailed(NOT_PRIVATE.format(host=host))
447
+ return str(addresses[0])
448
+
449
+
450
+ # The text first: a client that shows one part shows it, and every link is written out in it.
451
+ def _message(mailbox: Mailbox, letter: Letter) -> EmailMessage:
452
+ message = EmailMessage()
453
+ message["From"] = mailbox.sender
454
+ message["To"] = letter.to
455
+ message["Subject"] = letter.subject
456
+ message.set_content(letter.text)
457
+ message.add_alternative(letter.html, subtype="html")
458
+ return message
@@ -0,0 +1,123 @@
1
+ """An org's secrets in a world: the values its hosted apps are started with, kept sealed."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ from cryptography.fernet import MultiFernet
8
+
9
+ from pinecall.domain.errors import DeclarationRefused, NotFound
10
+ from pinecall.domain.names import Env
11
+ from pinecall.postgres.pool import Pool
12
+ from pinecall.tenancy.vault import opened, sealed
13
+
14
+ # An environment variable's name, as a shell takes it.
15
+ A_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
16
+
17
+
18
+ # What the box sets itself on every process it starts.
19
+ RESERVED = "PINECALL_"
20
+
21
+
22
+ LONGEST_VALUE = 16 * 1024
23
+
24
+
25
+ NOT_A_NAME = "{name!r} is not a secret's name: capitals, digits and underscores, like CRM_TOKEN"
26
+
27
+
28
+ IS_THE_BOXS = "{name} is the box's to set: a name starting with PINECALL_ is never an org's secret"
29
+
30
+
31
+ TOO_LONG = "a secret is {limit} KB at most"
32
+
33
+
34
+ NOT_SET = "the org has no secret called {name} in {env}"
35
+
36
+
37
+ SECRETS = """
38
+ SELECT name, set_by, set_at FROM org_secrets
39
+ WHERE org = %(org)s AND env = %(env)s ORDER BY name
40
+ """
41
+
42
+
43
+ SEALED = "SELECT name, sealed FROM org_secrets WHERE org = %(org)s AND env = %(env)s ORDER BY name"
44
+
45
+
46
+ WRITE_ONE = """
47
+ INSERT INTO org_secrets (org, env, name, sealed, set_by)
48
+ VALUES (%(org)s, %(env)s, %(name)s, %(sealed)s, %(set_by)s)
49
+ ON CONFLICT (org, env, name) DO UPDATE SET
50
+ sealed = excluded.sealed, set_by = excluded.set_by, set_at = now()
51
+ """
52
+
53
+
54
+ DROP_ONE = """
55
+ DELETE FROM org_secrets WHERE org = %(org)s AND env = %(env)s AND name = %(name)s RETURNING name
56
+ """
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ListedSecret:
61
+ """A secret as the org's list shows it: its name, who set it and when; never its value."""
62
+
63
+ name: str
64
+ set_by: str
65
+ set_at: datetime
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Secret:
70
+ """One secret written: the world it is for, its name and its value."""
71
+
72
+ env: Env
73
+ name: str
74
+ value: str
75
+
76
+ def __post_init__(self) -> None:
77
+ if not A_NAME.match(self.name):
78
+ raise DeclarationRefused(NOT_A_NAME.format(name=self.name))
79
+ if self.name.startswith(RESERVED):
80
+ raise DeclarationRefused(IS_THE_BOXS.format(name=self.name))
81
+ if len(self.value.encode()) > LONGEST_VALUE:
82
+ raise DeclarationRefused(TOO_LONG.format(limit=LONGEST_VALUE >> 10))
83
+
84
+
85
+ async def put_secret(
86
+ pool: Pool, vault: MultiFernet, org: str, secret: Secret, *, set_by: str
87
+ ) -> None:
88
+ """Keep the org's secret, sealed, replacing the value it had."""
89
+ values = {
90
+ "org": org,
91
+ "env": secret.env,
92
+ "name": secret.name,
93
+ "sealed": sealed(vault, secret.value),
94
+ "set_by": set_by,
95
+ }
96
+ async with pool.connection() as connection:
97
+ await connection.execute(WRITE_ONE, values)
98
+
99
+
100
+ async def secrets_of(pool: Pool, org: str, env: Env) -> list[ListedSecret]:
101
+ """The org's secrets in the world, by name: who set each and when, never a value."""
102
+ async with pool.connection() as connection:
103
+ rows = await (await connection.execute(SECRETS, {"org": org, "env": env})).fetchall()
104
+ return [
105
+ ListedSecret(name=row["name"], set_by=row["set_by"], set_at=row["set_at"]) for row in rows
106
+ ]
107
+
108
+
109
+ # A value no listed vault key opens is left out: the process starts without it and says so itself.
110
+ async def environment_of(pool: Pool, vault: MultiFernet, org: str, env: Env) -> dict[str, str]:
111
+ """The org's secrets in the world, opened, by name: what a hosted app is started with."""
112
+ async with pool.connection() as connection:
113
+ rows = await (await connection.execute(SEALED, {"org": org, "env": env})).fetchall()
114
+ values = {row["name"]: opened(vault, row["sealed"]) for row in rows}
115
+ return {name: value for name, value in values.items() if isinstance(value, str)}
116
+
117
+
118
+ async def drop_secret(pool: Pool, org: str, env: Env, name: str) -> None:
119
+ """Forget one of the org's secrets; NotFound for a name nobody set."""
120
+ async with pool.connection() as connection:
121
+ dropped = await connection.execute(DROP_ONE, {"org": org, "env": env, "name": name})
122
+ if await dropped.fetchone() is None:
123
+ raise NotFound(NOT_SET.format(name=name, env=env))
@@ -0,0 +1,78 @@
1
+ """Orgs: their names, the quotas they are born with, and whether their calls are judged."""
2
+
3
+ import secrets
4
+
5
+ from psycopg.rows import DictRow
6
+
7
+ from pinecall.domain.errors import Conflict
8
+ from pinecall.domain.org import Org
9
+ from pinecall.postgres.pool import Pool
10
+ from pinecall.tenancy.admission import give_first_quotas
11
+
12
+ CREATE = """
13
+ INSERT INTO orgs (id, slug, name) VALUES (%(id)s, %(slug)s, %(name)s)
14
+ ON CONFLICT (slug) DO NOTHING RETURNING id
15
+ """
16
+ LISTED = "SELECT id, slug, name FROM orgs ORDER BY created_at, id"
17
+ FIND = "SELECT id, slug, name FROM orgs WHERE id = %(named)s OR slug = %(named)s LIMIT 1"
18
+ # The quotas, keys and secrets of the org go with it by the foreign keys' cascade.
19
+ REMOVE = "DELETE FROM orgs WHERE id = %(org)s RETURNING id"
20
+ # A column nobody set is on: judging is what an org turns off, never what it turns on.
21
+ JUDGED = "SELECT judging IS NOT FALSE AS judged FROM orgs WHERE id = %(org)s"
22
+ SET_JUDGING = "UPDATE orgs SET judging = %(on)s WHERE id = %(org)s"
23
+
24
+ # An id is minted, never the slug, so a slug can be renamed without touching another row.
25
+ ID_PREFIX = "org_"
26
+ ID_BYTES = 6
27
+
28
+ SLUG_TAKEN = "{slug} is taken: pick another name for the org"
29
+
30
+
31
+ async def create(pool: Pool, slug: str, name: str, *, already: int = 0) -> Org:
32
+ """Make an org, with the limits the box's admission gives it in each world."""
33
+ org = Org(id=f"{ID_PREFIX}{secrets.token_hex(ID_BYTES)}", slug=slug, name=name)
34
+ async with pool.connection() as connection, connection.transaction():
35
+ row = await (
36
+ await connection.execute(CREATE, {"id": org.id, "slug": slug, "name": name})
37
+ ).fetchone()
38
+ if row is None:
39
+ raise Conflict(SLUG_TAKEN.format(slug=slug))
40
+ await give_first_quotas(connection, org.id, already=already)
41
+ return org
42
+
43
+
44
+ async def listed(pool: Pool) -> list[Org]:
45
+ """Every org, oldest first."""
46
+ async with pool.connection() as connection:
47
+ rows = await (await connection.execute(LISTED)).fetchall()
48
+ return [_org(row) for row in rows]
49
+
50
+
51
+ async def find(pool: Pool, named: str) -> Org | None:
52
+ """The org by its id or its slug."""
53
+ async with pool.connection() as connection:
54
+ row = await (await connection.execute(FIND, {"named": named})).fetchone()
55
+ return None if row is None else _org(row)
56
+
57
+
58
+ async def remove(pool: Pool, org: str) -> bool:
59
+ """Forget the org and everything it holds; whether there was one."""
60
+ async with pool.connection() as connection:
61
+ return await (await connection.execute(REMOVE, {"org": org})).fetchone() is not None
62
+
63
+
64
+ async def judged(pool: Pool, org: str) -> bool:
65
+ """Whether the org's calls are judged at hang-up."""
66
+ async with pool.connection() as connection:
67
+ row = await (await connection.execute(JUDGED, {"org": org})).fetchone()
68
+ return row is None or bool(row["judged"])
69
+
70
+
71
+ async def set_judging(pool: Pool, org: str, *, on: bool) -> None:
72
+ """Turn the org's judging on or off."""
73
+ async with pool.connection() as connection:
74
+ await connection.execute(SET_JUDGING, {"org": org, "on": on})
75
+
76
+
77
+ def _org(row: DictRow) -> Org:
78
+ return Org(id=row["id"], slug=row["slug"], name=row["name"])