1# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> 2# This file is released under the GNU GPL, version 3 or a later revision. 3# For further details see the COPYING file 4import glob 5import logging 6import os 7import re 8import email 9import email.policy 10from email.encoders import encode_7or8bit 11from email.mime.text import MIMEText 12from email.mime.multipart import MIMEMultipart 13from email.mime.application import MIMEApplication 14import email.charset as charset 15import gpg 16 17from .attachment import Attachment 18from .. import __version__ 19from .. import helper 20from .. import crypto 21from ..settings.const import settings 22from ..errors import GPGProblem, GPGCode 23 24charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') 25 26 27class Envelope: 28 """ 29 a message that is not yet sent and still editable. 30 31 It holds references to unencoded! body text and mail headers among other 32 things. Envelope implements the python container API for easy access of 33 header values. So `e['To']`, `e['To'] = 'foo@bar.baz'` and 34 'e.get_all('To')' would work for an envelope `e`.. 35 """ 36 37 headers = None 38 """ 39 dict containing the mail headers (a list of strings for each header key) 40 """ 41 body_txt = None 42 """mail body (plaintext) as unicode string""" 43 body_html = None 44 """mail body (html) as unicode string""" 45 tmpfile = None 46 """template text for initial content""" 47 attachments = None 48 """list of :class:`Attachments <alot.db.attachment.Attachment>`""" 49 tags = [] 50 """tags to add after successful sendout""" 51 account = None 52 """account to send from""" 53 54 def __init__( 55 self, template=None, bodytext=None, headers=None, attachments=None, 56 sign=False, sign_key=None, encrypt=False, tags=None, replied=None, 57 passed=None, account=None): 58 """ 59 :param template: if not None, the envelope will be initialised by 60 :meth:`parsing <parse_template>` this string before 61 setting any other values given to this constructor. 62 :type template: str 63 :param bodytext: text used as body part 64 :type bodytext: str 65 :param headers: unencoded header values 66 :type headers: dict (str -> [unicode]) 67 :param attachments: file attachments to include 68 :type attachments: list of :class:`~alot.db.attachment.Attachment` 69 :param tags: tags to add after successful sendout and saving this msg 70 :type tags: list of str 71 :param replied: message being replied to 72 :type replied: :class:`~alot.db.message.Message` 73 :param passed: message being passed on 74 :type replied: :class:`~alot.db.message.Message` 75 :param account: account to send from 76 :type account: :class:`Account` 77 """ 78 logging.debug('TEMPLATE: %s', template) 79 if template: 80 self.parse_template(template) 81 logging.debug('PARSED TEMPLATE: %s', template) 82 logging.debug('BODY: %s', self.body_txt) 83 self.body_txt = bodytext or '' 84 # TODO: if this was as collections.defaultdict a number of methods 85 # could be simplified. 86 self.headers = headers or {} 87 self.attachments = list(attachments) if attachments is not None else [] 88 self.sign = sign 89 self.sign_key = sign_key 90 self.encrypt = encrypt 91 self.encrypt_keys = {} 92 self.tags = tags or [] # tags to add after successful sendout 93 self.replied = replied # message being replied to 94 self.passed = passed # message being passed on 95 self.sent_time = None 96 self.modified_since_sent = False 97 self.sending = False # semaphore to avoid accidental double sendout 98 self.account = account 99 100 def __str__(self): 101 return "Envelope (%s)\n%s" % (self.headers, self.body_txt) 102 103 def __setitem__(self, name, val): 104 """setter for header values. This allows adding header like so: 105 envelope['Subject'] = 'sm\xf8rebr\xf8d' 106 """ 107 if name not in self.headers: 108 self.headers[name] = [] 109 self.headers[name].append(val) 110 111 if self.sent_time: 112 self.modified_since_sent = True 113 114 def __getitem__(self, name): 115 """getter for header values. 116 :raises: KeyError if undefined 117 """ 118 return self.headers[name][0] 119 120 def __delitem__(self, name): 121 del self.headers[name] 122 123 if self.sent_time: 124 self.modified_since_sent = True 125 126 def __contains__(self, name): 127 return name in self.headers 128 129 def get(self, key, fallback=None): 130 """secure getter for header values that allows specifying a `fallback` 131 return string (defaults to None). This returns the first matching value 132 and doesn't raise KeyErrors""" 133 if key in self.headers: 134 value = self.headers[key][0] 135 else: 136 value = fallback 137 return value 138 139 def get_all(self, key, fallback=None): 140 """returns all header values for given key""" 141 if key in self.headers: 142 value = self.headers[key] 143 else: 144 value = fallback or [] 145 return value 146 147 def add(self, key, value): 148 """add header value""" 149 if key not in self.headers: 150 self.headers[key] = [] 151 self.headers[key].append(value) 152 153 if self.sent_time: 154 self.modified_since_sent = True 155 156 def attach(self, attachment, filename=None, ctype=None): 157 """ 158 attach a file 159 160 :param attachment: File to attach, given as 161 :class:`~alot.db.attachment.Attachment` object or path to a file. 162 :type attachment: :class:`~alot.db.attachment.Attachment` or str 163 :param filename: filename to use in content-disposition. 164 Will be ignored if `path` matches multiple files 165 :param ctype: force content-type to be used for this attachment 166 :type ctype: str 167 """ 168 169 if isinstance(attachment, Attachment): 170 self.attachments.append(attachment) 171 elif isinstance(attachment, str): 172 path = os.path.expanduser(attachment) 173 part = helper.mimewrap(path, filename, ctype) 174 self.attachments.append(Attachment(part)) 175 else: 176 raise TypeError('attach accepts an Attachment or str') 177 178 if self.sent_time: 179 self.modified_since_sent = True 180 181 def construct_mail(self): 182 """ 183 Compiles the information contained in this envelope into a 184 :class:`email.Message`. 185 """ 186 # Build body text part. To properly sign/encrypt messages later on, we 187 # convert the text to its canonical format (as per RFC 2015). 188 canonical_format = self.body_txt.encode('utf-8') 189 textpart = MIMEText(canonical_format, 'plain', 'utf-8') 190 inner_msg = textpart 191 192 if self.body_html: 193 htmlpart = MIMEText(self.body_html, 'html', 'utf-8') 194 inner_msg = MIMEMultipart('alternative') 195 inner_msg.attach(textpart) 196 inner_msg.attach(htmlpart) 197 198 # wrap everything in a multipart container if there are attachments 199 if self.attachments: 200 msg = MIMEMultipart('mixed') 201 msg.attach(inner_msg) 202 # add attachments 203 for a in self.attachments: 204 msg.attach(a.get_mime_representation()) 205 inner_msg = msg 206 207 if self.sign: 208 plaintext = inner_msg.as_bytes(policy=email.policy.SMTP) 209 logging.debug('signing plaintext: %s', plaintext) 210 211 try: 212 signatures, signature_str = crypto.detached_signature_for( 213 plaintext, [self.sign_key]) 214 if len(signatures) != 1: 215 raise GPGProblem("Could not sign message (GPGME " 216 "did not return a signature)", 217 code=GPGCode.KEY_CANNOT_SIGN) 218 except gpg.errors.GPGMEError as e: 219 if e.getcode() == gpg.errors.BAD_PASSPHRASE: 220 # If GPG_AGENT_INFO is unset or empty, the user just does 221 # not have gpg-agent running (properly). 222 if os.environ.get('GPG_AGENT_INFO', '').strip() == '': 223 msg = "Got invalid passphrase and GPG_AGENT_INFO\ 224 not set. Please set up gpg-agent." 225 raise GPGProblem(msg, code=GPGCode.BAD_PASSPHRASE) 226 else: 227 raise GPGProblem("Bad passphrase. Is gpg-agent " 228 "running?", 229 code=GPGCode.BAD_PASSPHRASE) 230 raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_SIGN) 231 232 micalg = crypto.RFC3156_micalg_from_algo(signatures[0].hash_algo) 233 unencrypted_msg = MIMEMultipart( 234 'signed', micalg=micalg, protocol='application/pgp-signature') 235 236 # wrap signature in MIMEcontainter 237 stype = 'pgp-signature; name="signature.asc"' 238 signature_mime = MIMEApplication( 239 _data=signature_str.decode('ascii'), 240 _subtype=stype, 241 _encoder=encode_7or8bit) 242 signature_mime['Content-Description'] = 'signature' 243 signature_mime.set_charset('us-ascii') 244 245 # add signed message and signature to outer message 246 unencrypted_msg.attach(inner_msg) 247 unencrypted_msg.attach(signature_mime) 248 unencrypted_msg['Content-Disposition'] = 'inline' 249 else: 250 unencrypted_msg = inner_msg 251 252 if self.encrypt: 253 plaintext = unencrypted_msg.as_bytes(policy=email.policy.SMTP) 254 logging.debug('encrypting plaintext: %s', plaintext) 255 256 try: 257 encrypted_str = crypto.encrypt( 258 plaintext, list(self.encrypt_keys.values())) 259 except gpg.errors.GPGMEError as e: 260 raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_ENCRYPT) 261 262 outer_msg = MIMEMultipart('encrypted', 263 protocol='application/pgp-encrypted') 264 265 version_str = 'Version: 1' 266 encryption_mime = MIMEApplication(_data=version_str, 267 _subtype='pgp-encrypted', 268 _encoder=encode_7or8bit) 269 encryption_mime.set_charset('us-ascii') 270 271 encrypted_mime = MIMEApplication( 272 _data=encrypted_str.decode('ascii'), 273 _subtype='octet-stream', 274 _encoder=encode_7or8bit) 275 encrypted_mime.set_charset('us-ascii') 276 outer_msg.attach(encryption_mime) 277 outer_msg.attach(encrypted_mime) 278 279 else: 280 outer_msg = unencrypted_msg 281 282 headers = self.headers.copy() 283 284 # add Date header 285 if 'Date' not in headers: 286 headers['Date'] = [email.utils.formatdate(localtime=True)] 287 288 # add Message-ID 289 if 'Message-ID' not in headers: 290 domain = self.account.message_id_domain 291 headers['Message-ID'] = [email.utils.make_msgid(domain=domain)] 292 293 if 'User-Agent' in headers: 294 uastring_format = headers['User-Agent'][0] 295 else: 296 uastring_format = settings.get('user_agent').strip() 297 uastring = uastring_format.format(version=__version__) 298 if uastring: 299 headers['User-Agent'] = [uastring] 300 301 # copy headers from envelope to mail 302 for k, vlist in headers.items(): 303 for v in vlist: 304 outer_msg.add_header(k, v) 305 306 return outer_msg 307 308 def parse_template(self, raw, reset=False, only_body=False, 309 target_body='plaintext'): 310 """Parse a template or user edited string to fills this envelope. 311 312 :param raw: the string to parse. 313 :type raw: str 314 :param reset: remove previous envelope content 315 :type reset: bool 316 :param only_body: do not parse headers 317 :type only_body: bool 318 :param target_body: body text alternative this should be stored in; 319 can be 'plaintext' or 'html' 320 :type reset: str 321 """ 322 logging.debug('GoT: """\n%s\n"""', raw) 323 324 if self.sent_time: 325 self.modified_since_sent = True 326 327 if reset: 328 self.headers = {} 329 330 headerEndPos = 0 331 if not only_body: 332 # go through multiline, utf-8 encoded headers 333 # locally, lines are separated by a simple LF, not CRLF 334 # we decode the edited text ourselves here as 335 # email.message_from_file can't deal with raw utf8 header values 336 headerRe = re.compile(r'^(?P<k>.+?):(?P<v>(.|\n[ \t\r\f\v])+)$', 337 re.MULTILINE) 338 for header in headerRe.finditer(raw): 339 if header.start() > headerEndPos + 1: 340 break # switched to body 341 342 key = header.group('k') 343 # simple unfolding as decribed in 344 # https://tools.ietf.org/html/rfc2822#section-2.2.3 345 unfoldedValue = header.group('v').replace('\n', '') 346 self.add(key, unfoldedValue.strip()) 347 headerEndPos = header.end() 348 349 # interpret 'Attach' pseudo header 350 if 'Attach' in self: 351 to_attach = [] 352 for line in self.get_all('Attach'): 353 gpath = os.path.expanduser(line.strip()) 354 to_attach += [g for g in glob.glob(gpath) 355 if os.path.isfile(g)] 356 logging.debug('Attaching: %s', to_attach) 357 for path in to_attach: 358 self.attach(path) 359 del self['Attach'] 360 361 body = raw[headerEndPos:].strip() 362 if target_body == 'html': 363 self.body_html = body 364 else: 365 self.body_txt = body 366