Класс MailSender

klass mailsender Сценарии на стороне клиента

В примере 13.23 представлен класс, используемый для составления и отправки сообщений. Этот модуль предоставляет удобный интерфейс, объединяющий в себе инструменты из стандартной библиотеки, с которыми мы уже встречались в этой главе, — пакет email для составления сообщений с вложениями и их кодирования и модуль smtplib — для отправки получившегося текста сообщений. Вложения передаются в виде списка имен файлов — типы MIME и любые необходимые кодировки определяются автоматически, с помощью модуля mimetypes. Кроме того, с помощью функций из модуля email.utils автоматизировано создание строк с датой и временем, а заголовки, содержащие символы не из диапазона ASCII, кодируются в соответствии со стандартами электронной почты, MIME и Юникода. Более подробные сведения об особенностях работы класса вы найдете в программном коде и в комментариях внутри этого файла.

Проблемы поддержки Юникода при работе с вложениями заголовками и при сохранении файлов

Этот класс также открывает и вкладывает файлы, генерирует полный текст сообщений и сохраняет отправленные сообщения в локальном файле. Большинство файлов вложений открывается в двоичном режиме, но, как мы уже видели, некоторые текстовые вложения необходимо открывать в текстовом режиме, потому что текущая версия пакета email требует передавать их конструкторам объектов сообщений в виде строк str. Кроме того, выше мы также видели, что пакет email требует, чтобы вложения были представлены в виде строк str, когда позднее будет генерироваться полный текст сообщения, возможно, как результат преобразования в формат MIME.

Чтобы удовлетворить эти требования пакета email в Python 3.1, необходимо применить два обходных решения, описанных выше, — исходя из того, как пакет email обрабатывает данные, передавать функции open флаг текстового или двоичного режима (и тем самым обеспечить чтение данных в виде строки str или bytes) и выполнять преобразование двоичных данных в формат MIME, чтобы потом декодировать результат в текст ASCII. Последняя операция также разбивает на строки текст с двоичными частями в формате Base64 (в отличие от email), потому что в противном случае будет отправлена одна длинная строка, что может быть допустимо в некоторых контекстах, но может вызывать проблемы в некоторых текстовых редакторах при просмотре необработанного текста. Вдобавок к этим обходным решениям клиенты могут передавать имена кодировок Юникода для основной текстовой части и для каждого текстового вложения в отдельности. В приложении PyMailGUI, которое будет представлено в главе 14, выбор кодировки осуществляется с помощью модуля с пользовательскими настройками mailconfig, и всякий раз, когда с помощью пользовательских настроек оказывается невозможным закодировать текстовую часть, применяется кодировка UTF-8. В принципе, можно было бы также перехватывать ошибки декодирования файла и возвращать строку с сообщением об ошибке (как это делается в классе получения почты, обсуждаемом далее), но отправка недопустимого вложения может иметь более печальные последствия, чем его отображение. Поэтому в случае появления ошибок вся операция отправки терпит неудачу.

Наконец, реализованы также новая поддержка кодирования заголовков с символами не из диапазона ASCII (полных заголовков и компонентов имен в заголовках с адресами) с применением кодировки, выбираемой клиентом, или UTF-8 по умолчанию, и сохранение отправленного сообщения в файл, открытый с указанием той же кодировки, определяемой с помощью модуля mailconfig, которая использовалась для декодирования поступающих сообщений.

Последнее правило, применяемое при сохранении отправленных сообщений, используется потому, что позднее файл с отправленными сообщениями может быть открыт клиентами, применяющими ту же схему кодирования, для извлечения полного текста почтового сообщения в этой же кодировке. Это отражает способ, каким клиенты, такие как PyMailGUI, сохраняют полный текст сообщения в локальном файле, чтобы позднее его можно было открыть и вывести содержимое. Этот прием может приводить к неудаче, если механизм извлечения почты попытается применить другую, несовместимую кодировку. Он предполагает, что в файле не будет сохранено ни одно сообщение в несовместимой кодировке, даже при многократных сохранениях. Мы могли бы попробовать создать отдельный файл для каждой кодировки, при этом предполагая, что одна кодировка относится ко всему полному тексту сообщения. Стандартом предусмотрено, что полный текст сообщения должен быть в кодировке ASCII, поэтому скорее всего это будет 7- или 8-битовый текст.

Пример 13.23. PP4E\Internet\Email\mailtools\mailSender.py

############################################################################ отправляет сообщения, добавляет вложения (описание и тест приводятся

в модуле __init__)

############################################################################

import mailconfig # клиентские настройки

import smtplib, os, mimetypes # mime: имя в тип

import email.utils, email.encoders # строка с датой, base64

