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_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"
@@ -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
+ )