cit-pydata 0.0.2.dev4__tar.gz → 0.0.2.dev6__tar.gz

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 (31) hide show
  1. {cit_pydata-0.0.2.dev4/src/cit_pydata.egg-info → cit_pydata-0.0.2.dev6}/PKG-INFO +5 -1
  2. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/pyproject.toml +3 -2
  3. cit_pydata-0.0.2.dev6/src/cit_pydata/mailer/__init__.py +1 -0
  4. cit_pydata-0.0.2.dev6/src/cit_pydata/mailer/api.py +280 -0
  5. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6/src/cit_pydata.egg-info}/PKG-INFO +5 -1
  6. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata.egg-info/SOURCES.txt +2 -0
  7. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata.egg-info/requires.txt +5 -0
  8. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/LICENSE +0 -0
  9. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/README.md +0 -0
  10. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/setup.cfg +0 -0
  11. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/__init__.py +0 -0
  12. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/aws/__init__.py +0 -0
  13. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/aws/api.py +0 -0
  14. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/box/__init__.py +0 -0
  15. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/box/api.py +0 -0
  16. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/joblog/__init__.py +0 -0
  17. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/joblog/api.py +0 -0
  18. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/marketo/__init__.py +0 -0
  19. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/marketo/api.py +0 -0
  20. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/salesforce/__init__.py +0 -0
  21. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/salesforce/api.py +0 -0
  22. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sfsync/__init__.py +0 -0
  23. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sfsync/api.py +0 -0
  24. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sftp/__init__.py +0 -0
  25. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sftp/api.py +0 -0
  26. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sql/__init__.py +0 -0
  27. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/sql/api.py +0 -0
  28. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/util/__init__.py +0 -0
  29. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata/util/api.py +0 -0
  30. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata.egg-info/dependency_links.txt +0 -0
  31. {cit_pydata-0.0.2.dev4 → cit_pydata-0.0.2.dev6}/src/cit_pydata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cit-pydata
3
- Version: 0.0.2.dev4
3
+ Version: 0.0.2.dev6
4
4
  Summary: Python clients for commonly used api services including Salesforce, SQL, Marketo, Box, SFSync*, and JobLog*
5
5
  Author-email: Dionis Wang <dwang@eab.com>, Andrew Fitch <afitch@eab.com>, Dagim Desta <ddesta@eab.com>
6
6
  Project-URL: Homepage, https://github.com/EAB-IT-DEV/cit-pydata
@@ -18,6 +18,9 @@ Provides-Extra: box
18
18
  Requires-Dist: boxsdk; extra == "box"
19
19
  Provides-Extra: joblog
20
20
  Requires-Dist: marketorestpython; extra == "joblog"
21
+ Provides-Extra: mailer
22
+ Requires-Dist: requests; extra == "mailer"
23
+ Requires-Dist: boto3; extra == "mailer"
21
24
  Provides-Extra: salesforce
22
25
  Requires-Dist: simple_salesforce; extra == "salesforce"
23
26
  Requires-Dist: pyforce; extra == "salesforce"
@@ -70,6 +73,7 @@ Requires-Dist: pandas; extra == "all"
70
73
  Requires-Dist: psycopg2-binary; extra == "all"
71
74
  Requires-Dist: python-dotenv; extra == "all"
72
75
  Requires-Dist: boto3; extra == "all"
76
+ Requires-Dist: requests; extra == "all"
73
77
  Dynamic: license-file
74
78
 
75
79
  # cit-pydata
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cit-pydata"
7
- version = "0.0.2.dev4"
7
+ version = "0.0.2.dev6"
8
8
  authors = [
9
9
  { name="Dionis Wang", email="dwang@eab.com" },{ name="Andrew Fitch", email="afitch@eab.com" },{ name="Dagim Desta", email="ddesta@eab.com" }
10
10
  ]
@@ -24,6 +24,7 @@ Issues = "https://github.com/EAB-IT-DEV/cit-pydata/issues"
24
24
  aws = ["aws-lambda-powertools", "boto3"]
25
25
  box = ["boxsdk"]
26
26
  joblog = ["marketorestpython"]
27
+ mailer = ["requests", "boto3"]
27
28
  salesforce = ["simple_salesforce","pyforce"]
28
29
  sftp = ["paramiko"]
29
30
  sql = ["SQLAlchemy<2.0", "numpy", "pandas"]
@@ -33,4 +34,4 @@ sql-psycopg2 = ["SQLAlchemy<2.0", "psycopg2-binary", "numpy", "pandas"]
33
34
  sql-mssql = ["SQLAlchemy<2.0", "pymssql", "numpy", "pandas"]
34
35
  sql-postgres = ["SQLAlchemy<2.0", "psycopg2-binary", "numpy", "pandas"]