from .mailTool import MailTool, SilentMailTool # 4E: относительно пакета

from email.message import Message # объект сообщения, obj->text

from email.mime.multipart import MIMEMultipart # специализированные # объекты

from email.mime.audio import MIMEAudio # вложений с поддержкой

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: использовать новый # класс приложения

def fix_encode_base64(msgobj):

4E: реализация обходного решения для ошибки в пакете email в Python 3.1, препятствующей созданию полного текста сообщения с двоичными частями,
преобразованными в формат
base64 или другой формат электронной почты; функция email.encoder, вызываемая конструктором, оставляет содержимое в виде строки bytes, даже при том, что оно находится в текстовом формате base64; это препятствует работе механизма создания полного текста сообщения, который предполагает получить текстовые данные и поэтому требует, чтобы они имели тип str; в результате этого с помощью пакета email в Py 3.1 можно создавать только простейшие текстовые части сообщений — любая двоичная часть в формате MIME будет вызывать ошибку на этапе создания полного текста сообщения; есть сведения, что эта ошибка будет устранена в будущих версиях Python и пакета email, в этом случае данная функция не должна выполнять никаких действий; подробности смотрите в главе 13;

linelen = 76 # согласно стандартам MIME from email.encoders import encode_base64

подпись: encode_base64(msgobj) #
text = msgobj.get_payload() # #
if isinstance(text, bytes):
text = text.decode('ascii')
что обычно делает email: оставляет bytes bytes выз. ош. в email

при создании текста

#  содержимое — bytes в 3.1, str в 3.2

#  декодировать в str,

#  чтобы сгенерировать текст

lines = [] # разбить на строки, иначе 1 большая строка

text = text.replace(‘\n‘, ») # в 3.1 нет \n, но что будет потом!

while text:

line, text = text[:linelen], text[linelen:]

lines.append(line)

msgobj.set_payload(‘\n’.join(lines))

def fix_text_required(encodingname):

4E: обходное решение для ошибки, вызываемой смешиванием str/bytes в пакете email; в Python 3.1 класс MIMEText требует передавать ему строки разных типов для текста в разных кодировках, что обусловлено преобразованием некоторых типов текста в разные форматы MIME; смотрите главу 13;

единственная альтернатива — использовать обобщенный класс Message и повторить большую часть программного кода;

from email.charset import Charset, BASE64, QP

charset = Charset(encodingname) # так email опр., что делать # для кодировки

bodyenc = charset.body_encoding # utf8 и др. требует данные типа bytes

return bodyenc in (None, QP) # ascii, latin1 и др. требует # данные типа str

class MailSender(MailTool)

отправляет сообщение: формирует сообщение, соединяется с SMTP-сервером; работает на любых компьютерах с Python+Интернет, не использует клиента командной строки; не выполняет аутентификацию: смотрите MailSenderAuth, если требуется аутентификация;

4E: tracesize — количество символов в трассировочном сообщении: 0=нет, большое значение=все;

4E: поддерживает кодирование Юникода для основного текста и текстовых частей;

4E: поддерживает кодирование заголовков — и полных, и компонента имени в адресах;

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

формирует и отправляет сообщение: блокирует вызывающую программу, в графических интерфейсах следует вызывать в отдельном потоке выполнения;

bodytext — основной текст, attaches — список имен файлов, extrahdrs — список кортежей (имя, значение) добавляемых заголовков; возбуждает исключение, если отправка не удалась по каким-либо причинам; в случае успеха сохраняет отправленное сообщение

в локальный файл; предполагается, что значения

для заголовков To, Cc, Bcc являются списками

из 1 или более уже декодированных адресов (возможно, в полном формате имя+<адрес>); клиент должен сам выполнять анализ, чтобы разбить их по разделителям или использовать

многострочный ввод;

обратите внимание, что SMTP допускает использование полного формата имя+<адрес> в адресе получателя;

4E: адреса Bcc теперь используются для отправки, а заголовок отбрасывается;

4E: повторяющиеся адреса получателей отбрасываются, иначе они будут получать несколько копий письма;

предупреждение: не поддерживаются сообщения multipart/alternative,

только /mixed;

#  4E: предполагается, что основной текст уже в требуемой кодировке;

#  клиенты могут декодировать, используя кодировку по выбору

#  пользователя, по умолчанию или utf8;

#  так или иначе, email требует передать либо str, либо bytes;

if fix_text_required(bodytextEncoding):

if not isinstance(bodytext, str):

bodytext = bodytext.decode(bodytextEncoding) else:

if not isinstance(bodytext, bytes):

bodytext = bodytext.encode(bodytextEncoding)

#   создать корень сообщения if not attaches:

msg = Message()

