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.
Files changed (39) hide show
  1. google_api_client_wrapper-1.0.0.dist-info/METADATA +103 -0
  2. google_api_client_wrapper-1.0.0.dist-info/RECORD +39 -0
  3. google_api_client_wrapper-1.0.0.dist-info/WHEEL +5 -0
  4. google_api_client_wrapper-1.0.0.dist-info/licenses/LICENSE +21 -0
  5. google_api_client_wrapper-1.0.0.dist-info/top_level.txt +1 -0
  6. google_client/__init__.py +6 -0
  7. google_client/services/__init__.py +13 -0
  8. google_client/services/calendar/__init__.py +14 -0
  9. google_client/services/calendar/api_service.py +454 -0
  10. google_client/services/calendar/constants.py +48 -0
  11. google_client/services/calendar/exceptions.py +35 -0
  12. google_client/services/calendar/query_builder.py +314 -0
  13. google_client/services/calendar/types.py +403 -0
  14. google_client/services/calendar/utils.py +338 -0
  15. google_client/services/drive/__init__.py +13 -0
  16. google_client/services/drive/api_service.py +1133 -0
  17. google_client/services/drive/constants.py +37 -0
  18. google_client/services/drive/exceptions.py +60 -0
  19. google_client/services/drive/query_builder.py +385 -0
  20. google_client/services/drive/types.py +242 -0
  21. google_client/services/drive/utils.py +392 -0
  22. google_client/services/gmail/__init__.py +16 -0
  23. google_client/services/gmail/api_service.py +715 -0
  24. google_client/services/gmail/constants.py +6 -0
  25. google_client/services/gmail/exceptions.py +45 -0
  26. google_client/services/gmail/query_builder.py +408 -0
  27. google_client/services/gmail/types.py +285 -0
  28. google_client/services/gmail/utils.py +426 -0
  29. google_client/services/tasks/__init__.py +12 -0
  30. google_client/services/tasks/api_service.py +561 -0
  31. google_client/services/tasks/constants.py +32 -0
  32. google_client/services/tasks/exceptions.py +35 -0
  33. google_client/services/tasks/query_builder.py +324 -0
  34. google_client/services/tasks/types.py +156 -0
  35. google_client/services/tasks/utils.py +224 -0
  36. google_client/user_client.py +208 -0
  37. google_client/utils/__init__.py +0 -0
  38. google_client/utils/datetime.py +144 -0
  39. 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
+
@@ -0,0 +1,12 @@
1
+ """Google Tasks API client."""
2
+
3
+ from .api_service import TasksApiService
4
+ from .types import Task, TaskList
5
+ from .query_builder import TaskQueryBuilder
6
+
7
+ __all__ = [
8
+ "TasksApiService",
9
+ "Task",
10
+ "TaskList",
11
+ "TaskQueryBuilder",
12
+ ]