File: pyedit-products/unzipped/PP4E/Internet/Email/mailtools/mailSender.py
"""
###############################################################################
send messages, add attachments (see __init__ for docs, test)
###############################################################################
"""
import mailconfig # client's mailconfig
import smtplib, os, mimetypes # mime: name to type
import email.utils, email.encoders # date string, base64
from .mailTool import MailTool, SilentMailTool # 4E: package-relative
from email.message import Message # general message, obj->text
from email.mime.multipart import MIMEMultipart # type-specific messages
from email.mime.audio import MIMEAudio # format/encode attachments
from email.mime.image import MIMEImage
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email.mime.application import MIMEApplication # 4E: use new app class
def fix_encode_base64(msgobj):
"""
4E: workaround for a genuine bug in Python 3.1 email package that prevents
mail text generation for binary parts encoded with base64 or other email
encodings; the normal email.encoder run by the constructor leaves payload
as bytes, even though it's encoded to base64 text form; this breaks email
text generation which assumes this is text and requires it to be str; net
effect is that only simple text part emails can be composed in Py 3.1 email
package as is - any MIME-encoded binary part cause mail text generation to
fail; this bug seems likely to go away in a future Python and email package,
in which case this should become a no-op; see Chapter 13 for more details;
"""
linelen = 76 # per MIME standards
from email.encoders import encode_base64
encode_base64(msgobj) # what email does normally: leaves bytes
text = msgobj.get_payload() # bytes fails in email pkg on text gen
if isinstance(text, bytes): # payload is bytes in 3.1, str in 3.2 alpha
text = text.decode('ascii') # decode to unicode str so text gen works
lines = [] # split into lines, else 1 massive line
text = text.replace('\n', '') # no \n present in 3.1, but futureproof me!
while text:
line, text = text[:linelen], text[linelen:]
lines.append(line)
msgobj.set_payload('\n'.join(lines))
def fix_text_required(encodingname):
"""
4E: workaround for str/bytes combination errors in email package; MIMEText
requires different types for different Unicode encodings in Python 3.1, due
to the different ways it MIME-encodes some types of text; see Chapter 13;
the only other alternative is using generic Message and repeating much code;
"""
from email.charset import Charset, BASE64, QP
charset = Charset(encodingname) # how email knows what to do for encoding
bodyenc = charset.body_encoding # utf8, others require bytes input data
return bodyenc in (None, QP) # ascii, latin1, others require str
class MailSender(MailTool):
"""
send mail: format a message, interface with an SMTP server;
works on any machine with Python+Inet, doesn't use cmdline mail;
a nonauthenticating client: see MailSenderAuth if login required;
4E: tracesize is num chars of msg text traced: 0=none, big=all;
4E: supports Unicode encodings for main text and text parts;
4E: supports header encoding, both full headers and email names;
"""
def __init__(self, smtpserver=None, tracesize=256):
self.smtpServerName = smtpserver or mailconfig.smtpservername
self.tracesize = tracesize
def sendMessage(self, From, To, Subj, extrahdrs, bodytext, attaches,
saveMailSeparator=(('=' * 80) + 'PY\n'),
bodytextEncoding='us-ascii',
attachesEncodings=None):
"""
format and send mail: blocks caller, thread me in a GUI;
bodytext is main text part, attaches is list of filenames,
extrahdrs is list of (name, value) tuples to be added;
raises uncaught exception if send fails for any reason;
saves sent message text in a local file if successful;
assumes that To, Cc, Bcc hdr values are lists of 1 or more already
decoded addresses (possibly in full name+<addr> format); client
must parse to split these on delimiters, or use multiline input;
note that SMTP allows full name+<addr> format in recipients;
4E: Bcc addrs now used for send/envelope, but header is dropped;
4E: duplicate recipients removed, else will get >1 copies of mail;
caveat: no support for multipart/alternative mails, just /mixed;
"""
# 4E: assume main body text is already in desired encoding;
# clients can decode to user pick, default, or utf8 fallback;
# either way, email needs either str xor bytes specifically;
if fix_text_required(bodytextEncoding):
if not isinstance(bodytext, str):
bodytext = bodytext.decode(bodytextEncoding)
else:
if not isinstance(bodytext, bytes):
bodytext = bodytext.encode(bodytextEncoding)
# make message root
# tbd: add html alt for plain text body? [4.1]
if not attaches:
msg = Message()
msg.set_payload(bodytext, charset=bodytextEncoding)
else:
msg = MIMEMultipart()
self.addAttachments(msg, bodytext, attaches,
bodytextEncoding, attachesEncodings)
# 4E: non-ASCII hdrs encoded on sends; encode just name in address,
# else smtp may drop the message completely; encodes all envelope
# To names (but not addr) also, and assumes servers will allow;
# msg.as_string retains any line breaks added by encoding headers;
hdrenc = mailconfig.headersEncodeTo or 'utf-8' # default=utf8
Subj = self.encodeHeader(Subj, hdrenc) # full header
From = self.encodeAddrHeader(From, hdrenc) # email names
To = [self.encodeAddrHeader(T, hdrenc) for T in To] # each recip
Tos = ', '.join(To) # hdr+envelope
# add headers to root
msg['From'] = From
msg['To'] = Tos # poss many: addr list
msg['Subject'] = Subj # servers reject ';' sept
msg['Date'] = email.utils.formatdate() # curr datetime, rfc2822 utc
recip = To
for name, value in extrahdrs: # Cc, Bcc, X-Mailer, etc.
if value:
if name.lower() not in ['cc', 'bcc', 'reply-to']: # [4.1] +reply-to
value = self.encodeHeader(value, hdrenc)
msg[name] = value
else:
value = [self.encodeAddrHeader(V, hdrenc) for V in value]
recip += value # some servers reject ['']
if name.lower() != 'bcc': # 4E: bcc gets mail, no hdr
msg[name] = ', '.join(value) # add commas between cc
recip = list(set(recip)) # 4E: remove duplicates
fullText = msg.as_string() # generate formatted msg
# sendmail call raises except if all Tos failed,
# or returns failed Tos dict for any that failed
self.trace('Sending to...' + str(recip))
self.trace(fullText[:self.tracesize]) # SMTP calls connect
# Dec2015 and Jan2014: support SSL/TLS (post book)
# TLS is an extension to basic SSL: use one or the other
# all use an optional ':port' at end of server name, else default for type
# unlike poplib, port stripped automatically if present
srvrandport = self.smtpServerName
# May2016: timeouts increased, configurable
timeout = mailconfig.smtpTimeout
if getattr(mailconfig, 'smtpusesSSL', False): # set and True?
# start ssl smtp session, encrypted # default port=465
print('Using SSL SMTP on', srvrandport)
server = smtplib.SMTP_SSL(srvrandport, timeout=timeout) # may fail too
elif getattr(mailconfig, 'smtpusesTLS', False): # set and True?
# start tls smtp session, encrypted # default port=25
print('Using TLS/SSL SMTP on', srvrandport)
server = smtplib.SMTP(srvrandport, timeout=timeout) # may fail too
server.starttls() # uses SSL auto
server.ehlo()
else:
# original/book: plain smtp # default port=25
print('Using basic SMTP on', srvrandport)
server = smtplib.SMTP(srvrandport, timeout=timeout) # may fail too
# back to original/book code
self.getPassword() # if srvr requires
self.authenticateServer(server) # login in subclass
try:
failed = server.sendmail(From, recip, fullText) # except or dict
except:
server.close() # 4E: quit may hang!
raise # reraise except
else:
server.quit() # connect + send OK
self.saveSentMessage(fullText, saveMailSeparator) # 4E: do this first
if failed:
class SomeAddrsFailed(Exception): pass
raise SomeAddrsFailed('Failed addrs:%s\n' % failed)
self.trace('Send exit')
def addAttachments(self, mainmsg, bodytext, attaches,
bodytextEncoding, attachesEncodings):
"""
format a multipart message with attachments;
use Unicode encodings for text parts if passed;
"""
# add main text/plain part
msg = MIMEText(bodytext, _charset=bodytextEncoding)
mainmsg.attach(msg)
# add attachment parts
encodings = attachesEncodings or (['us-ascii'] * len(attaches))
for (filename, fileencode) in zip(attaches, encodings):
# filename may be absolute or relative
if not os.path.isfile(filename): # skip dirs, etc.
continue
# guess content type from file extension, ignore encoding
contype, encoding = mimetypes.guess_type(filename)
if contype is None or encoding is not None: # no guess, compressed?
contype = 'application/octet-stream' # use generic default
self.trace('Adding ' + contype)
# build sub-Message of appropriate kind
maintype, subtype = contype.split('/', 1)
if maintype == 'text': # 4E: text needs encoding
if fix_text_required(fileencode): # requires str or bytes
data = open(filename, 'r', encoding=fileencode)
else:
data = open(filename, 'rb')
msg = MIMEText(data.read(), _subtype=subtype, _charset=fileencode)
data.close()
elif maintype == 'image':
data = open(filename, 'rb') # 4E: use fix for binaries
msg = MIMEImage(
data.read(), _subtype=subtype, _encoder=fix_encode_base64)
data.close()
elif maintype == 'audio':
data = open(filename, 'rb')
msg = MIMEAudio(
data.read(), _subtype=subtype, _encoder=fix_encode_base64)
data.close()
elif maintype == 'application': # new in 4E
data = open(filename, 'rb')
msg = MIMEApplication(
data.read(), _subtype=subtype, _encoder=fix_encode_base64)
data.close()
else:
data = open(filename, 'rb') # application/* could
msg = MIMEBase(maintype, subtype) # use this code too
msg.set_payload(data.read())
data.close() # make generic type
fix_encode_base64(msg) # was broken here too!
#email.encoders.encode_base64(msg) # encode using base64
# set filename (ascii or utf8/mime encoded) and attach to container
basename = self.encodeHeader(os.path.basename(filename)) # oct 2011
msg.add_header('Content-Disposition',
'attachment', filename=basename)
mainmsg.attach(msg)
# text outside mime structure, seen by non-MIME mail readers
mainmsg.preamble = 'A multi-part MIME format message.\n'
mainmsg.epilogue = '' # make sure message ends with a newline
def saveSentMessage(self, fullText, saveMailSeparator):
"""
append sent message to local file if send worked for any;
client: pass separator used for your application, splits;
caveat: user may change the file at same time (unlikely);
"""
try:
sentfile = open(mailconfig.sentmailfile, 'a',
encoding=mailconfig.fetchEncoding) # 4E
if fullText[-1] != '\n': fullText += '\n'
sentfile.write(saveMailSeparator)
sentfile.write(fullText)
sentfile.close()
except:
self.trace('Could not save sent message') # not a show-stopper
def encodeHeader(self, headertext, unicodeencoding='utf-8'):
"""
4E: encode composed non-ascii message headers content per both email
and Unicode standards, according to an optional user setting or UTF-8;
header.encode adds line breaks in header string automatically if needed;
"""
try:
headertext.encode('ascii')
except:
try:
hdrobj = email.header.make_header([(headertext, unicodeencoding)])
headertext = hdrobj.encode()
except:
pass # auto splits into multiple cont lines if needed
return headertext # smtplib may fail if it won't encode to ascii
def encodeAddrHeader(self, headertext, unicodeencoding='utf-8'):
"""
4E: try to encode non-ASCII names in email addresess per email, MIME,
and Unicode standards; if this fails drop name and use just addr part;
if cannot even get addresses, try to decode as a whole, else smtplib
may run into errors when it tries to encode the entire mail as ASCII;
utf-8 default should work for most, as it formats code points broadly;
inserts newlines if too long or hdr.encode split names to multiple lines,
but this may not catch some lines longer than the cutoff (improve me);
as used, Message.as_string formatter won't try to break lines further;
see also decodeAddrHeader in mailParser module for the inverse of this;
"""
try:
pairs = email.utils.getaddresses([headertext]) # split addrs + parts
encoded = []
for name, addr in pairs:
try:
name.encode('ascii') # use as is if okay as ascii
except UnicodeError: # else try to encode name part
try:
uni = name.encode(unicodeencoding)
hdr = email.header.make_header([(uni, unicodeencoding)])
name = hdr.encode()
except:
name = None # drop name, use address part only
joined = email.utils.formataddr((name, addr)) # quote name if need
encoded.append(joined)
fullhdr = ', '.join(encoded)
if len(fullhdr) > 72 or '\n' in fullhdr: # not one short line?
fullhdr = ',\n '.join(encoded) # try multiple lines
return fullhdr
except:
return self.encodeHeader(headertext)
def authenticateServer(self, server):
pass # no login required for this server/class
def getPassword(self):
pass # no login required for this server/class
################################################################################
# specialized subclasses
################################################################################
class MailSenderAuth(MailSender):
"""
use for servers that require login authorization;
client: choose MailSender or MailSenderAuth super
class based on mailconfig.smtpuser setting (None?)
"""
smtpPassword = None # 4E: on class, not self, shared by poss N instances
def __init__(self, smtpserver=None, smtpuser=None):
MailSender.__init__(self, smtpserver)
self.smtpUser = smtpuser or mailconfig.smtpuser
# self.smtpPassword = None # 4E: makes pyMailGUI ask for each send!
def authenticateServer(self, server):
server.login(self.smtpUser, self.smtpPassword)
def getPassword(self):
"""
get SMTP auth password if not yet known;
may be called by superclass auto, or client manual:
not needed until send, but don't run in GUI thread;
get from client-side file or subclass method
Dec2015: user rstrip() instead of [:-1] in case no \n
"""
if not self.smtpPassword:
try:
self.getPasswordFromFile()
except:
MailSenderAuth.smtpPassword = self.askSmtpPassword() # 4E: class
def getPasswordFromFile(self):
"""
Dec2015: factor this code out so callable from PyMailGUI main thread;
that program can't allow a mail thread to popup a password dialog;
any exceptions (e.g., non-existent file/path) handled by the caller;
"""
localfile = open(mailconfig.smtppasswdfile)
MailSenderAuth.smtpPassword = localfile.readline().rstrip() # 4E: class
#self.trace('local file password' + repr(self.smtpPassword)) # nope!
def askSmtpPassword(self):
"""
if run in a GUI's spawned thread, caller should avoid GUI popup
"""
assert False, 'Subclass must define method' # GUIs beware!
class MailSenderAuthConsole(MailSenderAuth):
def askSmtpPassword(self):
import getpass
prompt = 'Password for %s on %s?' % (self.smtpUser, self.smtpServerName)
return getpass.getpass(prompt)
class SilentMailSender(SilentMailTool, MailSender):
pass # replaces trace