35
36
  local = ["python-dotenv"]
36
- all = ["aws-lambda-powertools","pandas","boxsdk","marketorestpython","simple_salesforce","pyforce","paramiko","SQLAlchemy<2.0", "pyodbc","pymssql", "numpy", "pandas","psycopg2-binary","python-dotenv", "boto3"]
37
+ all = ["aws-lambda-powertools","pandas","boxsdk","marketorestpython","simple_salesforce","pyforce","paramiko","SQLAlchemy<2.0", "pyodbc","pymssql", "numpy", "pandas","psycopg2-binary","python-dotenv", "boto3", "requests"]
@@ -0,0 +1 @@
1
+ from .api import MailClient
@@ -0,0 +1,280 @@
1
+ import base64
2
+ import os
3
+ import re
4
+
5
+ from cit_pydata.util import api as util_api
6
+ from cit_pydata.aws import api as aws_api
7
+
8
+ EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
9
+ FILE_ALIAS_PATTERN = re.compile(r"^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*[a-zA-Z0-9_-]$")
10
+
11
+ GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
12
+ # Attachments above this size require a Graph upload session rather than an
13
+ # inline base64 fileAttachment. sendMail supports inline attachments up to ~3 MB.
14
+ MAX_INLINE_ATTACHMENT_BYTES = 3 * 1024 * 1024
15
+
16
+
17
+ class MailClient:
18
+ """
19
+ Sends email via the Microsoft Graph API (sendMail endpoint).
20
+
21
+ Authenticates with the OAuth2 client-credentials flow using an app
22
+ registration (tenant_id / client_id / client_secret) that has been granted
23
+ the application permission "Mail.Send". Those credentials are read from AWS
24
+ SSM Parameter Store as a JSON document, e.g.:
25
+
26
+ {
27
+ "tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
28
+ "client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
29
+ "client_secret": "xxxxxxxxxxxxxxxxxxxxxxxx"
30
+ }
31
+
32
+ A single MailClient can be reused to send multiple emails; the access token
33
+ is fetched lazily on first send and reused.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ ssm_parameter_name: str = "/eab-pydata/graph/config",
39
+ environment: str = None,
40
+ iam_user: str = None,
41
+ approved_senders: list = None,
42
+ logger=None,
43
+ ):
44
+ self.logger = util_api.get_logger(__name__, "INFO") if not logger else logger
45
+
46
+ self.ssm_parameter_name = ssm_parameter_name
47
+
48
+ # Only needed for local (non-AWS) auth; on AWS the execution role is used.
49
+ self.aws_environment = environment or util_api.get_environment_variable(
50
+ logger=self.logger, variable_name="aws_auth_environment"
51
+ )
52
+ self.aws_iam_user = iam_user or util_api.get_environment_variable(
53
+ logger=self.logger, variable_name="aws_auth_iam_user"
54
+ )
55
+
56
+ # When set, sender_email must match one of these (case-insensitive). None skips the check.
57
+ self.approved_senders = (
58
+ [s.lower() for s in approved_senders] if approved_senders else None
59
+ )
60
+
61
+ # Fetched lazily on first send.
62
+ self._access_token = None
63
+
64
+ @staticmethod
65
+ def is_valid_email(email: str) -> bool:
66
+ return EMAIL_PATTERN.match(email) is not None
67
+
68
+ @staticmethod
69
+ def is_valid_file_alias(alias: str) -> bool:
70
+ return FILE_ALIAS_PATTERN.match(alias) is not None
71
+
72
+ def _validate_email_list(self, email_list: list):
73
+ invalid = [e for e in email_list if not self.is_valid_email(e.strip())]
74
+ if invalid:
75
+ raise ValueError(
76
+ f"The following email addresses are invalid: {', '.join(invalid)}"
77
+ )
78
+
79
+ def _check_sender(self, sender_email: str):
80
+ if not self.is_valid_email(sender_email):
81
+ raise ValueError(f'Sender email "{sender_email}" is not a valid email')
82
+ if (
83
+ self.approved_senders is not None
84
+ and sender_email.lower() not in self.approved_senders
85
+ ):
86
+ raise ValueError(
87
+ f'Sender email "{sender_email}" is not in the approved sender list'
88
+ )
89
+
90
+ def _get_graph_config(self) -> dict:
91
+ """Reads the Graph app-registration credentials JSON from SSM."""
92
+ import json
93
+
94
+ ssm_client = aws_api.SSMClient(
95
+ environment=self.aws_environment,
96
+ iam_user=self.aws_iam_user,
97
+ logger=self.logger,
98
+ )
99
+ raw = ssm_client.get_parameter(
100
+ name=self.ssm_parameter_name, with_decryption=True
101
+ )
102
+ if not raw:
103
+ raise ValueError(
104
+ f'Graph config not found in SSM at "{self.ssm_parameter_name}"'
105
+ )
106
+ config = json.loads(raw)
107
+ for key in ("tenant_id", "client_id", "client_secret"):
108
+ if not config.get(key):
109
+ raise ValueError(f'Graph config is missing required key "{key}"')
110
+ return config
111
+
112
+ def _authenticate(self):
113
+ """Fetches and caches an OAuth2 access token via the client-credentials flow."""
114
+ import requests
115
+
116
+ if self._access_token is not None:
117
+ return
118
+
119
+ config = self._get_graph_config()
120
+ token_url = (
121
+ f"https://login.microsoftonline.com/{config['tenant_id']}"
122
+ "/oauth2/v2.0/token"
123
+ )
124
+ payload = {
125
+ "grant_type": "client_credentials",
126
+ "client_id": config["client_id"],
127
+ "client_secret": config["client_secret"],
128
+ "scope": "https://graph.microsoft.com/.default",
129
+ }
130
+
131
+ response = requests.post(token_url, data=payload)
132
+ try:
133
+ response.raise_for_status()
134
+ except Exception as e:
135
+ self.logger.error(f"Failed to authenticate to Microsoft Graph: {e}")
136
+ self.logger.error(response.text)
137
+ raise
138
+
139
+ self._access_token = response.json().get("access_token")
140
+ if not self._access_token:
141
+ raise ValueError("Microsoft Graph token response did not include an access_token")
142
+
143
+ def _build_attachment(self, attachment, attachment_alias: str = None) -> dict:
144
+ """
145
+ Builds a Graph fileAttachment dict from a filepath (str) or an object
146
+ exposing to_csv() (e.g. a pandas DataFrame).
147
+ """
148
+ if attachment_alias and not self.is_valid_file_alias(attachment_alias):
149
+ raise ValueError(
150
+ f'Attachment alias "{attachment_alias}" uses an unexpected format'
151
+ )
152
+
153
+ if isinstance(attachment, str):
154
+ if not os.path.exists(attachment):
155
+ raise ValueError(
156
+ f'Attachment file could not be found at path "{attachment}"'
157
+ )
158
+ filename = (
159
+ attachment_alias + os.path.splitext(attachment)[1]
160
+ if attachment_alias
161
+ else os.path.basename(attachment)
162
+ )
163
+ with open(attachment, "rb") as file_obj:
164
+ content_bytes = file_obj.read()
165
+ elif hasattr(attachment, "to_csv"):
166
+ filename = f"{attachment_alias}.csv" if attachment_alias else "attachment.csv"
167
+ content_bytes = attachment.to_csv(index=False).encode("utf-8")
168
+ else:
169
+ raise ValueError(
170
+ "attachment must be a filepath (str) or an object with a to_csv() "
171
+ "method (e.g. a pandas DataFrame)"
172
+ )
173
+
174
+ if len(content_bytes) > MAX_INLINE_ATTACHMENT_BYTES:
175
+ raise ValueError(
176
+ f'Attachment "{filename}" exceeds the {MAX_INLINE_ATTACHMENT_BYTES} '
177
+ "byte limit for inline Graph attachments"
178
+ )
179
+
180
+ return {
181
+ "@odata.type": "#microsoft.graph.fileAttachment",
182
+ "name": filename,
183
+ "contentBytes": base64.b64encode(content_bytes).decode("ascii"),
184
+ }
185
+
186
+ @staticmethod
187
+ def _to_recipient_list(emails: str) -> list:
188
+ return [
189
+ {"emailAddress": {"address": e.strip()}}
190
+ for e in emails.split(",")
191
+ if e.strip()
192
+ ]
193
+
194
+ def send_email(
195
+ self,
196
+ sender_email: str,
197
+ receiver_email: str,
198
+ subject: str,
199
+ body: str = None,
200
+ cc_email: str = None,
201
+ attachment=None,
202
+ attachment_alias: str = None,
203
+ body_type: str = "HTML",
204
+ save_to_sent_items: bool = False,
205
+ ) -> bool:
206
+ """
207
+ Sends an email via Microsoft Graph. Returns True on success, False on failure.
208
+
209
+ sender_email must be a mailbox the app registration is authorized to send
210
+ as. receiver_email/cc_email accept comma-separated addresses.
211
+ attachment can be a filepath (str) or an object with a to_csv() method
212
+ (e.g. a pandas DataFrame). attachment_alias renames the attachment to
213
+ something more recipient-friendly (e.g. "Contract_Details").
214
+ body_type is "HTML" or "Text".
215
+ """
216
+ import requests
217
+
218
+ if body_type not in ("HTML", "Text"):
219
+ self.logger.error('body_type must be "HTML" or "Text"')
220
+ return False
221
+
222
+ try:
223
+ self._check_sender(sender_email)
224
+ self._validate_email_list(receiver_email.split(","))
225
+ if cc_email:
226
+ self._validate_email_list(cc_email.split(","))
227
+ if not subject:
228
+ raise ValueError("subject cannot be empty")
229
+ except ValueError as e:
230
+ self.logger.error(e)
231
+ return False
232
+
233
+ message = {
234
+ "subject": subject,
235
+ "body": {
236
+ "contentType": body_type,
237
+ "content": body or "-No message was included by the sender-",
238
+ },
239
+ "toRecipients": self._to_recipient_list(receiver_email),
240
+ }
241
+ if cc_email:
242
+ message["ccRecipients"] = self._to_recipient_list(cc_email)
243
+
244
+ attachment_label = None
245
+ if attachment is not None:
246
+ try:
247
+ file_attachment = self._build_attachment(attachment, attachment_alias)
248
+ except ValueError as e:
249
+ self.logger.error(e)
250
+ return False
251
+ message["attachments"] = [file_attachment]
252
+ attachment_label = file_attachment["name"]
253
+
254
+ try:
255
+ self._authenticate()
256
+ except Exception:
257
+ return False
258
+
259
+ self.logger.info(
260
+ f"Preparing to send email via Graph: subject={subject}, sender={sender_email}, "
261
+ f"receiver={receiver_email}, cc={cc_email}, attachment={attachment_label}"
262
+ )
263
+
264
+ send_mail_url = f"{GRAPH_BASE_URL}/users/{sender_email}/sendMail"
265
+ headers = {
266
+ "Authorization": f"Bearer {self._access_token}",
267
+ "Content-Type": "application/json",
268
+ }
269
+ payload = {"message": message, "saveToSentItems": save_to_sent_items}
270
+
271
+ try:
272
+ response = requests.post(send_mail_url, headers=headers, json=payload)
273
+ response.raise_for_status()
274
+ self.logger.info("Email sent successfully")
275
+ return True
276
+ except Exception as e:
277
+ self.logger.error(f"Error occurred while sending the email: {e}")
278
+ if "response" in dir() and getattr(response, "text", None):
279
+ self.logger.error(response.text)
280
+ return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cit-pydata
3
- Version: 0.0.2.dev4
3
+ Version: 0.0.2.dev6
4
4
  Summary: Python clients for commonly used api services including Salesforce, SQL, Marketo, Box, SFSync*, and JobLog*
5
5
  Author-email: Dionis Wang <dwang@eab.com>, Andrew Fitch <afitch@eab.com>, Dagim Desta <ddesta@eab.com>
6
6
  Project-URL: Homepage, https://github.com/EAB-IT-DEV/cit-pydata
@@ -18,6 +18,9 @@ Provides-Extra: box
18
18
  Requires-Dist: boxsdk; extra == "box"
19
19
  Provides-Extra: joblog
20
20
  Requires-Dist: marketorestpython; extra == "joblog"
21
+ Provides-Extra: mailer
22
+ Requires-Dist: requests; extra == "mailer"
23
+ Requires-Dist: boto3; extra == "mailer"
21
24
  Provides-Extra: salesforce
22
25
  Requires-Dist: simple_salesforce; extra == "salesforce"
23
26
  Requires-Dist: pyforce; extra == "salesforce"
@@ -70,6 +73,7 @@ Requires-Dist: pandas; extra == "all"
70
73
  Requires-Dist: psycopg2-binary; extra == "all"
71
74
  Requires-Dist: python-dotenv; extra == "all"
72
75
  Requires-Dist: boto3; extra == "all"
76
+ Requires-Dist: requests; extra == "all"
73
77
  Dynamic: license-file
74
78
 
75
79
  # cit-pydata
@@ -13,6 +13,8 @@ src/cit_pydata/box/__init__.py
13
13
  src/cit_pydata/box/api.py
14
14
  src/cit_pydata/joblog/__init__.py
15
15
  src/cit_pydata/joblog/api.py
16
+ src/cit_pydata/mailer/__init__.py
17
+ src/cit_pydata/mailer/api.py
16
18
  src/cit_pydata/marketo/__init__.py
17
19
  src/cit_pydata/marketo/api.py
18
20
  src/cit_pydata/salesforce/__init__.py
@@ -14,6 +14,7 @@ numpy
14
14
  psycopg2-binary
15
15
  python-dotenv
16
16
  boto3
17
+ requests
17
18
 
18
19
  [aws]
19
20
  aws-lambda-powertools
@@ -28,6 +29,10 @@ marketorestpython
28
29
  [local]
29
30
  python-dotenv
30
31
 
32
+ [mailer]
33
+ requests
34
+ boto3
35
+
31
36
  [salesforce]
32
37
  simple_salesforce
33
38
  pyforce
File without changes