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

1# -*- coding: utf-8 -*- 

2""" Email sender module """ 

3 

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 

11 

12import httplib2 

13from apiclient import discovery 

14from oauth2client import client, tools, file 

15 

16from ..utils.logger_config import setup_logger, DEFAULT_LOGGING_LEVEL 

17from ..utils.reporter_utils import format_error, check_captions_and_files 

18 

19 

20class EmailSender: 

21 """Email sender class""" 

22 

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 

35 

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 

65 

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) 

80 

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}" 

111 

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!") 

132 

133 def __connect_to_server(self): 

134 """ 

135 Connects to mail server 

136 

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 

150 

151 def __send_to_server(self, connection, recipients, message): 

152 """ 

153 Send data to server to mail server 

154 

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 

165 

166 def __disconnect_from_server(self, connection): 

167 """ 

168 Connects to mail server 

169 

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 

178 

179 def __prepare_payload(self, files, image_width, title, recipients, captions=None, method=None): 

180 """ 

181 Prepare payload method (mail content) 

182 

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()) 

204 

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 

219 

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 

223 

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 

235 

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 

239 

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 

269 

270 def __gmail_send_message(self, message, custom_folder=os.path.join(os.path.expanduser("~"), ".credentials")): 

271 """ 

272 Send Email via GMail 

273 

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}) 

293 

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 

297 

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