Coverage for testrail_api_reporter/publishers/email_sender.py: 15%

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

2""" Email sender module """

3

4 import base64

5 import os

6 import smtplib

7 from datetime import datetime

8 from email.mime.image import MIMEImage

9 from email.mime.multipart import MIMEMultipart

10 from email.mime.text import MIMEText

11

12 import httplib2

13 from apiclient import discovery

14 from oauth2client import client, tools, file

15

16 from ..utils.logger_config import setup_logger, DEFAULT_LOGGING_LEVEL

17 from ..utils.reporter_utils import format_error, check_captions_and_files

18

19

20 class 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