gsuite-sdk 0.1.0__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.
- gsuite_calendar/__init__.py +13 -0
- gsuite_calendar/calendar_entity.py +31 -0
- gsuite_calendar/client.py +268 -0
- gsuite_calendar/event.py +57 -0
- gsuite_calendar/parser.py +119 -0
- gsuite_calendar/py.typed +0 -0
- gsuite_core/__init__.py +62 -0
- gsuite_core/api_utils.py +167 -0
- gsuite_core/auth/__init__.py +6 -0
- gsuite_core/auth/oauth.py +249 -0
- gsuite_core/auth/scopes.py +84 -0
- gsuite_core/config.py +73 -0
- gsuite_core/exceptions.py +125 -0
- gsuite_core/py.typed +0 -0
- gsuite_core/storage/__init__.py +13 -0
- gsuite_core/storage/base.py +65 -0
- gsuite_core/storage/secretmanager.py +141 -0
- gsuite_core/storage/sqlite.py +79 -0
- gsuite_drive/__init__.py +12 -0
- gsuite_drive/client.py +401 -0
- gsuite_drive/file.py +103 -0
- gsuite_drive/parser.py +66 -0
- gsuite_drive/py.typed +0 -0
- gsuite_gmail/__init__.py +17 -0
- gsuite_gmail/client.py +412 -0
- gsuite_gmail/label.py +56 -0
- gsuite_gmail/message.py +211 -0
- gsuite_gmail/parser.py +155 -0
- gsuite_gmail/py.typed +0 -0
- gsuite_gmail/query.py +227 -0
- gsuite_gmail/thread.py +54 -0
- gsuite_sdk-0.1.0.dist-info/METADATA +384 -0
- gsuite_sdk-0.1.0.dist-info/RECORD +42 -0
- gsuite_sdk-0.1.0.dist-info/WHEEL +5 -0
- gsuite_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- gsuite_sdk-0.1.0.dist-info/top_level.txt +5 -0
- gsuite_sheets/__init__.py +13 -0
- gsuite_sheets/client.py +375 -0
- gsuite_sheets/parser.py +76 -0
- gsuite_sheets/py.typed +0 -0
- gsuite_sheets/spreadsheet.py +97 -0
- gsuite_sheets/worksheet.py +185 -0
gsuite_gmail/client.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Gmail client - high-level interface."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from email.mime.multipart import MIMEMultipart
|
|
6
|
+
from email.mime.text import MIMEText
|
|
7
|
+
|
|
8
|
+
from googleapiclient.discovery import build
|
|
9
|
+
from googleapiclient.errors import HttpError
|
|
10
|
+
|
|
11
|
+
from gsuite_core import GoogleAuth, api_call, api_call_optional
|
|
12
|
+
from gsuite_gmail.label import Label
|
|
13
|
+
from gsuite_gmail.message import Message
|
|
14
|
+
from gsuite_gmail.parser import GmailParser
|
|
15
|
+
from gsuite_gmail.query import Query
|
|
16
|
+
from gsuite_gmail.thread import Thread
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Gmail:
|
|
22
|
+
"""
|
|
23
|
+
High-level Gmail client.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
auth = GoogleAuth()
|
|
27
|
+
auth.authenticate()
|
|
28
|
+
|
|
29
|
+
gmail = Gmail(auth)
|
|
30
|
+
|
|
31
|
+
# Get unread
|
|
32
|
+
for msg in gmail.get_unread():
|
|
33
|
+
print(msg.subject)
|
|
34
|
+
msg.mark_as_read()
|
|
35
|
+
|
|
36
|
+
# Send
|
|
37
|
+
gmail.send(
|
|
38
|
+
to=["user@example.com"],
|
|
39
|
+
subject="Hello",
|
|
40
|
+
body="World",
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, auth: GoogleAuth, user_id: str = "me"):
|
|
45
|
+
"""
|
|
46
|
+
Initialize Gmail client.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
auth: GoogleAuth instance with valid credentials
|
|
50
|
+
user_id: Gmail user ID ("me" for authenticated user)
|
|
51
|
+
"""
|
|
52
|
+
self.auth = auth
|
|
53
|
+
self.user_id = user_id
|
|
54
|
+
self._service = None
|
|
55
|
+
self._labels_cache: dict[str, str] | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def service(self):
|
|
59
|
+
"""Lazy-load Gmail API service."""
|
|
60
|
+
if self._service is None:
|
|
61
|
+
self._service = build("gmail", "v1", credentials=self.auth.credentials)
|
|
62
|
+
return self._service
|
|
63
|
+
|
|
64
|
+
# ========== Message retrieval ==========
|
|
65
|
+
|
|
66
|
+
def get_messages(
|
|
67
|
+
self,
|
|
68
|
+
query: str | Query | None = None,
|
|
69
|
+
labels: list[str] | None = None,
|
|
70
|
+
max_results: int = 25,
|
|
71
|
+
include_body: bool = True,
|
|
72
|
+
) -> list[Message]:
|
|
73
|
+
"""
|
|
74
|
+
Get messages matching criteria.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
query: Gmail search query (str or Query object)
|
|
78
|
+
labels: Filter by label IDs
|
|
79
|
+
max_results: Maximum messages to return
|
|
80
|
+
include_body: Whether to fetch full message content
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of Message objects
|
|
84
|
+
"""
|
|
85
|
+
request_params = {
|
|
86
|
+
"userId": self.user_id,
|
|
87
|
+
"maxResults": min(max_results, 500),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if query:
|
|
91
|
+
request_params["q"] = str(query)
|
|
92
|
+
if labels:
|
|
93
|
+
request_params["labelIds"] = labels
|
|
94
|
+
|
|
95
|
+
response = self.service.users().messages().list(**request_params).execute()
|
|
96
|
+
|
|
97
|
+
messages = []
|
|
98
|
+
for msg_ref in response.get("messages", []):
|
|
99
|
+
msg = self._get_message_by_id(msg_ref["id"], include_body)
|
|
100
|
+
messages.append(msg)
|
|
101
|
+
|
|
102
|
+
return messages
|
|
103
|
+
|
|
104
|
+
def search(
|
|
105
|
+
self,
|
|
106
|
+
query: str | Query,
|
|
107
|
+
max_results: int = 25,
|
|
108
|
+
) -> list[Message]:
|
|
109
|
+
"""
|
|
110
|
+
Search messages with a query.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
query: Gmail search query
|
|
114
|
+
max_results: Maximum results
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of matching messages
|
|
118
|
+
"""
|
|
119
|
+
return self.get_messages(query=query, max_results=max_results)
|
|
120
|
+
|
|
121
|
+
def get_unread(self, max_results: int = 25) -> list[Message]:
|
|
122
|
+
"""Get unread messages."""
|
|
123
|
+
return self.get_messages(query="is:unread", max_results=max_results)
|
|
124
|
+
|
|
125
|
+
def get_unread_inbox(self, max_results: int = 25) -> list[Message]:
|
|
126
|
+
"""Get unread messages in inbox."""
|
|
127
|
+
return self.get_messages(query="is:unread in:inbox", max_results=max_results)
|
|
128
|
+
|
|
129
|
+
def get_starred(self, max_results: int = 25) -> list[Message]:
|
|
130
|
+
"""Get starred messages."""
|
|
131
|
+
return self.get_messages(query="is:starred", max_results=max_results)
|
|
132
|
+
|
|
133
|
+
def get_important(self, max_results: int = 25) -> list[Message]:
|
|
134
|
+
"""Get important messages."""
|
|
135
|
+
return self.get_messages(query="is:important", max_results=max_results)
|
|
136
|
+
|
|
137
|
+
def get_sent(self, max_results: int = 25) -> list[Message]:
|
|
138
|
+
"""Get sent messages."""
|
|
139
|
+
return self.get_messages(query="in:sent", max_results=max_results)
|
|
140
|
+
|
|
141
|
+
def get_drafts(self, max_results: int = 25) -> list[Message]:
|
|
142
|
+
"""Get draft messages."""
|
|
143
|
+
return self.get_messages(query="in:drafts", max_results=max_results)
|
|
144
|
+
|
|
145
|
+
def get_message(self, message_id: str) -> Message | None:
|
|
146
|
+
"""Get a specific message by ID."""
|
|
147
|
+
return self._get_message_by_id(message_id, include_body=True)
|
|
148
|
+
|
|
149
|
+
def _get_message_by_id(self, message_id: str, include_body: bool = True) -> Message:
|
|
150
|
+
"""Internal: fetch and parse a message."""
|
|
151
|
+
msg_data = (
|
|
152
|
+
self.service.users()
|
|
153
|
+
.messages()
|
|
154
|
+
.get(
|
|
155
|
+
userId=self.user_id,
|
|
156
|
+
id=message_id,
|
|
157
|
+
format="full" if include_body else "metadata",
|
|
158
|
+
)
|
|
159
|
+
.execute()
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return self._parse_message(msg_data, include_body)
|
|
163
|
+
|
|
164
|
+
# ========== Threads ==========
|
|
165
|
+
|
|
166
|
+
def get_thread(self, thread_id: str) -> Thread:
|
|
167
|
+
"""Get a full thread by ID."""
|
|
168
|
+
thread_data = (
|
|
169
|
+
self.service.users()
|
|
170
|
+
.threads()
|
|
171
|
+
.get(
|
|
172
|
+
userId=self.user_id,
|
|
173
|
+
id=thread_id,
|
|
174
|
+
format="full",
|
|
175
|
+
)
|
|
176
|
+
.execute()
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
messages = [
|
|
180
|
+
self._parse_message(msg_data, include_body=True)
|
|
181
|
+
for msg_data in thread_data.get("messages", [])
|
|
182
|
+
]
|
|
183
|
+
|
|
184
|
+
return Thread(
|
|
185
|
+
id=thread_data["id"],
|
|
186
|
+
messages=messages,
|
|
187
|
+
snippet=thread_data.get("snippet", ""),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# ========== Labels ==========
|
|
191
|
+
|
|
192
|
+
def get_labels(self) -> list[Label]:
|
|
193
|
+
"""Get all labels."""
|
|
194
|
+
response = self.service.users().labels().list(userId=self.user_id).execute()
|
|
195
|
+
|
|
196
|
+
labels = []
|
|
197
|
+
for label_data in response.get("labels", []):
|
|
198
|
+
# Fetch full details
|
|
199
|
+
full_label = (
|
|
200
|
+
self.service.users()
|
|
201
|
+
.labels()
|
|
202
|
+
.get(
|
|
203
|
+
userId=self.user_id,
|
|
204
|
+
id=label_data["id"],
|
|
205
|
+
)
|
|
206
|
+
.execute()
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
labels.append(GmailParser.parse_label(full_label))
|
|
210
|
+
|
|
211
|
+
return labels
|
|
212
|
+
|
|
213
|
+
def _get_label_id(self, label_name: str) -> str | None:
|
|
214
|
+
"""Get label ID by name."""
|
|
215
|
+
if self._labels_cache is None:
|
|
216
|
+
labels = self.get_labels()
|
|
217
|
+
self._labels_cache = {l.name: l.id for l in labels}
|
|
218
|
+
|
|
219
|
+
# Check if it's already an ID
|
|
220
|
+
if label_name in self._labels_cache.values():
|
|
221
|
+
return label_name
|
|
222
|
+
|
|
223
|
+
return self._labels_cache.get(label_name)
|
|
224
|
+
|
|
225
|
+
# ========== Send ==========
|
|
226
|
+
|
|
227
|
+
def get_signature(self, send_as_email: str | None = None) -> str | None:
|
|
228
|
+
"""
|
|
229
|
+
Get the account's email signature.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
send_as_email: Specific send-as email (default: primary)
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
HTML signature or None
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
email = send_as_email or self.email
|
|
239
|
+
settings = (
|
|
240
|
+
self.service.users()
|
|
241
|
+
.settings()
|
|
242
|
+
.sendAs()
|
|
243
|
+
.get(
|
|
244
|
+
userId=self.user_id,
|
|
245
|
+
sendAsEmail=email,
|
|
246
|
+
)
|
|
247
|
+
.execute()
|
|
248
|
+
)
|
|
249
|
+
return settings.get("signature")
|
|
250
|
+
except HttpError as e:
|
|
251
|
+
logger.debug(f"Could not get signature for {send_as_email}: {e}")
|
|
252
|
+
return None
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.warning(f"Unexpected error getting signature: {e}")
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def send(
|
|
258
|
+
self,
|
|
259
|
+
to: list[str],
|
|
260
|
+
subject: str,
|
|
261
|
+
body: str,
|
|
262
|
+
cc: list[str] | None = None,
|
|
263
|
+
bcc: list[str] | None = None,
|
|
264
|
+
html: bool = False,
|
|
265
|
+
signature: bool = False,
|
|
266
|
+
reply_to: str | None = None,
|
|
267
|
+
thread_id: str | None = None,
|
|
268
|
+
attachments: list[str] | None = None,
|
|
269
|
+
) -> Message:
|
|
270
|
+
"""
|
|
271
|
+
Send an email.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
to: Recipient email addresses
|
|
275
|
+
subject: Email subject
|
|
276
|
+
body: Email body
|
|
277
|
+
cc: CC recipients
|
|
278
|
+
bcc: BCC recipients
|
|
279
|
+
html: Whether body is HTML
|
|
280
|
+
signature: Include account signature (appends to body)
|
|
281
|
+
reply_to: Message ID to reply to
|
|
282
|
+
thread_id: Thread ID (for threading)
|
|
283
|
+
attachments: List of file paths to attach
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
The sent message
|
|
287
|
+
"""
|
|
288
|
+
# Append signature if requested
|
|
289
|
+
final_body = body
|
|
290
|
+
if signature:
|
|
291
|
+
sig = self.get_signature()
|
|
292
|
+
if sig:
|
|
293
|
+
if html:
|
|
294
|
+
final_body = f"{body}<br><br>{sig}"
|
|
295
|
+
else:
|
|
296
|
+
# Strip HTML from signature for plain text
|
|
297
|
+
import re
|
|
298
|
+
|
|
299
|
+
plain_sig = re.sub("<[^<]+?>", "", sig)
|
|
300
|
+
final_body = f"{body}\n\n{plain_sig}"
|
|
301
|
+
|
|
302
|
+
# Build message
|
|
303
|
+
if html:
|
|
304
|
+
message = MIMEMultipart("alternative")
|
|
305
|
+
message.attach(MIMEText(final_body, "html"))
|
|
306
|
+
else:
|
|
307
|
+
message = MIMEText(final_body)
|
|
308
|
+
|
|
309
|
+
message["To"] = ", ".join(to)
|
|
310
|
+
message["Subject"] = subject
|
|
311
|
+
|
|
312
|
+
if cc:
|
|
313
|
+
message["Cc"] = ", ".join(cc)
|
|
314
|
+
if bcc:
|
|
315
|
+
message["Bcc"] = ", ".join(bcc)
|
|
316
|
+
|
|
317
|
+
# Encode
|
|
318
|
+
raw = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
|
|
319
|
+
body_dict = {"raw": raw}
|
|
320
|
+
|
|
321
|
+
if thread_id:
|
|
322
|
+
body_dict["threadId"] = thread_id
|
|
323
|
+
|
|
324
|
+
# Send
|
|
325
|
+
sent = (
|
|
326
|
+
self.service.users()
|
|
327
|
+
.messages()
|
|
328
|
+
.send(
|
|
329
|
+
userId=self.user_id,
|
|
330
|
+
body=body_dict,
|
|
331
|
+
)
|
|
332
|
+
.execute()
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
return self.get_message(sent["id"])
|
|
336
|
+
|
|
337
|
+
# ========== Profile ==========
|
|
338
|
+
|
|
339
|
+
def get_profile(self) -> dict:
|
|
340
|
+
"""Get authenticated user's profile."""
|
|
341
|
+
return self.service.users().getProfile(userId=self.user_id).execute()
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def email(self) -> str:
|
|
345
|
+
"""Get authenticated user's email address."""
|
|
346
|
+
return self.get_profile().get("emailAddress", "")
|
|
347
|
+
|
|
348
|
+
# ========== Internal modification methods ==========
|
|
349
|
+
|
|
350
|
+
def _modify_labels(
|
|
351
|
+
self,
|
|
352
|
+
message_id: str,
|
|
353
|
+
add: list[str] | None = None,
|
|
354
|
+
remove: list[str] | None = None,
|
|
355
|
+
) -> None:
|
|
356
|
+
"""Internal: modify labels on a message."""
|
|
357
|
+
body = {}
|
|
358
|
+
if add:
|
|
359
|
+
body["addLabelIds"] = add
|
|
360
|
+
if remove:
|
|
361
|
+
body["removeLabelIds"] = remove
|
|
362
|
+
|
|
363
|
+
if body:
|
|
364
|
+
self.service.users().messages().modify(
|
|
365
|
+
userId=self.user_id,
|
|
366
|
+
id=message_id,
|
|
367
|
+
body=body,
|
|
368
|
+
).execute()
|
|
369
|
+
|
|
370
|
+
def _trash_message(self, message_id: str) -> None:
|
|
371
|
+
"""Internal: trash a message."""
|
|
372
|
+
self.service.users().messages().trash(
|
|
373
|
+
userId=self.user_id,
|
|
374
|
+
id=message_id,
|
|
375
|
+
).execute()
|
|
376
|
+
|
|
377
|
+
def _untrash_message(self, message_id: str) -> None:
|
|
378
|
+
"""Internal: untrash a message."""
|
|
379
|
+
self.service.users().messages().untrash(
|
|
380
|
+
userId=self.user_id,
|
|
381
|
+
id=message_id,
|
|
382
|
+
).execute()
|
|
383
|
+
|
|
384
|
+
def _download_attachment(self, message_id: str, attachment_id: str) -> bytes:
|
|
385
|
+
"""Internal: download attachment content."""
|
|
386
|
+
attachment = (
|
|
387
|
+
self.service.users()
|
|
388
|
+
.messages()
|
|
389
|
+
.attachments()
|
|
390
|
+
.get(
|
|
391
|
+
userId=self.user_id,
|
|
392
|
+
messageId=message_id,
|
|
393
|
+
id=attachment_id,
|
|
394
|
+
)
|
|
395
|
+
.execute()
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return base64.urlsafe_b64decode(attachment.get("data", ""))
|
|
399
|
+
|
|
400
|
+
# ========== Parsing ==========
|
|
401
|
+
|
|
402
|
+
def _parse_message(self, data: dict, include_body: bool = True) -> Message:
|
|
403
|
+
"""Parse Gmail API response to Message object."""
|
|
404
|
+
# Use centralized parser
|
|
405
|
+
msg = GmailParser.parse_message(data, include_body)
|
|
406
|
+
|
|
407
|
+
# Link message and attachments to this client for fluent methods
|
|
408
|
+
msg._gmail = self
|
|
409
|
+
for att in msg.attachments:
|
|
410
|
+
att._gmail = self
|
|
411
|
+
|
|
412
|
+
return msg
|
gsuite_gmail/label.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Gmail Label entity."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LabelType(StrEnum):
|
|
8
|
+
"""Label type classification."""
|
|
9
|
+
|
|
10
|
+
SYSTEM = "system"
|
|
11
|
+
USER = "user"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Label:
|
|
16
|
+
"""
|
|
17
|
+
Gmail label.
|
|
18
|
+
|
|
19
|
+
Labels are like folders/tags that can be applied to messages.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
type: LabelType = LabelType.USER
|
|
25
|
+
messages_total: int = 0
|
|
26
|
+
messages_unread: int = 0
|
|
27
|
+
threads_total: int = 0
|
|
28
|
+
threads_unread: int = 0
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_system(self) -> bool:
|
|
32
|
+
"""Check if this is a system label."""
|
|
33
|
+
return self.type == LabelType.SYSTEM
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def has_unread(self) -> bool:
|
|
37
|
+
"""Check if label has unread messages."""
|
|
38
|
+
return self.messages_unread > 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SystemLabels:
|
|
42
|
+
"""Constants for common Gmail system labels."""
|
|
43
|
+
|
|
44
|
+
INBOX = "INBOX"
|
|
45
|
+
SENT = "SENT"
|
|
46
|
+
DRAFT = "DRAFT"
|
|
47
|
+
TRASH = "TRASH"
|
|
48
|
+
SPAM = "SPAM"
|
|
49
|
+
STARRED = "STARRED"
|
|
50
|
+
UNREAD = "UNREAD"
|
|
51
|
+
IMPORTANT = "IMPORTANT"
|
|
52
|
+
CATEGORY_PERSONAL = "CATEGORY_PERSONAL"
|
|
53
|
+
CATEGORY_SOCIAL = "CATEGORY_SOCIAL"
|
|
54
|
+
CATEGORY_PROMOTIONS = "CATEGORY_PROMOTIONS"
|
|
55
|
+
CATEGORY_UPDATES = "CATEGORY_UPDATES"
|
|
56
|
+
CATEGORY_FORUMS = "CATEGORY_FORUMS"
|
gsuite_gmail/message.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Gmail Message entity with fluent methods."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import TYPE_CHECKING, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from gsuite_gmail.client import Gmail
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Attachment:
|
|
13
|
+
"""Email attachment."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
filename: str
|
|
17
|
+
mime_type: str
|
|
18
|
+
size: int
|
|
19
|
+
_message_id: str = ""
|
|
20
|
+
_gmail: Optional["Gmail"] = field(default=None, repr=False)
|
|
21
|
+
|
|
22
|
+
def download(self) -> bytes:
|
|
23
|
+
"""Download attachment content."""
|
|
24
|
+
if not self._gmail:
|
|
25
|
+
raise RuntimeError("Attachment not linked to Gmail client")
|
|
26
|
+
return self._gmail._download_attachment(self._message_id, self.id)
|
|
27
|
+
|
|
28
|
+
def save(self, path: str | None = None) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Download and save attachment to disk.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Save path (default: current dir with original filename)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Path where file was saved
|
|
37
|
+
"""
|
|
38
|
+
content = self.download()
|
|
39
|
+
save_path = path or self.filename
|
|
40
|
+
|
|
41
|
+
with open(save_path, "wb") as f:
|
|
42
|
+
f.write(content)
|
|
43
|
+
|
|
44
|
+
return save_path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Message:
|
|
49
|
+
"""
|
|
50
|
+
Gmail message with fluent modification methods.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
message.mark_as_read().star().add_label("Work")
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
id: str
|
|
57
|
+
thread_id: str
|
|
58
|
+
subject: str
|
|
59
|
+
sender: str
|
|
60
|
+
recipient: str
|
|
61
|
+
cc: list[str] = field(default_factory=list)
|
|
62
|
+
bcc: list[str] = field(default_factory=list)
|
|
63
|
+
date: datetime | None = None
|
|
64
|
+
snippet: str = ""
|
|
65
|
+
plain: str | None = None # Plain text body
|
|
66
|
+
html: str | None = None # HTML body
|
|
67
|
+
labels: list[str] = field(default_factory=list)
|
|
68
|
+
attachments: list[Attachment] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
_gmail: Optional["Gmail"] = field(default=None, repr=False)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_unread(self) -> bool:
|
|
74
|
+
"""Check if message is unread."""
|
|
75
|
+
return "UNREAD" in self.labels
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def is_starred(self) -> bool:
|
|
79
|
+
"""Check if message is starred."""
|
|
80
|
+
return "STARRED" in self.labels
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def is_important(self) -> bool:
|
|
84
|
+
"""Check if message is marked important."""
|
|
85
|
+
return "IMPORTANT" in self.labels
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def body(self) -> str:
|
|
89
|
+
"""Get body content (prefers plain text)."""
|
|
90
|
+
return self.plain or self.html or ""
|
|
91
|
+
|
|
92
|
+
# Fluent modification methods
|
|
93
|
+
|
|
94
|
+
def mark_as_read(self) -> "Message":
|
|
95
|
+
"""Mark message as read."""
|
|
96
|
+
if self._gmail and self.is_unread:
|
|
97
|
+
self._gmail._modify_labels(self.id, remove=["UNREAD"])
|
|
98
|
+
self.labels = [l for l in self.labels if l != "UNREAD"]
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def mark_as_unread(self) -> "Message":
|
|
102
|
+
"""Mark message as unread."""
|
|
103
|
+
if self._gmail and not self.is_unread:
|
|
104
|
+
self._gmail._modify_labels(self.id, add=["UNREAD"])
|
|
105
|
+
self.labels.append("UNREAD")
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
def star(self) -> "Message":
|
|
109
|
+
"""Star the message."""
|
|
110
|
+
if self._gmail and not self.is_starred:
|
|
111
|
+
self._gmail._modify_labels(self.id, add=["STARRED"])
|
|
112
|
+
self.labels.append("STARRED")
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def unstar(self) -> "Message":
|
|
116
|
+
"""Remove star from message."""
|
|
117
|
+
if self._gmail and self.is_starred:
|
|
118
|
+
self._gmail._modify_labels(self.id, remove=["STARRED"])
|
|
119
|
+
self.labels = [l for l in self.labels if l != "STARRED"]
|
|
120
|
+
return self
|
|
121
|
+
|
|
122
|
+
def mark_important(self) -> "Message":
|
|
123
|
+
"""Mark message as important."""
|
|
124
|
+
if self._gmail and not self.is_important:
|
|
125
|
+
self._gmail._modify_labels(self.id, add=["IMPORTANT"])
|
|
126
|
+
self.labels.append("IMPORTANT")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def mark_not_important(self) -> "Message":
|
|
130
|
+
"""Mark message as not important."""
|
|
131
|
+
if self._gmail and self.is_important:
|
|
132
|
+
self._gmail._modify_labels(self.id, remove=["IMPORTANT"])
|
|
133
|
+
self.labels = [l for l in self.labels if l != "IMPORTANT"]
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def trash(self) -> "Message":
|
|
137
|
+
"""Move message to trash."""
|
|
138
|
+
if self._gmail:
|
|
139
|
+
self._gmail._trash_message(self.id)
|
|
140
|
+
if "TRASH" not in self.labels:
|
|
141
|
+
self.labels.append("TRASH")
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def untrash(self) -> "Message":
|
|
145
|
+
"""Remove message from trash."""
|
|
146
|
+
if self._gmail:
|
|
147
|
+
self._gmail._untrash_message(self.id)
|
|
148
|
+
self.labels = [l for l in self.labels if l != "TRASH"]
|
|
149
|
+
return self
|
|
150
|
+
|
|
151
|
+
def archive(self) -> "Message":
|
|
152
|
+
"""Archive message (remove from inbox)."""
|
|
153
|
+
if self._gmail:
|
|
154
|
+
self._gmail._modify_labels(self.id, remove=["INBOX"])
|
|
155
|
+
self.labels = [l for l in self.labels if l != "INBOX"]
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
def move_to_inbox(self) -> "Message":
|
|
159
|
+
"""Move message to inbox."""
|
|
160
|
+
if self._gmail and "INBOX" not in self.labels:
|
|
161
|
+
self._gmail._modify_labels(self.id, add=["INBOX"])
|
|
162
|
+
self.labels.append("INBOX")
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def add_label(self, label_name: str) -> "Message":
|
|
166
|
+
"""Add a label to the message."""
|
|
167
|
+
if self._gmail:
|
|
168
|
+
label_id = self._gmail._get_label_id(label_name)
|
|
169
|
+
if label_id and label_id not in self.labels:
|
|
170
|
+
self._gmail._modify_labels(self.id, add=[label_id])
|
|
171
|
+
self.labels.append(label_id)
|
|
172
|
+
return self
|
|
173
|
+
|
|
174
|
+
def remove_label(self, label_name: str) -> "Message":
|
|
175
|
+
"""Remove a label from the message."""
|
|
176
|
+
if self._gmail:
|
|
177
|
+
label_id = self._gmail._get_label_id(label_name)
|
|
178
|
+
if label_id and label_id in self.labels:
|
|
179
|
+
self._gmail._modify_labels(self.id, remove=[label_id])
|
|
180
|
+
self.labels = [l for l in self.labels if l != label_id]
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def reply(
|
|
184
|
+
self,
|
|
185
|
+
body: str,
|
|
186
|
+
html: bool = False,
|
|
187
|
+
signature: bool = False,
|
|
188
|
+
) -> "Message":
|
|
189
|
+
"""
|
|
190
|
+
Reply to this message.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
body: Reply body
|
|
194
|
+
html: Whether body is HTML
|
|
195
|
+
signature: Include account signature
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
The sent reply message
|
|
199
|
+
"""
|
|
200
|
+
if not self._gmail:
|
|
201
|
+
raise RuntimeError("Message not linked to Gmail client")
|
|
202
|
+
|
|
203
|
+
return self._gmail.send(
|
|
204
|
+
to=[self.sender],
|
|
205
|
+
subject=f"Re: {self.subject}" if not self.subject.startswith("Re:") else self.subject,
|
|
206
|
+
body=body,
|
|
207
|
+
html=html,
|
|
208
|
+
signature=signature,
|
|
209
|
+
reply_to=self.id,
|
|
210
|
+
thread_id=self.thread_id,
|
|
211
|
+
)
|