protonlite 0.1.0__tar.gz

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 (36) hide show
  1. protonlite-0.1.0/LICENSE +21 -0
  2. protonlite-0.1.0/PKG-INFO +193 -0
  3. protonlite-0.1.0/README.md +169 -0
  4. protonlite-0.1.0/protonlite/__init__.py +34 -0
  5. protonlite-0.1.0/protonlite/__main__.py +3 -0
  6. protonlite-0.1.0/protonlite/_modulus_key.py +18 -0
  7. protonlite-0.1.0/protonlite/accounts.py +133 -0
  8. protonlite-0.1.0/protonlite/api.py +415 -0
  9. protonlite-0.1.0/protonlite/calendar.py +495 -0
  10. protonlite-0.1.0/protonlite/cli.py +229 -0
  11. protonlite-0.1.0/protonlite/client.py +1301 -0
  12. protonlite-0.1.0/protonlite/contacts.py +194 -0
  13. protonlite-0.1.0/protonlite/crypto.py +180 -0
  14. protonlite-0.1.0/protonlite/drive.py +567 -0
  15. protonlite-0.1.0/protonlite/errors.py +56 -0
  16. protonlite-0.1.0/protonlite/htmltext.py +117 -0
  17. protonlite-0.1.0/protonlite/send.py +213 -0
  18. protonlite-0.1.0/protonlite/srp.py +244 -0
  19. protonlite-0.1.0/protonlite.egg-info/PKG-INFO +193 -0
  20. protonlite-0.1.0/protonlite.egg-info/SOURCES.txt +34 -0
  21. protonlite-0.1.0/protonlite.egg-info/dependency_links.txt +1 -0
  22. protonlite-0.1.0/protonlite.egg-info/entry_points.txt +3 -0
  23. protonlite-0.1.0/protonlite.egg-info/requires.txt +6 -0
  24. protonlite-0.1.0/protonlite.egg-info/top_level.txt +2 -0
  25. protonlite-0.1.0/protonlite_mcp/__init__.py +1 -0
  26. protonlite-0.1.0/protonlite_mcp/__main__.py +3 -0
  27. protonlite-0.1.0/protonlite_mcp/server.py +86 -0
  28. protonlite-0.1.0/protonlite_mcp/tools.py +125 -0
  29. protonlite-0.1.0/pyproject.toml +41 -0
  30. protonlite-0.1.0/setup.cfg +4 -0
  31. protonlite-0.1.0/tests/test_accounts.py +132 -0
  32. protonlite-0.1.0/tests/test_htmltext.py +105 -0
  33. protonlite-0.1.0/tests/test_mcp_tools.py +140 -0
  34. protonlite-0.1.0/tests/test_send.py +245 -0
  35. protonlite-0.1.0/tests/test_sharelink_crypto.py +76 -0
  36. protonlite-0.1.0/tests/test_srp.py +183 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Wilamo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: protonlite
