1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180

181

182

183

184

185

186

187

188

189

190

191

192

193

194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212

213

214

215

216

217

218

219

220

221

222

223

224

225

226

227

228

229

230

231

232

233

234

235

236

237

238

239

240

241

242

243

244

245

246

247

248

249

250

251

252

253

254

# Copyright 2013-2015 The Distro Tracker Developers 

# See the COPYRIGHT file at the top-level directory of this distribution and 

# at https://deb.li/DTAuthors 

# 

# This file is part of Distro Tracker. It is subject to the license terms 

# in the LICENSE file found in the top-level directory of this 

# distribution and at https://deb.li/DTLicense. No part of Distro Tracker, 

# including this file, may be copied, modified, propagated, or distributed 

# except according to the terms contained in the LICENSE file. 

 

""" 

Module including some utility functions and classes for manipulating email. 

""" 

import copy 

import email 

import io 

import re 

import types 

from email.mime.base import MIMEBase 

 

from django.core.mail import EmailMessage 

from django.utils.encoding import force_bytes 

 

 

def extract_email_address_from_header(header): 

""" 

Extracts the email address from the From email header. 

 

>>> str(extract_email_address_from_header('Real Name <foo@domain.com>')) 

'foo@domain.com' 

>>> str(extract_email_address_from_header('foo@domain.com')) 

'foo@domain.com' 

""" 

from email.utils import parseaddr 

real_name, from_address = parseaddr(str(header)) 

return from_address 

 

 

def name_and_address_from_string(content): 

""" 

Takes an address in almost-RFC822 format and turns it into a dict 

{'name': real_name, 'email': email_address} 

 

The difference with email.utils.parseaddr and rfc822.parseaddr 

is that this routine allows unquoted commas to appear in the real name 

(in violation of RFC822). 

""" 

from email.utils import parseaddr 

hacked_content = content.replace(",", "WEWANTNOCOMMAS") 

name, mail = parseaddr(hacked_content) 

if mail: 

return { 

'name': name.replace("WEWANTNOCOMMAS", ","), 

'email': mail.replace("WEWANTNOCOMMAS", ",") 

} 

else: 

return None 

 

 

def names_and_addresses_from_string(content): 

""" 

Takes a string with addresses in RFC822 format and returns a list of dicts 

{'name': real_name, 'email': email_address} 

It tries to be forgiving about unquoted commas in addresses. 

""" 

all_parts = [ 

name_and_address_from_string(part) 

for part in re.split(r'(?<=>)\s*,\s*', content) 

] 

return [ 

part 

for part in all_parts 

if part is not None 

] 

 

 

def get_decoded_message_payload(message, default_charset='utf-8'): 

""" 

Extracts the payload of the given ``email.message.Message`` and returns it 

decoded based on the Content-Transfer-Encoding and charset. 

""" 

# If the message is multipart there is nothing to decode so None is 

# returned 

84 ↛ 85line 84 didn't jump to line 85, because the condition on line 84 was never true if message.is_multipart(): 

return None 

# Decodes the message based on transfer encoding and returns bytes 

payload = message.get_payload(decode=True) 

if payload is None: 

return None 

 

# The charset defaults to ascii if none is given 

charset = message.get_content_charset(default_charset) 

try: 

return payload.decode(charset) 

except (UnicodeDecodeError, LookupError): 

# If we did not get the charset right, assume it's latin1 and make 

# sure to not fail furter 

return payload.decode('latin1', 'replace') 

 

 

def patch_message_for_django_compat(message): 

""" 

Live patch the :py:class:`email.message.Message` object passed as 

parameter so that: 

 

* the ``as_string()`` method return the same set of bytes it has been parsed 

from (to preserve as much as possible the original message) 

* the ``as_bytes()`` is added too (this method is expected by Django's SMTP 

backend) 

""" 

# Django expects patched versions of as_string/as_bytes, see 

# django/core/mail/message.py 

def as_string(self, unixfrom=False, maxheaderlen=0, linesep='\n'): 

""" 

Returns the payload of the message encoded as bytes. 

""" 

from email.generator import BytesGenerator as Generator 

fp = io.BytesIO() 

g = Generator(fp, mangle_from_=False, maxheaderlen=maxheaderlen) 

g.flatten(self, unixfrom=unixfrom, linesep=linesep) 