msg.set_payload(bodytext, charset=bodytextEncoding) else:

msg = MIMEMultipart()

self.addAttachments(msg, bodytext, attaches, bodytextEncoding, attachesEncodings)

#   4E: неASCII заголовки кодируются; кодировать только имена

#   в адресах, иначе smtp может отвергнуть сообщение;

#   кодирует все имена в аргументе To (но не адреса),

#   предполагается, что это допустимо для сервера;

#   msg.as_string сохраняет все разрывы строк, # добавленные при кодировании заголовков;

hdrenc = mailconfig.headersEncodeTo or ‘utf-8’ # по умолчанию=utf8

Subj = self.encodeHeader(Subj, hdrenc) # полный заголовок

From = self.encodeAddrHeader(From, hdrenc) # имена в адресах

To = [self.encodeAddrHeader(T, hdrenc) for T in To] # каждый

# адрес

Tos = ‘, ‘.join(To) # заголовок+аргумент

#   добавить заголовки в корень сообщения

msg[‘From’] = From

msg[‘To‘] = Tos # возможно несколько: список адресов

msg[‘Subject‘] = Subj # серверы отвергают разделитель ‘;’

msg[‘Date‘] = email.utils.formatdate() # дата+время, rfc2822 utc

recip = To

for name, value in extrahdrs: # Cc, Bcc, X-Mailer и др. if value:

if name.lower() not in [‘cc’, ‘bcc’]:

value = self.encodeHeader(value, hdrenc) msg[name] = value else:

value = [self.encodeAddrHeader(V, hdrenc) for V in value] recip += value # некоторые серверы отвергают [»] if name.lower() != ‘bcc’: # 4E: bcc получает почту, # без заголовка

msg[name] = ‘, ‘.join(value) # доб. зап. между cc

recip = list(set(recip)) # 4E: удалить дубликаты fullText = msg.as_string() # сформировать сообщение

#   вызов sendmail возбудит исключение, если все адреса Tos ошибочны,

#   или вернет словарь с ошибочными адресами Tos

self.trace(‘Sending to…’ + str(recip))

self.trace(fullText[:self.tracesize]) # вызов SMTP # для соединения server = smtplib.SMTP(self.smtpServerName,

timeout=15) # также может дать ошибку

self.getPassword() # если сервер требует

self.authenticateServer(server) # регистрация в подклассе

try:

failed = server.sendmail(From, recip, fullText) # искл.

# или словарь except:

server.close() # 4E: заверш. может подвесить!

raise # повторно возбудить исключение

else:

server.quit() # соединение + отправка, успех

self.saveSentMessage(fullText, saveMailSeparator) # 4E: в первую # очередь 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):

формирует сообщение, состоящее из нескольких частей, добавляя вложения attachments; использует для текста указанную кодировку Юникода, если была передана;

#   добавить главную часть text/plain

msg = MIMEText(bodytext, _charset=bodytextEncoding)

mainmsg.attach(msg)

#   добавить части с вложениями

encodings = attachesEncodings or ([‘us-ascii’] * len(attaches))

for (filename, fileencode) in zip(attaches, encodings):

#   имя файла может содержать абсолюный или относительный путь

if not os.path.isfile(filename): # пропустить каталоги и пр. continue

#   определить тип содержимого по расширению имени файла,

#   игнорировать кодировку

contype, encoding = mimetypes.guess_type(filename)

if contype is None or encoding is not None: # не определено, сжат?

contype = ‘application/octet-stream’ # универсальный тип

self.trace(‘Adding ‘ + contype)

# сконструировать вложенный объект Message соответствующего типа maintype, subtype = contype.split(‘/’, 1) if maintype == ‘text’: # 4E: текст требует кодирования

if fix_text_required(fileencode): # требуется str или 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: обходной прием для двоичных 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’: # новый тип в 4E

data = open(filename, ‘rb’)

msg = MIMEApplication(data.read(), _subtype=subtype, _encoder=fix_encode_base64) data.close()

else:

data = open(filename, ‘rb’) # тип application/* мог бы

msg = MIMEBase(maintype, subtype) # обрабатываться здесь msg.set_payload(data.read()) data.close() # создание универс. типа

fix_encode_base64(msg) # также было нарушено!

#email.encoders.encode_base64(msg) # преобразовать в base64

#  установить имя файла и присоединить к контейнеру basename = os.path.basename(filename) msg.add_header(‘ContentDisposition‘, ‘attachment‘, filename=basename) mainmsg.attach(msg)

#   текст за пределами структуры mime, виден клиентам,

#   которые не могут декодировать формат MIME

mainmsg.preamble = A multipart MIME format message.\nmainmsg.epilogue = » # гарантировать завершение сообщения

# переводом строки