3
+ Version: 0.1.0
4
+ Summary: A small, unofficial Python client for Proton Mail, Calendar, Contacts and Drive -- no Bridge, no paid plan
5
+ Author: William Wilamo
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Maytrella007/protonlite
8
+ Project-URL: Issues, https://github.com/Maytrella007/protonlite/issues
9
+ Keywords: proton,protonmail,proton-drive,proton-calendar,email,openpgp
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Communications :: Email
14
+ Classifier: Topic :: Security :: Cryptography
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: PGPy13<0.7,>=0.6.1rc1
19
+ Requires-Dist: bcrypt<6,>=4.0
20
+ Requires-Dist: requests<3,>=2.28
21
+ Provides-Extra: mcp
22
+ Requires-Dist: mcp<2,>=1.2; extra == "mcp"
23
+ Dynamic: license-file
24
+
25
+ # protonlite
26
+
27
+ A small, dependency-light Python client for **Proton Mail, Calendar, Contacts and Drive** --
28
+ no Proton Mail Bridge, no paid plan.
29
+
30
+ Proton has no IMAP or SMTP. The official way in is Proton Mail Bridge, which needs a paid
31
+ plan. `protonlite` talks to Proton's web API directly, so it works on a free account, and the
32
+ end-to-end crypto is ported from **Proton's own MIT-licensed Go libraries**
33
+ ([go-srp](https://github.com/ProtonMail/go-srp),
34
+ [go-proton-api](https://github.com/ProtonMail/go-proton-api)). It stays MIT-licensed and
35
+ dependency-light: just OpenPGP (`PGPy13`, a maintained fork of PGPy), `bcrypt` and `requests`. No CAPTCHA solver, no
36
+ computer-vision stack -- a person completes Proton's verification page the same way Proton's
37
+ own apps do.
38
+
39
+ > **Unofficial.** This project is not affiliated with, endorsed by, or supported by Proton AG.
40
+ > "Proton", "Proton Mail", "Proton Calendar" and "Proton Drive" are trademarks of Proton AG.
41
+ > It talks to a private, undocumented API that can change at any time, and using it may be
42
+ > subject to Proton's terms of service. Use it at your own risk, on accounts you own.
43
+
44
+ ## What it does
45
+
46
+ - **Mail** -- read the inbox and any folder, decrypt messages (HTML flattened to text),
47
+ search, send (plain or HTML, attachments, replies in-thread, scheduled), save drafts,
48
+ mark read/unread, trash/delete, labels and folders, empty trash, storage usage.
49
+ - **Calendar** -- read the agenda, create / edit / cancel events, send invitations, RSVP.
50
+ - **Contacts** -- read, create, edit and delete contacts (signed/encrypted vCards).
51
+ - **Drive** -- list, download, upload (encrypted), move, rename, trash, and create public
52
+ share links.
53
+
54
+ All of it is end-to-end: keys are unlocked locally with the account password and messages,
55
+ events, contacts and files are decrypted on your machine.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install protonlite
61
+ ```
62
+
63
+ Requires Python 3.10+.
64
+
65
+ ## Quickstart
66
+
67
+ ```python
68
+ import os
69
+ import datetime as dt
70
+
71
+ from protonlite import ProtonMail
72
+
73
+ # --- Signing in -------------------------------------------------------------
74
+ # The recommended way is the CLI `protonlite login`, which reads the password
75
+ # with getpass so it never lives in a script. For automation, read it from the
76
+ # environment rather than hard-coding it. Proton usually asks a human to
77
+ # complete a verification page; login raises HumanVerificationRequired with
78
+ # that URL -- open it, then call again passing verification_token (see
79
+ # "Signing in" below). Sign in each account once; every login saves its own
80
+ # session file.
81
+ ProtonMail.login(
82
+ "you@proton.me",
83
+ password=os.environ["PROTON_PASSWORD"].encode(),
84
+ session_path="you@proton.me.json",
85
+ )
86
+
87
+ # --- More than one account --------------------------------------------------
88
+ # Reopen each saved session independently -- no password needed. The two
89
+ # clients are fully independent mailboxes.
90
+ client = ProtonMail.from_session(session_path="you@proton.me.json") # personal
91
+ client1 = ProtonMail.from_session(session_path="work@proton.me.json") # work
92
+ # (after a CLI `protonlite login`, ProtonMail.for_account("work") finds it by name)
93
+ print(client.addresses()[0]["Email"], "/", client1.addresses()[0]["Email"])
94
+
95
+ # --- Mail -------------------------------------------------------------------
96
+ for msg in client.list_messages(unread_only=True):
97
+ print(msg.time, msg.sender.address, msg.subject)
98
+ full = client.read_message(msg.id)
99
+ print(full.text) # HTML flattened to prose
100
+ client.send(to=["friend@example.com"], subject="Hi", body="Hello")
101
+ client1.send(to=["teammate@work.com"], subject="Hi from work", body="Hello")
102
+
103
+ # --- Calendar ---------------------------------------------------------------
104
+ cal_id = client.list_calendars()[0]["ID"]
105
+ begins = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=1)
106
+ event = client.create_event(cal_id, summary="Demo",
107
+ start=begins, end=begins + dt.timedelta(hours=1))
108
+ for ev in client.list_events(cal_id, start=int(begins.timestamp()) - 3600,
109
+ end=int(begins.timestamp()) + 86400):
110
+ print(dt.datetime.fromtimestamp(ev.start, dt.timezone.utc), ev.title)
111
+ client.delete_event(cal_id, event["id"])
112
+
113
+ # --- Contacts ---------------------------------------------------------------
114
+ cid = client.create_contact(name="Ada Lovelace", emails=["ada@example.com"])
115
+ for row in client.list_contact_emails():
116
+ print(row["name"], row["email"])
117
+ client.delete_contacts([cid])
118
+
119
+ # --- Drive ------------------------------------------------------------------
120
+ drive = client.drive()
121
+ with open("report.pdf", "rb") as fh:
122
+ drive.upload("", "report.pdf", fh.read(), "application/pdf")
123
+ for entry in drive.list_folder(""):
124
+ print(entry["name"], entry["type"])
125
+ link = drive.share_link("report.pdf")
126
+ print(link["url"]) # https://drive.proton.me/urls/...#password
127
+ ```
128
+
129
+ ### Command line
130
+
131
+ A small CLI covers the mail basics:
132
+
133
+ ```bash
134
+ protonlite login --user you@proton.me
135
+ protonlite unread
136
+ protonlite read <message-id>
137
+ protonlite send --to friend@example.com --subject "Hi" --body "Hello"
138
+ ```
139
+
140
+ ## Signing in
141
+
142
+ Proton logins from a server almost always trigger a human-verification page (hCaptcha). The
143
+ login flow surfaces that URL so a person can complete it in a browser; `protonlite` never
144
+ solves it and never asks for a password over any channel but your own code. The account
145
+ password is used only to derive keys locally -- it is never stored; the saved session holds
146
+ OAuth-style tokens and the derived key passphrase, nothing more. Two-factor (TOTP) is
147
+ supported.
148
+
149
+ ## MCP server
150
+
151
+ `protonlite` ships an optional [MCP](https://modelcontextprotocol.io) server so any MCP client
152
+ (Cursor, Claude Code, Claude Desktop) can use Proton through it. Sign in once with the CLI,
153
+ then run the server pointed at that session:
154
+
155
+ ```bash
156
+ pip install "protonlite[mcp]"
157
+ protonlite login --user you@proton.me # one-time; saves a session file
158
+ PROTONLITE_SESSION=~/you@proton.me.json protonlite-mcp
159
+ ```
160
+
161
+ Wire it into an MCP client (e.g. Claude Desktop / Cursor `mcpServers` config):
162
+
163
+ ```json
164
+ {
165
+ "mcpServers": {
166
+ "proton": {
167
+ "command": "protonlite-mcp",
168
+ "env": { "PROTONLITE_SESSION": "/home/you/you@proton.me.json" }
169
+ }
170
+ }
171
+ }
172
+ ```
173
+
174
+ The server never takes a password -- it reuses the saved session. It exposes `list_messages`,
175
+ `read_message`, `search_messages`, `send_message`, `calendar`, `contacts`, `drive_list` and
176
+ `drive_download`, and returns other people's content wrapped as untrusted data.
177
+
178
+ ## Status and limits
179
+
180
+ The write paths (Drive upload, calendar event creation, share links) were reverse-engineered
181
+ from Proton's Go and web clients and verified against live accounts, but the API is
182
+ undocumented and may change. Calendar key generation shells out to `gpg` (Proton requires an
183
+ Ed25519 key that `PGPy` cannot generate). Bug reports and PRs welcome.
184
+
185
+ ## Related
186
+
187
+ `protonlite` is also the engine behind [hermes-proton](https://github.com/Maytrella007/hermes-proton), a Proton plugin for the
188
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent) that adds a recipient allowlist
189
+ and a human-approval gate on every send.
190
+
191
+ ## License
192
+
193
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,169 @@
1
+ # protonlite
2
+
3
+ A small, dependency-light Python client for **Proton Mail, Calendar, Contacts and Drive** --
4
+ no Proton Mail Bridge, no paid plan.
5
+
6
+ Proton has no IMAP or SMTP. The official way in is Proton Mail Bridge, which needs a paid
7
+ plan. `protonlite` talks to Proton's web API directly, so it works on a free account, and the
8
+ end-to-end crypto is ported from **Proton's own MIT-licensed Go libraries**
9
+ ([go-srp](https://github.com/ProtonMail/go-srp),
10
+ [go-proton-api](https://github.com/ProtonMail/go-proton-api)). It stays MIT-licensed and
11
+ dependency-light: just OpenPGP (`PGPy13`, a maintained fork of PGPy), `bcrypt` and `requests`. No CAPTCHA solver, no
12
+ computer-vision stack -- a person completes Proton's verification page the same way Proton's
13
+ own apps do.
14
+
15
+ > **Unofficial.** This project is not affiliated with, endorsed by, or supported by Proton AG.
16
+ > "Proton", "Proton Mail", "Proton Calendar" and "Proton Drive" are trademarks of Proton AG.
17
+ > It talks to a private, undocumented API that can change at any time, and using it may be
18
+ > subject to Proton's terms of service. Use it at your own risk, on accounts you own.
19
+
20
+ ## What it does
21
+
22
+ - **Mail** -- read the inbox and any folder, decrypt messages (HTML flattened to text),
23
+ search, send (plain or HTML, attachments, replies in-thread, scheduled), save drafts,
24
+ mark read/unread, trash/delete, labels and folders, empty trash, storage usage.
25
+ - **Calendar** -- read the agenda, create / edit / cancel events, send invitations, RSVP.
26
+ - **Contacts** -- read, create, edit and delete contacts (signed/encrypted vCards).
27
+ - **Drive** -- list, download, upload (encrypted), move, rename, trash, and create public
28
+ share links.
29
+
30
+ All of it is end-to-end: keys are unlocked locally with the account password and messages,
31
+ events, contacts and files are decrypted on your machine.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install protonlite
37
+ ```
38
+
39
+ Requires Python 3.10+.
40
+
41
+ ## Quickstart
42
+
43
+ ```python
44
+ import os
45
+ import datetime as dt
46
+
47
+ from protonlite import ProtonMail
48
+
49
+ # --- Signing in -------------------------------------------------------------
50
+ # The recommended way is the CLI `protonlite login`, which reads the password
51
+ # with getpass so it never lives in a script. For automation, read it from the
52
+ # environment rather than hard-coding it. Proton usually asks a human to
53
+ # complete a verification page; login raises HumanVerificationRequired with
54
+ # that URL -- open it, then call again passing verification_token (see
55
+ # "Signing in" below). Sign in each account once; every login saves its own
56
+ # session file.
57
+ ProtonMail.login(
58
+ "you@proton.me",
59
+ password=os.environ["PROTON_PASSWORD"].encode(),
60
+ session_path="you@proton.me.json",
61
+ )
62
+
63
+ # --- More than one account --------------------------------------------------
64
+ # Reopen each saved session independently -- no password needed. The two
65
+ # clients are fully independent mailboxes.
66
+ client = ProtonMail.from_session(session_path="you@proton.me.json") # personal
67
+ client1 = ProtonMail.from_session(session_path="work@proton.me.json") # work
68
+ # (after a CLI `protonlite login`, ProtonMail.for_account("work") finds it by name)
69
+ print(client.addresses()[0]["Email"], "/", client1.addresses()[0]["Email"])
70
+
71
+ # --- Mail -------------------------------------------------------------------
72
+ for msg in client.list_messages(unread_only=True):
73
+ print(msg.time, msg.sender.address, msg.subject)
74
+ full = client.read_message(msg.id)
75
+ print(full.text) # HTML flattened to prose
76
+ client.send(to=["friend@example.com"], subject="Hi", body="Hello")
77
+ client1.send(to=["teammate@work.com"], subject="Hi from work", body="Hello")
78
+
79
+ # --- Calendar ---------------------------------------------------------------
80
+ cal_id = client.list_calendars()[0]["ID"]
81
+ begins = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=1)
82
+ event = client.create_event(cal_id, summary="Demo",
83
+ start=begins, end=begins + dt.timedelta(hours=1))
84
+ for ev in client.list_events(cal_id, start=int(begins.timestamp()) - 3600,
85
+ end=int(begins.timestamp()) + 86400):
86
+ print(dt.datetime.fromtimestamp(ev.start, dt.timezone.utc), ev.title)
87
+ client.delete_event(cal_id, event["id"])
88
+
89
+ # --- Contacts ---------------------------------------------------------------
90
+ cid = client.create_contact(name="Ada Lovelace", emails=["ada@example.com"])
91
+ for row in client.list_contact_emails():
92
+ print(row["name"], row["email"])
93
+ client.delete_contacts([cid])
94
+
95
+ # --- Drive ------------------------------------------------------------------
96
+ drive = client.drive()
97
+ with open("report.pdf", "rb") as fh:
98
+ drive.upload("", "report.pdf", fh.read(), "application/pdf")
99
+ for entry in drive.list_folder(""):
100
+ print(entry["name"], entry["type"])
101
+ link = drive.share_link("report.pdf")
102
+ print(link["url"]) # https://drive.proton.me/urls/...#password
103
+ ```
104
+
105
+ ### Command line
106
+
107
+ A small CLI covers the mail basics:
108
+
109
+ ```bash
110
+ protonlite login --user you@proton.me
111
+ protonlite unread
112
+ protonlite read <message-id>
113
+ protonlite send --to friend@example.com --subject "Hi" --body "Hello"
114
+ ```
115
+
116
+ ## Signing in
117
+
118
+ Proton logins from a server almost always trigger a human-verification page (hCaptcha). The
119
+ login flow surfaces that URL so a person can complete it in a browser; `protonlite` never
120
+ solves it and never asks for a password over any channel but your own code. The account
121
+ password is used only to derive keys locally -- it is never stored; the saved session holds
122
+ OAuth-style tokens and the derived key passphrase, nothing more. Two-factor (TOTP) is
123
+ supported.
124
+
125
+ ## MCP server
126
+
127
+ `protonlite` ships an optional [MCP](https://modelcontextprotocol.io) server so any MCP client
128
+ (Cursor, Claude Code, Claude Desktop) can use Proton through it. Sign in once with the CLI,
129
+ then run the server pointed at that session:
130
+
131
+ ```bash
132
+ pip install "protonlite[mcp]"
133
+ protonlite login --user you@proton.me # one-time; saves a session file
134
+ PROTONLITE_SESSION=~/you@proton.me.json protonlite-mcp
135
+ ```
136
+
137
+ Wire it into an MCP client (e.g. Claude Desktop / Cursor `mcpServers` config):
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "proton": {
143
+ "command": "protonlite-mcp",
144
+ "env": { "PROTONLITE_SESSION": "/home/you/you@proton.me.json" }
145
+ }
146
+ }
147
+ }
148
+ ```
149
+
150
+ The server never takes a password -- it reuses the saved session. It exposes `list_messages`,
151
+ `read_message`, `search_messages`, `send_message`, `calendar`, `contacts`, `drive_list` and
152
+ `drive_download`, and returns other people's content wrapped as untrusted data.
153
+
154
+ ## Status and limits
155
+
156
+ The write paths (Drive upload, calendar event creation, share links) were reverse-engineered
157
+ from Proton's Go and web clients and verified against live accounts, but the API is
158
+ undocumented and may change. Calendar key generation shells out to `gpg` (Proton requires an
159
+ Ed25519 key that `PGPy` cannot generate). Bug reports and PRs welcome.
160
+
161
+ ## Related
162
+
163
+ `protonlite` is also the engine behind [hermes-proton](https://github.com/Maytrella007/hermes-proton), a Proton plugin for the
164
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent) that adds a recipient allowlist
165
+ and a human-approval gate on every send.
166
+
167
+ ## License
168
+
169
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,34 @@
1
+ """A small, unofficial Python client for Proton Mail, Calendar, Contacts and Drive.
2
+
3
+ The protocol work is a port of Proton's own MIT Go libraries; see
4
+ THIRD-PARTY-NOTICES.md. Nothing here is derived from GPL sources.
5
+
6
+ This project is not affiliated with Proton AG. It talks to a private, undocumented
7
+ API and is provided as-is, for use on accounts you own.
8
+ """
9
+
10
+ from .client import Contact, Message, MessageSummary, ProtonMail
11
+ from .errors import (
12
+ APIError,
13
+ AuthError,
14
+ HumanVerificationRequired,
15
+ ProtonError,
16
+ SessionExpired,
17
+ TwoFactorRequired,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "ProtonMail",
24
+ "Message",
25
+ "MessageSummary",
26
+ "Contact",
27
+ "APIError",
28
+ "AuthError",
29
+ "HumanVerificationRequired",
30
+ "ProtonError",
31
+ "SessionExpired",
32
+ "TwoFactorRequired",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ """Proton's SRP modulus signing key.
2
+
3
+ Verbatim from ProtonMail/go-srp (MIT, Copyright (c) 2019 Proton Technologies AG).
4
+ See THIRD-PARTY-NOTICES.md.
5
+ """
6
+
7
+ MODULUS_SIGNING_KEY = '''-----BEGIN PGP PUBLIC KEY BLOCK-----
8
+
9
+ xjMEXAHLgxYJKwYBBAHaRw8BAQdAFurWXXwjTemqjD7CXjXVyKf0of7n9Ctm
10
+ L8v9enkzggHNEnByb3RvbkBzcnAubW9kdWx1c8J3BBAWCgApBQJcAcuDBgsJ
11
+ BwgDAgkQNQWFxOlRjyYEFQgKAgMWAgECGQECGwMCHgEAAPGRAP9sauJsW12U
12
+ MnTQUZpsbJb53d0Wv55mZIIiJL2XulpWPQD/V6NglBd96lZKBmInSXX/kXat
13
+ Sv+y0io+LR8i2+jV+AbOOARcAcuDEgorBgEEAZdVAQUBAQdAeJHUz1c9+KfE
14
+ kSIgcBRE3WuXC4oj5a2/U3oASExGDW4DAQgHwmEEGBYIABMFAlwBy4MJEDUF
15
+ hcTpUY8mAhsMAAD/XQD8DxNI6E78meodQI+wLsrKLeHn32iLvUqJbVDhfWSU
16
+ WO4BAMcm1u02t4VKw++ttECPt+HUgPUq5pqQWe5Q2cW4TMsE
17
+ =Y4Mw
18
+ -----END PGP PUBLIC KEY BLOCK-----'''
@@ -0,0 +1,133 @@
1
+ """Several Proton accounts side by side.
2
+
3
+ One session file per account, in a directory, with the address recorded inside
4
+ each one. Which account a request means is resolved by that address, never
5
+ guessed: with more than one signed in and nothing said, the tools ask instead
6
+ of picking. Mailing the wrong person from the wrong account is not a mistake
7
+ worth being convenient about.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from .errors import ProtonError, SessionExpired
19
+
20
+ #: Where the sessions live, overridable for tests and odd layouts.
21
+ ROOT_ENV = "PROTONLITE_HOME"
22
+
23
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
24
+
25
+
26
+ def default_root() -> Path:
27
+ return Path(os.environ.get(ROOT_ENV) or Path.home() / ".config" / "protonlite")
28
+
29
+
30
+ def _slug(address: str) -> str:
31
+ """A filename for an address. Cosmetic: the address itself lives in the file."""
32
+ return _SAFE.sub("-", address.strip().lower()).strip("-") or "account"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Account:
37
+ address: str
38
+ path: Path
39
+
40
+ @property
41
+ def label(self) -> str:
42
+ return self.address or self.path.stem
43
+
44
+
45
+ class AccountStore:
46
+ """The set of accounts signed in on this machine."""
47
+
48
+ def __init__(self, root: str | os.PathLike[str] | None = None) -> None:
49
+ self.root = Path(root) if root else default_root()
50
+
51
+ # -- layout -----------------------------------------------------------
52
+
53
+ @property
54
+ def sessions_dir(self) -> Path:
55
+ return self.root / "sessions"
56
+
57
+ @property
58
+ def legacy_session(self) -> Path:
59
+ return self.root / "session.json"
60
+
61
+ def path_for(self, address: str) -> Path:
62
+ return self.sessions_dir / f"{_slug(address)}.json"
63
+
64
+ def migrate_legacy(self) -> Account | None:
65
+ """Move a single-account session into the per-account layout, once.
66
+
67
+ Early versions kept one ``session.json``. Rather than carry two code
68
+ paths forever, the file is moved the first time the store is read.
69
+ """
70
+ if not self.legacy_session.exists():
71
+ return None
72
+ try:
73
+ address = json.loads(self.legacy_session.read_text()).get("username", "")
74
+ except (OSError, ValueError):
75
+ address = ""
76
+ target = self.path_for(address or "default")
77
+ target.parent.mkdir(parents=True, exist_ok=True)
78
+ if target.exists(): # a newer session wins; leave the old file alone
79
+ return None
80
+ os.replace(self.legacy_session, target)
81
+ return Account(address=address, path=target)
82
+
83
+ # -- reading ----------------------------------------------------------
84
+
85
+ def list(self) -> list[Account]:
86
+ self.migrate_legacy()
87
+ if not self.sessions_dir.is_dir():
88
+ return []
89
+ accounts: list[Account] = []
90
+ for path in sorted(self.sessions_dir.glob("*.json")):
91
+ try:
92
+ address = json.loads(path.read_text()).get("username", "")
93
+ except (OSError, ValueError):
94
+ continue # a half-written file is not an account
95
+ accounts.append(Account(address=address, path=path))
96
+ return accounts
97
+
98
+ def resolve(self, selector: str = "") -> Account:
99
+ """Find the account a request means, or explain why it cannot.
100
+
101
+ An empty selector is fine with exactly one account signed in and an
102
+ error with several -- that ambiguity is the caller's to settle.
103
+ """
104
+ accounts = self.list()
105
+ if not accounts:
106
+ raise SessionExpired(
107
+ "no Proton account is signed in yet; sign in before reading mail"
108
+ )
109
+
110
+ selector = selector.strip().lower()
111
+ if not selector:
112
+ if len(accounts) == 1:
113
+ return accounts[0]
114
+ names = ", ".join(a.label for a in accounts)
115
+ raise ProtonError(
116
+ f"there are {len(accounts)} accounts signed in ({names}); say which one"
117
+ )
118
+
119
+ exact = [a for a in accounts if a.address.lower() == selector]
120
+ if exact:
121
+ return exact[0]
122
+
123
+ # A local part or any distinctive fragment is enough when it picks out
124
+ # exactly one account; anything vaguer is reported rather than guessed.
125
+ partial = [a for a in accounts if selector in a.address.lower()]
126
+ if len(partial) == 1:
127
+ return partial[0]
128
+ if partial:
129
+ names = ", ".join(a.label for a in partial)
130
+ raise ProtonError(f"{selector!r} matches several accounts ({names}); be exact")
131
+
132
+ names = ", ".join(a.label for a in accounts)
133
+ raise ProtonError(f"no account matches {selector!r}; signed in: {names}")