Coverage for testrail_api_reporter/publishers/email_sender.py: 15%
154 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-29 15:21 +0200
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-29 15:21 +0200
1# -*- coding: utf-8 -*-
2""" Email sender module """
4import base64
5import os
6import smtplib
7from datetime import datetime
8from email.mime.image import MIMEImage
9from email.mime.multipart import MIMEMultipart
10from email.mime.text import MIMEText
12import httplib2
13from apiclient import discovery
14from oauth2client import client, tools, file
16from ..utils.logger_config import setup_logger, DEFAULT_LOGGING_LEVEL
17from ..utils.reporter_utils import format_error, check_captions_and_files
20class EmailSender:
21 """Email sender class"""
23 def __init__(
24 self,
25 email=None,
26 password=None,
27 server_smtp=None,
28 server_port=None,
29 gmail_token=None,
30 logger=None,
31 log_level=DEFAULT_LOGGING_LEVEL,
32 ):
33 """
34 General init
36 :param email: email of user, from which emails will be sent, string
37 :param password: password of user, from which emails will be sent, string
38 :param server_smtp: full smtp address (endpoint) of mail server, string
39 :param server_port: mail server port, integer
40 :gmail_token: gmail OAuth secret file (expected json)
41 :param logger: logger object
42 :param log_level: logging level, optional, by default is 'logging.DEBUG'
43 """
44 if not logger:
45 self.___logger = setup_logger(name="EmailSender", log_file="email_sender.log", level=log_level)
46 else:
47 self.___logger = logger
48 self.___logger.debug("EmailSender init")
49 self.__method = None
50 if email is not None and password is not None and server_smtp is not None and server_port is not None:
51 self.__method = "regular"
52 elif gmail_token and email:
53 gmail_token = f"{os.getcwd()}/{gmail_token}" if not os.path.exists(gmail_token) else gmail_token
54 if os.path.exists(gmail_token):
55 self.__method = "gmail"
56 self.__gmail_scopes = "https://www.googleapis.com/auth/gmail.send"
57 self.__gmail_app_name = "Gmail API Python Send Email"
58 if not self.__method:
59 raise ValueError("No email credentials are provided, aborted!")
60 self.__email = email
61 self.__password = password
62 self.__server_smtp = server_smtp
63 self.__server_port = server_port
64 self.__gmail_token = gmail_token
66 def send_message( # pylint: disable=too-many-branches
67 self,
68 files=None,
69 captions=None,
70 image_width="400px",
71 title=None,
72 timestamp=None,
73 recipients=None,
74 method=None,
75 custom_message=None,
76 custom_folder=os.path.join(os.path.expanduser("~"), ".credentials"),
77 ):
78 """
79 Send email to recipients with a report (with attached images)
81 :param files: list of filenames (maybe with path) with charts to attach to report, list of strings, required
82 :param captions: captions for charts, length should be equal to count of files, list of strings, optional
83 :param image_width: default image width, string, optional
84 :param title: title of report, string, optional
85 :param timestamp: non-default timestamp, string, optional, will be used only when title is not provided
86 :param recipients: list of recipient emails, list of strings, optional
87 :param method: method which will be used for sending
88 :param custom_message: custom message, prepared by user at his own, by default its payload with TR state report
89 :param custom_folder: custom home folder for gmail credentials storage, by default is ~/.credentials
90 :return: none
91 """
92 # Check params
93 if not method:
94 method = self.__method
95 if not isinstance(files, list) and not custom_message:
96 raise ValueError("No file list for report provided, aborted!")
97 if isinstance(recipients, str) and not custom_message:
98 recipients = [recipients]
99 elif not isinstance(recipients, list) and not custom_message:
100 raise ValueError("Wrong list of recipients is provided, aborted!")
101 captions = check_captions_and_files(
102 captions=captions,
103 files=files,
104 debug=self.___logger.level == DEFAULT_LOGGING_LEVEL,
105 logger=self.___logger,
106 )
107 if not captions or custom_message:
108 self.___logger.debug("No captions provided, no legend will be displayed")
109 timestamp = timestamp if timestamp else datetime.now().strftime("%Y-%m-%d")
110 title = title if title else f"Test development & automation coverage report for {timestamp}"
112 # Connect and send a message
113 if not custom_message:
114 message = self.__prepare_payload(
115 files=files,
116 captions=captions,
117 image_width=image_width,
118 title=title,
119 recipients=recipients,
120 method=method,
121 )
122 else:
123 self.___logger.debug("Ignoring payload preparations, assuming user custom message is right")
124 message = custom_message
125 if method == "regular":
126 connection = self.__connect_to_server()
127 self.__send_to_server(connection=connection, recipients=recipients, message=message)
128 self.__disconnect_from_server(connection=connection)
129 elif method == "gmail":
130 self.__gmail_send_message(message=message, custom_folder=custom_folder)
131 self.___logger.debug("Email sent!")
133 def __connect_to_server(self):
134 """
135 Connects to mail server
137 :return: connection handle ( smtplib.SMTP )
138 """
139 self.___logger.debug(
140 "Connecting to custom mail server %s:%s using %s", self.__server_smtp, self.__server_port, self.__email
141 )
142 try:
143 connection = smtplib.SMTP(self.__server_smtp, self.__server_port)
144 connection.ehlo()
145 connection.starttls()
146 connection.login(self.__email, self.__password)
147 except Exception as error:
148 raise ValueError(f"Can't login to mail!\nError{format_error(error)}") from error
149 return connection
151 def __send_to_server(self, connection, recipients, message):
152 """
153 Send data to server to mail server
155 :param connection: connection handle ( smtplib.SMTP )
156 :param recipients: list of recipient emails, list of strings, optional
157 :param message: formatted multipart message
158 :return: none
159 """
160 self.___logger.debug("Sending mail from %s to %s", self.__email, recipients)
161 try:
162 connection.sendmail(from_addr=self.__email, to_addrs=recipients, msg=message.as_string())
163 except Exception as error:
164 raise ValueError(f"Can't send mail!\nError{format_error(error)}") from error
166 def __disconnect_from_server(self, connection):
167 """
168 Connects to mail server
170 :param connection: connection handle ( smtplib.SMTP )
171 :return: none
172 """
173 self.___logger.debug("Disconnecting from custom mail server %s:%s", self.__server_smtp, self.__server_port)
174 try:
175 connection.quit()
176 except Exception as error:
177 raise ValueError(f"Can't close connection!\nError{format_error(error)}") from error
179 def __prepare_payload(self, files, image_width, title, recipients, captions=None, method=None):
180 """
181 Prepare payload method (mail content)
183 :param files: list of filenames (maybe with path) with charts to attach to report, list of strings, required
184 :param captions: captions for charts, length should be equal to count of files, list of strings, optional
185 :param image_width: default image width, string
186 :param title: title of report, string
187 :param recipients: list of recipient emails, list of strings, optional
188 :param method: specify which method is used to set proper MIMEMultipart type ('gmail' or not)
189 :return: formatted multipart message
190 """
191 message = MIMEMultipart("alternative") if method != "gmail" else MIMEMultipart()
192 message["Subject"] = title
193 message["From"] = self.__email
194 message["To"] = ", ".join(recipients)
195 html = (
196 '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">'
197 f"<title>{title}</title></head>"
198 f'<body><div align="center"><h3>{title}'
199 '</h3></div><table border="0px" width="100%"><tbody><td>'
200 )
201 for j, val in enumerate(files):
202 with open(f"{val}", "rb") as attachment:
203 mime_image = MIMEImage(attachment.read())
205 # Define the image's ID with counter as you will reference it.
206 mime_image.add_header("Content-ID", f"<image_id_{j}>")
207 mime_image.add_header("Content-Disposition", f"attachment; filename= {val}")
208 message.attach(mime_image)
209 # add to body
210 if captions:
211 html = f'{html}<tr><div align="center"><b>{captions[j]}</b></div></tr>'
212 html = (
213 f'{html}<tr><div align="center"><img src="cid:image_id_{j}" '
214 f'width="{image_width}" height="auto">></div></tr>'
215 )
216 html = f"{html}</td></tbody></table></body></html>"
217 message.attach(MIMEText(html, "html"))
218 return message
220 def __gmail_get_credential_path(self, custom_folder=os.path.join(os.path.expanduser("~"), ".credentials")):
221 """
222 Service function target Google OAuth credentials path to storage
224 :param custom_folder: custom home folder for gmail credentials storage, by default is ~/.credentials
225 :return: credentials file path (string)
226 """
227 self.___logger.debug("Checking GMail credentials path at %s", custom_folder)
228 try:
229 self.___logger.debug("No credential directory found, creating new one here: %s", custom_folder)
230 os.makedirs(custom_folder, exist_ok=True)
231 except OSError as error:
232 self.___logger.error("Can't create credential directory!\nError%s", format_error(error))
233 credential_path = os.path.join(custom_folder, "gmail-python-email-send.json")
234 return credential_path
236 def __gmail_get_credentials(self, custom_folder=os.path.join(os.path.expanduser("~"), ".credentials")):
237 """
238 Service function to get and convert Google OAuth credential from client_id and client_secret
240 :param custom_folder: custom home folder for gmail credentials storage, by default is ~/.credentials
241 :return: credentials
242 """
243 credential_path = self.__gmail_get_credential_path(custom_folder=custom_folder)
244 self.___logger.debug("Obtaining GMail credentials from %s", credential_path)
245 try:
246 store = file.Storage(credential_path)
247 except Exception as error:
248 raise ValueError(f"Couldn't open storage\nError{format_error(error)}") from error
249 try:
250 credentials = store.get()
251 except Exception as error:
252 raise ValueError(f"Obtaining of credentials unexpectedly failed\nError{format_error(error)}") from error
253 if not credentials or credentials.invalid:
254 try:
255 flow = client.flow_from_clientsecrets(self.__gmail_token, self.__gmail_scopes)
256 except Exception as error:
257 raise ValueError(
258 f"Couldn't obtain new client secrets from Google OAuth\nError{format_error(error)}"
259 ) from error
260 flow.user_agent = self.__gmail_app_name
261 try:
262 credentials = tools.run_flow(flow, store)
263 except Exception as error:
264 raise ValueError(
265 f"Couldn't obtain new credential from Google OAuth\nError{format_error(error)}"
266 ) from error
267 self.___logger.debug("Storing credentials to %s", credential_path)
268 return credentials
270 def __gmail_send_message(self, message, custom_folder=os.path.join(os.path.expanduser("~"), ".credentials")):
271 """
272 Send Email via GMail
274 :param message: message in MIME type format
275 :param custom_folder: custom home folder for gmail credentials storage, by default is ~/.credentials
276 :return: none
277 """
278 self.___logger.debug("Sending message using GMail")
279 credentials = self.__gmail_get_credentials(custom_folder=custom_folder)
280 try:
281 http = credentials.authorize(httplib2.Http())
282 except Exception as error:
283 raise ValueError(f"Can't authorize via Google OAuth\nError{format_error(error)}") from error
284 try:
285 service = discovery.build("gmail", "v1", http=http)
286 except Exception as error:
287 raise ValueError(f"Can't build service for Google OAuth\nError{format_error(error)}") from error
288 try:
289 raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
290 except Exception as error:
291 raise ValueError(f"Can't convert payload to base64\nError{format_error(error)}") from error
292 self.__gmail_send_message_internal(service, self.__email, {"raw": raw})
294 def __gmail_send_message_internal(self, service, user_id, message):
295 """
296 Low-level gmail sent function to send email via GMail API service
298 :param service: service API
299 :param user_id: user id, the same as "from" email field
300 :param message: formatted in base64 type encoded raw message
301 :return: message
302 """
303 try:
304 message = service.users().messages().send(userId=user_id, body=message).execute()
305 self.___logger.debug("Message sent with Id: %s", message["id"])
306 return message
307 except Exception as error:
308 raise ValueError(f"Can't send mail via GMail!\nError{format_error(error)}") from error