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/parser.py ADDED
@@ -0,0 +1,155 @@
1
+ """Gmail response parsers - converts API responses to domain entities."""
2
+
3
+ import base64
4
+ from datetime import datetime
5
+ from email.utils import parsedate_to_datetime
6
+
7
+ from gsuite_gmail.label import Label, LabelType
8
+ from gsuite_gmail.message import Attachment, Message
9
+
10
+
11
+ class GmailParser:
12
+ """Parser for Gmail API responses."""
13
+
14
+ @staticmethod
15
+ def parse_message(
16
+ data: dict,
17
+ include_body: bool = True,
18
+ ) -> Message:
19
+ """
20
+ Parse Gmail API response to Message entity.
21
+
22
+ Args:
23
+ data: Raw API response dict
24
+ include_body: Whether to parse body content
25
+
26
+ Returns:
27
+ Message entity
28
+ """
29
+ payload = data.get("payload", {})
30
+ headers = payload.get("headers", [])
31
+
32
+ def get_header(name: str) -> str:
33
+ for h in headers:
34
+ if h.get("name", "").lower() == name.lower():
35
+ return h.get("value", "")
36
+ return ""
37
+
38
+ # Parse date
39
+ date = GmailParser._parse_date(get_header("Date"), data.get("internalDate"))
40
+
41
+ # Parse body and attachments
42
+ plain, html, attachments = None, None, []
43
+ if include_body:
44
+ plain, html, attachments = GmailParser._parse_payload(payload, data["id"])
45
+
46
+ return Message(
47
+ id=data["id"],
48
+ thread_id=data.get("threadId", data["id"]),
49
+ subject=get_header("Subject"),
50
+ sender=get_header("From"),
51
+ recipient=get_header("To"),
52
+ cc=[addr.strip() for addr in get_header("Cc").split(",") if addr.strip()],
53
+ date=date,
54
+ snippet=data.get("snippet", ""),
55
+ plain=plain,
56
+ html=html,
57
+ labels=data.get("labelIds", []),
58
+ attachments=attachments,
59
+ )
60
+
61
+ @staticmethod
62
+ def parse_label(data: dict) -> Label:
63
+ """
64
+ Parse Gmail API response to Label entity.
65
+
66
+ Args:
67
+ data: Raw API response dict
68
+
69
+ Returns:
70
+ Label entity
71
+ """
72
+ return Label(
73
+ id=data["id"],
74
+ name=data.get("name", data["id"]),
75
+ type=LabelType.SYSTEM if data.get("type") == "system" else LabelType.USER,
76
+ messages_total=data.get("messagesTotal", 0),
77
+ messages_unread=data.get("messagesUnread", 0),
78
+ threads_total=data.get("threadsTotal", 0),
79
+ threads_unread=data.get("threadsUnread", 0),
80
+ )
81
+
82
+ @staticmethod
83
+ def _parse_date(date_header: str, internal_date: str | None) -> datetime | None:
84
+ """Parse date from header or internal timestamp."""
85
+ if date_header:
86
+ try:
87
+ return parsedate_to_datetime(date_header)
88
+ except (ValueError, TypeError):
89
+ pass
90
+
91
+ if internal_date:
92
+ try:
93
+ return datetime.fromtimestamp(int(internal_date) / 1000)
94
+ except (ValueError, TypeError):
95
+ pass
96
+
97
+ return None
98
+
99
+ @staticmethod
100
+ def _parse_payload(
101
+ payload: dict,
102
+ message_id: str,
103
+ ) -> tuple[str | None, str | None, list[Attachment]]:
104
+ """
105
+ Parse message payload for body content and attachments.
106
+
107
+ Args:
108
+ payload: Message payload dict
109
+ message_id: Parent message ID
110
+
111
+ Returns:
112
+ Tuple of (plain_text, html, attachments)
113
+ """
114
+ plain = None
115
+ html = None
116
+ attachments = []
117
+
118
+ def extract_parts(part: dict) -> None:
119
+ nonlocal plain, html
120
+
121
+ mime_type = part.get("mimeType", "")
122
+ body = part.get("body", {})
123
+ data = body.get("data")
124
+
125
+ # Text content
126
+ if data:
127
+ try:
128
+ decoded = base64.urlsafe_b64decode(data).decode("utf-8", errors="replace")
129
+ if mime_type == "text/plain" and not plain:
130
+ plain = decoded
131
+ elif mime_type == "text/html" and not html:
132
+ html = decoded
133
+ except Exception:
134
+ pass
135
+
136
+ # Attachments
137
+ filename = part.get("filename")
138
+ attachment_id = body.get("attachmentId")
139
+ if filename and attachment_id:
140
+ attachments.append(
141
+ Attachment(
142
+ id=attachment_id,
143
+ filename=filename,
144
+ mime_type=mime_type,
145
+ size=body.get("size", 0),
146
+ _message_id=message_id,
147
+ )
148
+ )
149
+
150
+ # Recurse into nested parts
151
+ for sub_part in part.get("parts", []):
152
+ extract_parts(sub_part)
153
+
154
+ extract_parts(payload)
155
+ return plain, html, attachments
gsuite_gmail/py.typed ADDED
File without changes
gsuite_gmail/query.py ADDED
@@ -0,0 +1,227 @@
1
+ """Gmail query builder for intuitive searches."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Query:
8
+ """
9
+ Gmail search query builder.
10
+
11
+ Supports combining queries with & (AND) and | (OR).
12
+
13
+ Example:
14
+ q = newer_than(days=7) & has_attachment() & from_("boss@company.com")
15
+ messages = gmail.search(q)
16
+ """
17
+
18
+ _query: str
19
+
20
+ def __str__(self) -> str:
21
+ return self._query
22
+
23
+ def __and__(self, other: "Query") -> "Query":
24
+ """Combine with AND."""
25
+ return Query(f"{self._query} {other._query}")
26
+
27
+ def __or__(self, other: "Query") -> "Query":
28
+ """Combine with OR."""
29
+ return Query(f"({self._query}) OR ({other._query})")
30
+
31
+ def __invert__(self) -> "Query":
32
+ """Negate query."""
33
+ return Query(f"-({self._query})")
34
+
35
+
36
+ def from_(address: str) -> Query:
37
+ """Messages from a specific sender."""
38
+ return Query(f"from:{address}")
39
+
40
+
41
+ def to(address: str) -> Query:
42
+ """Messages to a specific recipient."""
43
+ return Query(f"to:{address}")
44
+
45
+
46
+ def subject(text: str) -> Query:
47
+ """Messages with subject containing text."""
48
+ if " " in text:
49
+ return Query(f'subject:"{text}"')
50
+ return Query(f"subject:{text}")
51
+
52
+
53
+ def has_words(words: str) -> Query:
54
+ """Messages containing specific words."""
55
+ return Query(words)
56
+
57
+
58
+ def has_attachment() -> Query:
59
+ """Messages with attachments."""
60
+ return Query("has:attachment")
61
+
62
+
63
+ def is_unread() -> Query:
64
+ """Unread messages."""
65
+ return Query("is:unread")
66
+
67
+
68
+ def is_read() -> Query:
69
+ """Read messages."""
70
+ return Query("is:read")
71
+
72
+
73
+ def is_starred() -> Query:
74
+ """Starred messages."""
75
+ return Query("is:starred")
76
+
77
+
78
+ def is_important() -> Query:
79
+ """Important messages."""
80
+ return Query("is:important")
81
+
82
+
83
+ def in_inbox() -> Query:
84
+ """Messages in inbox."""
85
+ return Query("in:inbox")
86
+
87
+
88
+ def in_sent() -> Query:
89
+ """Sent messages."""
90
+ return Query("in:sent")
91
+
92
+
93
+ def in_drafts() -> Query:
94
+ """Draft messages."""
95
+ return Query("in:drafts")
96
+
97
+
98
+ def in_trash() -> Query:
99
+ """Trashed messages."""
100
+ return Query("in:trash")
101
+
102
+
103
+ def in_spam() -> Query:
104
+ """Spam messages."""
105
+ return Query("in:spam")
106
+
107
+
108
+ def label(name: str) -> Query:
109
+ """Messages with a specific label."""
110
+ if " " in name:
111
+ return Query(f'label:"{name}"')
112
+ return Query(f"label:{name}")
113
+
114
+
115
+ def newer_than(days: int | None = None, months: int | None = None) -> Query:
116
+ """Messages newer than a time period."""
117
+ if days:
118
+ return Query(f"newer_than:{days}d")
119
+ if months:
120
+ return Query(f"newer_than:{months}m")
121
+ raise ValueError("Specify days or months")
122
+
123
+
124
+ def older_than(days: int | None = None, months: int | None = None) -> Query:
125
+ """Messages older than a time period."""
126
+ if days:
127
+ return Query(f"older_than:{days}d")
128
+ if months:
129
+ return Query(f"older_than:{months}m")
130
+ raise ValueError("Specify days or months")
131
+
132
+
133
+ def after(date: str) -> Query:
134
+ """Messages after a date (YYYY/MM/DD)."""
135
+ return Query(f"after:{date}")
136
+
137
+
138
+ def before(date: str) -> Query:
139
+ """Messages before a date (YYYY/MM/DD)."""
140
+ return Query(f"before:{date}")
141
+
142
+
143
+ def filename(name: str) -> Query:
144
+ """Messages with attachment matching filename."""
145
+ return Query(f"filename:{name}")
146
+
147
+
148
+ def size_larger(bytes: int) -> Query:
149
+ """Messages larger than size in bytes."""
150
+ return Query(f"larger:{bytes}")
151
+
152
+
153
+ def size_smaller(bytes: int) -> Query:
154
+ """Messages smaller than size in bytes."""
155
+ return Query(f"smaller:{bytes}")
156
+
157
+
158
+ def category(name: str) -> Query:
159
+ """Messages in a category (primary, social, promotions, updates, forums)."""
160
+ return Query(f"category:{name}")
161
+
162
+
163
+ def raw(query_string: str) -> Query:
164
+ """Raw Gmail query string."""
165
+ return Query(query_string)
166
+
167
+
168
+ # Convenience function to build query from dict (like simplegmail)
169
+ def construct_query(
170
+ from_: str | None = None,
171
+ to: str | None = None,
172
+ subject: str | None = None,
173
+ unread: bool | None = None,
174
+ starred: bool | None = None,
175
+ newer_than: tuple[int, str] | None = None, # (2, "day")
176
+ older_than: tuple[int, str] | None = None,
177
+ labels: list[str] | None = None,
178
+ has_attachment: bool | None = None,
179
+ exclude_starred: bool | None = None,
180
+ ) -> Query:
181
+ """
182
+ Build query from parameters.
183
+
184
+ Args:
185
+ from_: Sender email
186
+ to: Recipient email
187
+ subject: Subject contains
188
+ unread: True for unread only
189
+ starred: True for starred only
190
+ newer_than: Tuple of (amount, unit) e.g., (2, "day")
191
+ older_than: Tuple of (amount, unit)
192
+ labels: List of label names
193
+ has_attachment: True for messages with attachments
194
+ exclude_starred: True to exclude starred
195
+
196
+ Returns:
197
+ Query object
198
+ """
199
+ parts = []
200
+
201
+ if from_:
202
+ parts.append(f"from:{from_}")
203
+ if to:
204
+ parts.append(f"to:{to}")
205
+ if subject:
206
+ parts.append(f'subject:"{subject}"' if " " in subject else f"subject:{subject}")
207
+ if unread:
208
+ parts.append("is:unread")
209
+ if starred:
210
+ parts.append("is:starred")
211
+ if exclude_starred:
212
+ parts.append("-is:starred")
213
+ if newer_than:
214
+ amount, unit = newer_than
215
+ unit_map = {"day": "d", "days": "d", "d": "d", "month": "m", "months": "m", "m": "m"}
216
+ parts.append(f"newer_than:{amount}{unit_map.get(unit, unit)}")
217
+ if older_than:
218
+ amount, unit = older_than
219
+ unit_map = {"day": "d", "days": "d", "d": "d", "month": "m", "months": "m", "m": "m"}
220
+ parts.append(f"older_than:{amount}{unit_map.get(unit, unit)}")
221
+ if labels:
222
+ for lbl in labels:
223
+ parts.append(f'label:"{lbl}"' if " " in lbl else f"label:{lbl}")
224
+ if has_attachment:
225
+ parts.append("has:attachment")
226
+
227
+ return Query(" ".join(parts))
gsuite_gmail/thread.py ADDED
@@ -0,0 +1,54 @@
1
+ """Gmail Thread entity."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from gsuite_gmail.message import Message
8
+
9
+
10
+ @dataclass
11
+ class Thread:
12
+ """
13
+ Gmail thread (conversation).
14
+
15
+ A thread is a collection of related messages.
16
+ """
17
+
18
+ id: str
19
+ messages: list["Message"] = field(default_factory=list)
20
+ snippet: str = ""
21
+
22
+ @property
23
+ def subject(self) -> str:
24
+ """Get thread subject from first message."""
25
+ if self.messages:
26
+ return self.messages[0].subject
27
+ return ""
28
+
29
+ @property
30
+ def message_count(self) -> int:
31
+ """Get number of messages in thread."""
32
+ return len(self.messages)
33
+
34
+ @property
35
+ def participants(self) -> set[str]:
36
+ """Get all unique email addresses in thread."""
37
+ emails = set()
38
+ for msg in self.messages:
39
+ emails.add(msg.sender)
40
+ emails.add(msg.recipient)
41
+ return emails
42
+
43
+ @property
44
+ def has_unread(self) -> bool:
45
+ """Check if thread has any unread messages."""
46
+ return any(msg.is_unread for msg in self.messages)
47
+
48
+ @property
49
+ def labels(self) -> set[str]:
50
+ """Get all unique labels from all messages."""
51
+ all_labels = set()
52
+ for msg in self.messages:
53
+ all_labels.update(msg.labels)
54
+ return all_labels