def saveSentMessage(self, fullText, saveMailSeparator): добавляет отправленное сообщение в конец локального файла, если письмо было отправлено хотя бы одному адресату;

клиент: определяет строку-разделитель, используемую приложением;

предупреждение: пользователь может изменить файл во время работы сценария (маловероятно);

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’) # не прекращает работу # сценария

def encodeHeader(self, headertext, unicodeencoding=’utf-8′):

4E: кодирует содержимое заголовков с символами не из диапазона ASCII в соответствии со стандартами электронной почты и Юникода, применяя кодировку пользователя или UTF-8; метод header.encode автоматически добавляет разрывы строк, если необходимо;

try:

headertext.encode(‘ascii’)

except:

try:

hdrobj = email.header.make_header([(headertext,

unicodeencoding)])

headertext = hdrobj.encode()

except:

pass # автоматически разбивает на несколько строк return headertext # smtplib может потерпеть неудачу, если не будет

# закодировано в ascii

def encodeAddrHeader(self, headertext, unicodeencoding=’utf-8′):

4E: пытается закодировать имена в адресах электронной почты

с символами не из диапазона ASCII в соответствии со стандартами электронной почты, MIME и Юникода; если терпит неудачу, компонент имени отбрасывается и используется только часть с фактическим адресом;

если не может получить даже адрес, пытается декодировать целиком, иначе smtplib может столкнуться с ошибками, когда попытается закодировать все почтовое сообщение как ASCII; в большинстве случаев кодировки utf-8 вполне достаточно, так как она предусматривает довольно широкое разнообразие кодовых пунктов;

вставляет символы перевода строки, если строка заголовка слишком длинная, иначе метод hdr.encode разобьет имена на несколько строк, но он может не замечать некоторые строки, длиннее максимального значения (улучшите меня); в данном случае метод Message.as_string форматирования не будет пытаться разбивать строки;

смотрите также метод decodeAddrHeader в модуле mailParser, реализующий обратную операцию;

try:

pairs = email.utils.getaddresses([headertext]) # разбить

# на части encoded = []

for name, addr in pairs:

try:

name.encode(‘ascii‘) # использовать, как есть, # если ascii

except UnicodeError: # иначе закодировать

# компонент имени

try:

uni = name.encode(unicodeencoding)

hdr = email.header.make_header([(uni,

unicodeencoding)]) name = hdr.encode()

except:

name = None # отбросить имя, использовать только адрес joined = email.utils.formataddr((name, addr)) # заключить encoded.append(joined) # имя в кавычки,

#   если необходимо fullhdr = ‘, ‘.join(encoded)

if len(fullhdr) > 72 or ‘\n’ in fullhdr: # не одна короткая

#                                                      строка?

fullhdr = ‘,\n ‘.join(encoded) # попробовать несколько # строк

return fullhdr

except:

return self.encodeHeader(headertext)

def authenticateServer(self, server):

pass # этот класс/сервер не предусматривает аутентификацию

def getPassword(self):

pass # этот класс/сервер не предусматривает аутентификацию

############################################################################ # специализированные подклассы

############################################################################

class MailSenderAuth(MailSender):

используется для работы с серверами, требующими аутентификацию;

клиент: выбирает суперкласс MailSender или MailSenderAuth, опираясь на параметр mailconfig.smtpuser (None?)

smtpPassword = None # 4E: в классе, не в self, совместно используется # всеми экземплярами

def __init__(self, smtpserver=None, smtpuser=None):

MailSender.__init__(self, smtpserver)

self.smtpUser = smtpuser or mailconfig.smtpuser

#self.smtpPassword = None # 4E: заставит PyMailGUI запрашивать # пароль при каждой операции отправки!

def authenticateServer(self, server):

server.login(self.smtpUser, self.smtpPassword)

def getPassword(self):

get получает пароль для аутентификации на сервере SMTP, если он еще не известен; может вызываться суперклассом автоматически или клиентом вручную: не требуется до момента отправки, но не следует вызывать из потока выполнения графического интерфейса; пароль извлекается из файла на стороне клиента или методом подкласса if not self.smtpPassword:

try:

localfile = open(mailconfig.smtppasswdfile)

MailSenderAuth.smtpPassword = localfile.readline()[:-1] # 4E self.trace(‘local file password’ + repr(self.smtpPassword)) except:

MailSenderAuth.smtpPassword = self.askSmtpPassword() # 4E

def askSmtpPassword(self): assert False, ‘Subclass must define method’

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 # отключает трассировку

Использованная литература:

Марк Лутц — Программирование на Python, 4-е издание, II том, 2011

Каталог сайтов Всего.ру
Оцените статью
Секреты программирования
Добавить комментарий