google-api-client-wrapper 1.0.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.
- google_api_client_wrapper-1.0.0.dist-info/METADATA +103 -0
- google_api_client_wrapper-1.0.0.dist-info/RECORD +39 -0
- google_api_client_wrapper-1.0.0.dist-info/WHEEL +5 -0
- google_api_client_wrapper-1.0.0.dist-info/licenses/LICENSE +21 -0
- google_api_client_wrapper-1.0.0.dist-info/top_level.txt +1 -0
- google_client/__init__.py +6 -0
- google_client/services/__init__.py +13 -0
- google_client/services/calendar/__init__.py +14 -0
- google_client/services/calendar/api_service.py +454 -0
- google_client/services/calendar/constants.py +48 -0
- google_client/services/calendar/exceptions.py +35 -0
- google_client/services/calendar/query_builder.py +314 -0
- google_client/services/calendar/types.py +403 -0
- google_client/services/calendar/utils.py +338 -0
- google_client/services/drive/__init__.py +13 -0
- google_client/services/drive/api_service.py +1133 -0
- google_client/services/drive/constants.py +37 -0
- google_client/services/drive/exceptions.py +60 -0
- google_client/services/drive/query_builder.py +385 -0
- google_client/services/drive/types.py +242 -0
- google_client/services/drive/utils.py +392 -0
- google_client/services/gmail/__init__.py +16 -0
- google_client/services/gmail/api_service.py +715 -0
- google_client/services/gmail/constants.py +6 -0
- google_client/services/gmail/exceptions.py +45 -0
- google_client/services/gmail/query_builder.py +408 -0
- google_client/services/gmail/types.py +285 -0
- google_client/services/gmail/utils.py +426 -0
- google_client/services/tasks/__init__.py +12 -0
- google_client/services/tasks/api_service.py +561 -0
- google_client/services/tasks/constants.py +32 -0
- google_client/services/tasks/exceptions.py +35 -0
- google_client/services/tasks/query_builder.py +324 -0
- google_client/services/tasks/types.py +156 -0
- google_client/services/tasks/utils.py +224 -0
- google_client/user_client.py +208 -0
- google_client/utils/__init__.py +0 -0
- google_client/utils/datetime.py +144 -0
- google_client/utils/validation.py +71 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from email.mime.text import MIMEText
|
|
3
|
+
from email.mime.multipart import MIMEMultipart
|
|
4
|
+
from email.mime.base import MIMEBase
|
|
5
|
+
from email import encoders
|
|
6
|
+
from email.utils import getaddresses, parsedate_to_datetime
|
|
7
|
+
import mimetypes
|
|
8
|
+
import html
|
|
9
|
+
from typing import Optional, List
|
|
10
|
+
import base64
|
|
11
|
+
import re
|
|
12
|
+
from .types import EmailMessage, EmailAttachment, EmailAddress, EmailThread
|
|
13
|
+
from ...utils.datetime import convert_datetime_to_local_timezone, convert_datetime_to_readable
|
|
14
|
+
from .constants import MAX_SUBJECT_LENGTH, MAX_BODY_LENGTH
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Import from shared utilities
|
|
18
|
+
from ...utils.validation import is_valid_email, sanitize_header_value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_body(payload: dict) -> tuple[Optional[str], Optional[str]]:
|
|
22
|
+
"""
|
|
23
|
+
Extracts plain text and HTML body from Gmail message payload.
|
|
24
|
+
Returns:
|
|
25
|
+
A tuple of (plain_text, html_text)
|
|
26
|
+
"""
|
|
27
|
+
body_text = None
|
|
28
|
+
body_html = None
|
|
29
|
+
|
|
30
|
+
def decode_body(data: str) -> str:
|
|
31
|
+
"""Decode base64url encoded body data."""
|
|
32
|
+
try:
|
|
33
|
+
return base64.urlsafe_b64decode(data + '===').decode('utf-8')
|
|
34
|
+
except:
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
def extract_from_parts(parts: List[dict]):
|
|
38
|
+
nonlocal body_text, body_html
|
|
39
|
+
for part in parts:
|
|
40
|
+
mime_type = part.get('mimeType', '')
|
|
41
|
+
if mime_type == 'text/plain' and part.get('body', {}).get('data'):
|
|
42
|
+
body_text = decode_body(part['body']['data'])
|
|
43
|
+
elif mime_type == 'text/html' and part.get('body', {}).get('data'):
|
|
44
|
+
body_html = decode_body(part['body']['data'])
|
|
45
|
+
elif part.get('parts'):
|
|
46
|
+
extract_from_parts(part['parts'])
|
|
47
|
+
|
|
48
|
+
# Handle different payload structures
|
|
49
|
+
if payload.get('parts'):
|
|
50
|
+
extract_from_parts(payload['parts'])
|
|
51
|
+
elif payload.get('body', {}).get('data'):
|
|
52
|
+
mime_type = payload.get('mimeType', '')
|
|
53
|
+
if mime_type == 'text/plain':
|
|
54
|
+
body_text = decode_body(payload['body']['data'])
|
|
55
|
+
elif mime_type == 'text/html':
|
|
56
|
+
body_html = decode_body(payload['body']['data'])
|
|
57
|
+
|
|
58
|
+
return body_text, body_html
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_attachments(message_id: str, payload: dict) -> List[EmailAttachment]:
|
|
62
|
+
"""
|
|
63
|
+
Extracts attachment information from Gmail message payload.
|
|
64
|
+
Returns:
|
|
65
|
+
A list of EmailAttachment objects.
|
|
66
|
+
"""
|
|
67
|
+
attachments = []
|
|
68
|
+
|
|
69
|
+
def extract_from_parts(parts: List[dict]):
|
|
70
|
+
for part in parts:
|
|
71
|
+
if part.get('filename') and part.get('body', {}).get('attachmentId'):
|
|
72
|
+
try:
|
|
73
|
+
attachment = EmailAttachment(
|
|
74
|
+
filename=part['filename'],
|
|
75
|
+
mime_type=part.get('mimeType', 'application/octet-stream'),
|
|
76
|
+
size=part.get('body', {}).get('size', 0),
|
|
77
|
+
attachment_id=part['body']['attachmentId'],
|
|
78
|
+
message_id=message_id
|
|
79
|
+
)
|
|
80
|
+
attachments.append(attachment)
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
elif part.get('parts'):
|
|
84
|
+
extract_from_parts(part['parts'])
|
|
85
|
+
|
|
86
|
+
if payload.get('parts'):
|
|
87
|
+
extract_from_parts(payload['parts'])
|
|
88
|
+
|
|
89
|
+
return attachments
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def from_gmail_message(gmail_message: dict) -> "EmailMessage":
|
|
93
|
+
"""
|
|
94
|
+
Creates an EmailMessage instance from a Gmail API response.
|
|
95
|
+
Args:
|
|
96
|
+
gmail_message: A dictionary containing message data from Gmail API.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
An EmailMessage instance populated with the data from the dictionary.
|
|
100
|
+
"""
|
|
101
|
+
headers = {}
|
|
102
|
+
payload = gmail_message.get('payload', {})
|
|
103
|
+
|
|
104
|
+
# Extract headers
|
|
105
|
+
for header in payload.get('headers', []):
|
|
106
|
+
headers[header['name'].lower()] = header['value']
|
|
107
|
+
|
|
108
|
+
# Parse email addresses
|
|
109
|
+
def parse_email_addresses(header_value: str) -> List[EmailAddress]:
|
|
110
|
+
if not header_value:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
addresses = []
|
|
114
|
+
for name, email in getaddresses([header_value]):
|
|
115
|
+
if email and is_valid_email(email):
|
|
116
|
+
try:
|
|
117
|
+
addresses.append(EmailAddress(email=email, name=name if name else None))
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
return addresses
|
|
121
|
+
|
|
122
|
+
sender = None
|
|
123
|
+
if headers.get('from'):
|
|
124
|
+
sender_list = parse_email_addresses(headers['from'])
|
|
125
|
+
sender = sender_list[0] if sender_list else None
|
|
126
|
+
|
|
127
|
+
recipients = parse_email_addresses(headers.get('to', ''))
|
|
128
|
+
cc_recipients = parse_email_addresses(headers.get('cc', ''))
|
|
129
|
+
bcc_recipients = parse_email_addresses(headers.get('bcc', ''))
|
|
130
|
+
|
|
131
|
+
# Extract body
|
|
132
|
+
body_text, body_html = extract_body(payload)
|
|
133
|
+
|
|
134
|
+
# Extract attachments
|
|
135
|
+
message_id = gmail_message.get('id')
|
|
136
|
+
attachments = extract_attachments(message_id, payload)
|
|
137
|
+
|
|
138
|
+
# Parse date
|
|
139
|
+
date_received = None
|
|
140
|
+
if headers.get('date'):
|
|
141
|
+
try:
|
|
142
|
+
# Parse RFC 2822 date format
|
|
143
|
+
date_received = parsedate_to_datetime(headers['date'])
|
|
144
|
+
date_received = convert_datetime_to_local_timezone(date_received)
|
|
145
|
+
except:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# Extract labels
|
|
149
|
+
labels = gmail_message.get('labelIds', [])
|
|
150
|
+
|
|
151
|
+
# Determine read status, starred, important
|
|
152
|
+
is_read = 'UNREAD' not in labels
|
|
153
|
+
is_starred = 'STARRED' in labels
|
|
154
|
+
is_important = 'IMPORTANT' in labels
|
|
155
|
+
|
|
156
|
+
return EmailMessage(
|
|
157
|
+
message_id=gmail_message.get('id'),
|
|
158
|
+
thread_id=gmail_message.get('threadId'),
|
|
159
|
+
subject=headers.get('subject', "").strip(),
|
|
160
|
+
sender=sender,
|
|
161
|
+
recipients=recipients,
|
|
162
|
+
cc_recipients=cc_recipients,
|
|
163
|
+
bcc_recipients=bcc_recipients,
|
|
164
|
+
date_time=date_received,
|
|
165
|
+
body_text=body_text,
|
|
166
|
+
body_html=body_html,
|
|
167
|
+
attachments=attachments,
|
|
168
|
+
labels=labels,
|
|
169
|
+
is_read=is_read,
|
|
170
|
+
is_starred=is_starred,
|
|
171
|
+
is_important=is_important,
|
|
172
|
+
snippet=html.unescape(gmail_message.get('snippet')).strip(),
|
|
173
|
+
reply_to_id=headers.get('message-id'),
|
|
174
|
+
references=headers.get('references')
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def create_message(
|
|
179
|
+
to: List[str],
|
|
180
|
+
subject: Optional[str] = None,
|
|
181
|
+
body_text: Optional[str] = None,
|
|
182
|
+
body_html: Optional[str] = None,
|
|
183
|
+
cc: Optional[List[str]] = None,
|
|
184
|
+
bcc: Optional[List[str]] = None,
|
|
185
|
+
attachment_paths: Optional[List[str]] = None,
|
|
186
|
+
attachment_data_list: Optional[List[tuple]] = None,
|
|
187
|
+
reply_to_message_id: Optional[str] = None,
|
|
188
|
+
references: Optional[str] = None
|
|
189
|
+
|
|
190
|
+
) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Creates a MIMEText email message.
|
|
193
|
+
|
|
194
|
+
Security: Attachment filenames are sanitized to prevent header injection attacks.
|
|
195
|
+
Filenames containing control characters (CRLF, etc.) that could inject additional
|
|
196
|
+
headers are automatically cleaned.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
to: List of recipient email addresses.
|
|
200
|
+
subject: The subject line of the email.
|
|
201
|
+
body_text: Plain text body of the email (optional).
|
|
202
|
+
body_html: HTML body of the email (optional).
|
|
203
|
+
cc: List of CC recipient email addresses (optional).
|
|
204
|
+
bcc: List of BCC recipient email addresses (optional).
|
|
205
|
+
attachment_paths: List of file paths to attach (optional).
|
|
206
|
+
attachment_data_list: List of tuples (filename, mime_type, data_bytes) for in-memory attachments (optional).
|
|
207
|
+
reply_to_message_id: ID of message this is replying to (optional).
|
|
208
|
+
references: List of references to attach (optional).
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
A MIMEText object representing the email message.
|
|
212
|
+
"""
|
|
213
|
+
if not to:
|
|
214
|
+
raise ValueError("At least one recipient is required.")
|
|
215
|
+
|
|
216
|
+
# Validate inputs
|
|
217
|
+
if subject and len(subject) > MAX_SUBJECT_LENGTH:
|
|
218
|
+
raise ValueError(f"Subject cannot exceed {MAX_SUBJECT_LENGTH} characters")
|
|
219
|
+
if body_text and len(body_text) > MAX_BODY_LENGTH:
|
|
220
|
+
raise ValueError(f"Body text cannot exceed {MAX_BODY_LENGTH} characters")
|
|
221
|
+
if body_html and len(body_html) > MAX_BODY_LENGTH:
|
|
222
|
+
raise ValueError(f"Body HTML cannot exceed {MAX_BODY_LENGTH} characters")
|
|
223
|
+
|
|
224
|
+
# Create the message content (text and/or HTML)
|
|
225
|
+
if body_html and body_text:
|
|
226
|
+
# Both text and HTML - create alternative container
|
|
227
|
+
content_part = MIMEMultipart('alternative')
|
|
228
|
+
content_part.attach(MIMEText(body_text, 'plain'))
|
|
229
|
+
content_part.attach(MIMEText(body_html, 'html'))
|
|
230
|
+
message = content_part
|
|
231
|
+
elif body_html:
|
|
232
|
+
message = MIMEText(body_html, 'html')
|
|
233
|
+
else:
|
|
234
|
+
message = MIMEText(body_text or '', 'plain')
|
|
235
|
+
|
|
236
|
+
message['to'] = ', '.join(to)
|
|
237
|
+
message['subject'] = subject
|
|
238
|
+
|
|
239
|
+
if cc:
|
|
240
|
+
message['cc'] = ', '.join(cc)
|
|
241
|
+
if bcc:
|
|
242
|
+
message['bcc'] = ', '.join(bcc)
|
|
243
|
+
|
|
244
|
+
# Add attachments
|
|
245
|
+
if attachment_paths or attachment_data_list:
|
|
246
|
+
# Create mixed container for content + attachments
|
|
247
|
+
content_message = message # Save the content part
|
|
248
|
+
message = MIMEMultipart('mixed')
|
|
249
|
+
|
|
250
|
+
# Attach the content (text/HTML/alternative) as first part
|
|
251
|
+
message.attach(content_message)
|
|
252
|
+
|
|
253
|
+
# Set headers on the mixed container
|
|
254
|
+
message['to'] = ', '.join(to)
|
|
255
|
+
message['subject'] = subject
|
|
256
|
+
if cc:
|
|
257
|
+
message['cc'] = ', '.join(cc)
|
|
258
|
+
if bcc:
|
|
259
|
+
message['bcc'] = ', '.join(bcc)
|
|
260
|
+
|
|
261
|
+
# Add file attachments
|
|
262
|
+
if attachment_paths:
|
|
263
|
+
for file_path in attachment_paths:
|
|
264
|
+
if os.path.isfile(file_path):
|
|
265
|
+
content_type, encoding = mimetypes.guess_type(file_path)
|
|
266
|
+
if content_type is None or encoding is not None:
|
|
267
|
+
content_type = 'application/octet-stream'
|
|
268
|
+
|
|
269
|
+
main_type, sub_type = content_type.split('/', 1)
|
|
270
|
+
|
|
271
|
+
with open(file_path, 'rb') as fp:
|
|
272
|
+
attachment = MIMEBase(main_type, sub_type)
|
|
273
|
+
attachment.set_payload(fp.read())
|
|
274
|
+
encoders.encode_base64(attachment)
|
|
275
|
+
# Sanitize filename to prevent header injection
|
|
276
|
+
safe_filename = sanitize_header_value(os.path.basename(file_path))
|
|
277
|
+
attachment.add_header(
|
|
278
|
+
'Content-Disposition',
|
|
279
|
+
f'attachment; filename="{safe_filename}"'
|
|
280
|
+
)
|
|
281
|
+
message.attach(attachment)
|
|
282
|
+
|
|
283
|
+
# Add in-memory attachments
|
|
284
|
+
if attachment_data_list:
|
|
285
|
+
for filename, mime_type, data_bytes in attachment_data_list:
|
|
286
|
+
|
|
287
|
+
main_type, sub_type = mime_type.split('/', 1) if '/' in mime_type else ('application', 'octet-stream')
|
|
288
|
+
attachment = MIMEBase(main_type, sub_type)
|
|
289
|
+
|
|
290
|
+
# data_bytes from get_attachment_payload is already raw binary data
|
|
291
|
+
# so we need to encode it to base64
|
|
292
|
+
attachment.set_payload(data_bytes)
|
|
293
|
+
encoders.encode_base64(attachment)
|
|
294
|
+
|
|
295
|
+
# Sanitize filename to prevent header injection
|
|
296
|
+
safe_filename = sanitize_header_value(filename)
|
|
297
|
+
attachment.add_header(
|
|
298
|
+
'Content-Disposition',
|
|
299
|
+
f'attachment; filename="{safe_filename}"'
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Add Content-Type header explicitly
|
|
303
|
+
attachment.add_header('Content-Type', mime_type)
|
|
304
|
+
|
|
305
|
+
message.attach(attachment)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Add reply headers if this is a reply
|
|
309
|
+
if reply_to_message_id:
|
|
310
|
+
message['In-Reply-To'] = reply_to_message_id
|
|
311
|
+
message['References'] = references or reply_to_message_id
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Encode message
|
|
315
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
|
316
|
+
|
|
317
|
+
return raw_message
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def from_gmail_thread(gmail_thread: dict) -> EmailThread:
|
|
321
|
+
"""
|
|
322
|
+
Creates an EmailThread instance from a Gmail API thread response.
|
|
323
|
+
Args:
|
|
324
|
+
gmail_thread: A dictionary containing thread data from Gmail API.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
An EmailThread instance populated with the data from the dictionary.
|
|
328
|
+
"""
|
|
329
|
+
thread_id = gmail_thread.get('id')
|
|
330
|
+
snippet = html.unescape(gmail_thread.get('snippet', '')).strip()
|
|
331
|
+
history_id = gmail_thread.get('historyId')
|
|
332
|
+
|
|
333
|
+
# Convert messages to EmailMessage objects
|
|
334
|
+
messages = []
|
|
335
|
+
for gmail_message in gmail_thread.get('messages', []):
|
|
336
|
+
try:
|
|
337
|
+
email_message = from_gmail_message(gmail_message)
|
|
338
|
+
messages.append(email_message)
|
|
339
|
+
except Exception:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
return EmailThread(
|
|
343
|
+
thread_id=thread_id,
|
|
344
|
+
messages=messages,
|
|
345
|
+
snippet=snippet,
|
|
346
|
+
history_id=history_id
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def build_references_header(email: EmailMessage) -> Optional[str]:
|
|
351
|
+
"""
|
|
352
|
+
Builds a References header by appending the original message's Message-ID to existing references.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
email: The email being replied to
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
A properly formatted References header string or None
|
|
359
|
+
"""
|
|
360
|
+
references_parts = []
|
|
361
|
+
|
|
362
|
+
# Add existing references (already contains the proper thread chain)
|
|
363
|
+
if email.references:
|
|
364
|
+
references_parts.append(email.references.strip())
|
|
365
|
+
|
|
366
|
+
# Add the original message's Message-ID to continue the chain
|
|
367
|
+
if email.reply_to_id:
|
|
368
|
+
references_parts.append(email.reply_to_id.strip())
|
|
369
|
+
|
|
370
|
+
return " ".join(references_parts) if references_parts else None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def prepare_forward_body_text(email: EmailMessage) -> str:
|
|
374
|
+
if email.body_text:
|
|
375
|
+
forwarded_body_text = "\n".join([
|
|
376
|
+
email.body_text,
|
|
377
|
+
"\n\n---------- Forwarded message ---------",
|
|
378
|
+
f"From: {email.sender}",
|
|
379
|
+
f"Date: {convert_datetime_to_readable(email.date_time)}",
|
|
380
|
+
f"Subject: {email.subject}",
|
|
381
|
+
f"To: {", ".join([str(recipient) for recipient in email.recipients])}",
|
|
382
|
+
""
|
|
383
|
+
])
|
|
384
|
+
else:
|
|
385
|
+
forwarded_body_text = "\n".join([
|
|
386
|
+
"\n\n---------- Forwarded message ---------",
|
|
387
|
+
f"From: {email.sender}",
|
|
388
|
+
f"Date: {convert_datetime_to_readable(email.date_time)}",
|
|
389
|
+
f"Subject: {email.subject}",
|
|
390
|
+
f"To: {", ".join([str(recipient) for recipient in email.recipients])}",
|
|
391
|
+
""
|
|
392
|
+
])
|
|
393
|
+
|
|
394
|
+
return forwarded_body_text
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def prepare_forward_body_html(email: EmailMessage) -> Optional[str]:
|
|
398
|
+
"""
|
|
399
|
+
Prepares the HTML body for a forwarded email.
|
|
400
|
+
"""
|
|
401
|
+
if not email.body_html:
|
|
402
|
+
return None
|
|
403
|
+
|
|
404
|
+
forward_content = []
|
|
405
|
+
|
|
406
|
+
forward_content.append("---------- Forwarded message ---------<br>")
|
|
407
|
+
|
|
408
|
+
forward_content.append(f"<b>From:</b> {email.sender}<br>")
|
|
409
|
+
forward_content.append(f"<b>Date:</b> {convert_datetime_to_readable(email.date_time)}<br>")
|
|
410
|
+
forward_content.append(f"<b>Subject:</b> {email.subject}<br>")
|
|
411
|
+
if email.recipients:
|
|
412
|
+
to_list = ", ".join([str(recipient) for recipient in email.recipients])
|
|
413
|
+
forward_content.append(f"<b>To:</b> {to_list}<br>")
|
|
414
|
+
|
|
415
|
+
forward_content.append("<br>") # Empty line before original content
|
|
416
|
+
|
|
417
|
+
# Add original message content
|
|
418
|
+
if email.body_html:
|
|
419
|
+
forward_content.append(email.body_html)
|
|
420
|
+
elif email.body_text:
|
|
421
|
+
# Convert plain text to HTML
|
|
422
|
+
html_text = email.body_text.replace('\n', '<br>')
|
|
423
|
+
forward_content.append(html_text)
|
|
424
|
+
|
|
425
|
+
return "".join(forward_content)
|
|
426
|
+
|