return force_bytes(fp.getvalue(), 'utf-8') 

 

message.as_string = types.MethodType(as_string, message) 

message.as_bytes = message.as_string 

return message 

 

 

def message_from_bytes(message_bytes): 

""" 

Returns a live-patched :class:`email.Message` object from the given 

bytes. 

 

The changes ensure that parsing the message's bytes with this method 

and then returning them by using the returned object's as_string 

method is an idempotent operation. 

 

An as_bytes method is also created since Django's SMTP backend relies 

on this method (which is usually brought by its own 

:class:`django.core.mail.SafeMIMEText` object but that we don't use 

in our :class:`CustomEmailMessage`). 

""" 

from email import message_from_bytes as email_message_from_bytes 

message = email_message_from_bytes(message_bytes) 

 

return patch_message_for_django_compat(message) 

 

 

class CustomEmailMessage(EmailMessage): 

""" 

A subclass of :class:`django.core.mail.EmailMessage` which can be fed 

an :class:`email.message.Message` instance to define the body of the 

message. 

 

If :attr:`msg` is set, the :attr:`body <django.core.mail.EmailMessage.body>` 

attribute is ignored. 

 

If the user wants to attach additional parts to the message, the 

:meth:`attach` method can be used but the user must ensure that the given 

``msg`` instance is a multipart message before doing so. 

 

Effectively, this is also a wrapper which allows sending instances of 

:class:`email.message.Message` via Django email backends. 

""" 

def __init__(self, msg=None, *args, **kwargs): 

""" 

Use the keyword argument ``msg`` to set the 

:class:`email.message.Message` instance which should be used to define 

the body of the message. The original object is copied. 

 

If no ``msg`` is set, the object's behaviour is identical to 

:class:`django.core.mail.EmailMessage` 

""" 

super(CustomEmailMessage, self).__init__(*args, **kwargs) 

self.msg = msg 

 

def message(self): 

""" 

Returns the underlying :class:`email.message.Message` object. 

In case the user did not set a :attr:`msg` attribute for this instance 

the parent :meth:`EmailMessage.message 

<django.core.mail.EmailMessage.message>` method is used. 

""" 

183 ↛ 187line 183 didn't jump to line 187, because the condition on line 183 was never false if self.msg: 

msg = self._attach_all() 

return msg 

else: 

return EmailMessage.message(self) 

 

def _attach_all(self): 

""" 

Attaches all existing attachments to the given message ``msg``. 

""" 

msg = self.msg 

194 ↛ 195line 194 didn't jump to line 195, because the condition on line 194 was never true if self.attachments: 

assert self.msg.is_multipart() 

msg = copy.deepcopy(self.msg) 

for attachment in self.attachments: 

if isinstance(attachment, MIMEBase): 

msg.attach(attachment) 

else: 

msg.attach(self._create_attachment(*attachment)) 

return msg 

 

 

def decode_header(header, default_encoding='utf-8'): 

""" 

Decodes an email message header and returns it coded as a unicode 

string. 

 

This is necessary since it is possible that a header is made of multiple 

differently encoded parts which makes :func:`email.header.decode_header` 

insufficient. 

""" 

if header is None: 

return None 

decoded_header = email.header.decode_header(header) 

# Join all the different parts of the header into a single unicode string 

result = '' 

for part, encoding in decoded_header: 

if encoding == 'unknown-8bit': 

# Python 3 returns unknown-8bit instead of None when you have 8bit 

# characters without any encoding information 

encoding = 'iso-8859-1' 

if isinstance(part, bytes): 

encoding = encoding if encoding else default_encoding 

try: 

result += part.decode(encoding) 

except UnicodeDecodeError: 

result += part.decode('iso-8859-1', 'replace') 

else: 

result += part 

return result 

 

 

def unfold_header(header): 

""" 

Unfolding is the process to remove the line wrapping added by mail agents. 

A header is a single logical line and they are not allowed to be multi-line 

values. 

 

We need to unfold their values in particular when we want to reuse the 

values to compose a reply message as Python's email API chokes on those 

newline characters. 

 

If header is None, the return value is None as well. 

 

:param:header: the header value to unfold 

:type param: str 

:returns: the unfolded version of the header. 

:rtype: str 

""" 

if header is None: 

return None 

return re.sub(r'\r?\n(\s)', r'\1', str(header), 0, re.MULTILINE)