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.
- protonlite-0.1.0/LICENSE +21 -0
- protonlite-0.1.0/PKG-INFO +193 -0
- protonlite-0.1.0/README.md +169 -0
- protonlite-0.1.0/protonlite/__init__.py +34 -0
- protonlite-0.1.0/protonlite/__main__.py +3 -0
- protonlite-0.1.0/protonlite/_modulus_key.py +18 -0
- protonlite-0.1.0/protonlite/accounts.py +133 -0
- protonlite-0.1.0/protonlite/api.py +415 -0
- protonlite-0.1.0/protonlite/calendar.py +495 -0
- protonlite-0.1.0/protonlite/cli.py +229 -0
- protonlite-0.1.0/protonlite/client.py +1301 -0
- protonlite-0.1.0/protonlite/contacts.py +194 -0
- protonlite-0.1.0/protonlite/crypto.py +180 -0
- protonlite-0.1.0/protonlite/drive.py +567 -0
- protonlite-0.1.0/protonlite/errors.py +56 -0
- protonlite-0.1.0/protonlite/htmltext.py +117 -0
- protonlite-0.1.0/protonlite/send.py +213 -0
- protonlite-0.1.0/protonlite/srp.py +244 -0
- protonlite-0.1.0/protonlite.egg-info/PKG-INFO +193 -0
- protonlite-0.1.0/protonlite.egg-info/SOURCES.txt +34 -0
- protonlite-0.1.0/protonlite.egg-info/dependency_links.txt +1 -0
- protonlite-0.1.0/protonlite.egg-info/entry_points.txt +3 -0
- protonlite-0.1.0/protonlite.egg-info/requires.txt +6 -0
- protonlite-0.1.0/protonlite.egg-info/top_level.txt +2 -0
- protonlite-0.1.0/protonlite_mcp/__init__.py +1 -0
- protonlite-0.1.0/protonlite_mcp/__main__.py +3 -0
- protonlite-0.1.0/protonlite_mcp/server.py +86 -0
- protonlite-0.1.0/protonlite_mcp/tools.py +125 -0
- protonlite-0.1.0/pyproject.toml +41 -0
- protonlite-0.1.0/setup.cfg +4 -0
- protonlite-0.1.0/tests/test_accounts.py +132 -0
- protonlite-0.1.0/tests/test_htmltext.py +105 -0
- protonlite-0.1.0/tests/test_mcp_tools.py +140 -0
- protonlite-0.1.0/tests/test_send.py +245 -0
- protonlite-0.1.0/tests/test_sharelink_crypto.py +76 -0
- protonlite-0.1.0/tests/test_srp.py +183 -0
protonlite-0.1.0/LICENSE
ADDED
|
@@ -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,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}")
|