Скачиваем аттачменты из Gmail

Этот пост будет сугубо техническим. Никакой интересной математики, сплошное решение практических задач.

Несмотря на весь прогресс науки и техники, всевозможные LMS и прочие чудеса, до сих пор самым надёжным способом принять домашние задания у студентов в электронной форме остаётся присылка их на e-mail преподавателя. Конечно, это ужасно. Иногда удаётся уговорить вместо этого загрузить файлы в специальную формочку (которая, к слову, очень легко делается с помощью Google Drive и и каких-то там скриптов), но всё равно где-то для 15-20% студентов это окажется непреодолимой трудностью. А e-mail работает более-менее всегда. Поэтому проведя контрольную работу по Python в прошедший вторник, я решил не мудрствовать лукаво и предложил сдать мне работы в виде ipynb-файлов, отправив их по почте.

И вот сейчас, собравшись с духом и надумав их всё-таки проверить, я столкнулся с необходимостью открыть три десятка писем и ручками скачать оттуда вложения. Энтузиазм как-то сразу иссяк...

Впрочем, о чём это я? Какими такими ручками? 2014-й год за окном (уже почти закончился), всё поддаётся автоматизации! Итак…

Как пройти на почту

В общем случае работать с любым почтовым сервисом можно с помощью стандартных почтовых протоколов — точно так же, как работает любой почтовый клиент. Есть старый-добрый POP3, есть IMAP, и можно быстренько написать «мини-клиент», который подключится к серверу и скачает всё, что нужно. Но в случае с Gmail есть ещё и их собственное API, которое позволяет получить наиболее естественный доступ ко всем возможностям Gmail (например, тредам), а также безопасную авторизацию. Им я и решил воспользоваться.

Вот тут лежит документация, а точнее руководство для быстрого старта, ориентированное на Python. После получения ключа для API и установки google-api-python-client, приведенный там примерчик, конечно, не заработал, поскольку потребовал какую-то библиотеку gflags (которая, как оказывается, на самом деле называется python-gflags), но после её установки всё-таки заработал (поругавшись на то, что там что-то устарело): открыл браузер и попросил авторизовать моё приложение. Я согласился.

Дальше всё было легко. Ну, или почти легко.

Шаг 1. Выбираем нужные сообщения

Можно было бы сделать какой-нибудь хитрый запрос, чтобы обрабатывать только сообщения, пришедшие в определенный интервал времени, но я поступил проще: в интерфейсе Gmail вручную присвоил всем нужным мне письмам метку py-cw1 (это было легко сделать, поскольку все эти письма шли подряд и их можно было выделить двумя кликами). Теперь нужно получить список всех сообщений с этой меткой. А для начала — её внутренний идентификатор (метку можно переименовать, а идентификатор не изменится). Делается это так:

In [5]:
labels=gmail_service.users().labels().list(userId='me').execute()
for label in labels['labels']:
    if label['name']=='py-cw1':
        py_cw1_label=label['id']
        print py_cw1_label
Label_17

Теперь нужно получить список всех сообщений с этой меткой. Я было собрался написать этот код сам, но полез в документацию и обнаружил там пример ровно про это. (Я не сразу заметил, что рядом с кодом примера есть переключатель языков и поначалу пытался переводить с Java, но там и для Python всё есть.) Нас интересует функция ListMessagesWithLabels, которая прекрасно заработала безо всяких модификаций.

In [9]:
messages=ListMessagesWithLabels(gmail_service,'me',py_cw1_label)
print messages[0:3]
[{u'id': u'14a0c2c700797cea', u'threadId': u'14a0c2c700797cea'}, {u'id': u'14a0c252f30f6044', u'threadId': u'14a0c252f30f6044'}, {u'id': u'14a0c251c9d10413', u'threadId': u'14a0c251c9d10413'}]

Конечно, эти id'шники нам ни о чём не говорят — и не надо. Мы будем их использовать, чтобы обрабатывать сообщения по одному.

Шаг 2. Скачиваем вложения

Дальше я стал думать о том, как пройтись по всем письмам и из каждого письма вытащить и сохранить на диск вложения (attachments). Размышления мои были недолгими, поскольку в соответствующем разделе справки по API снова был приведён пример, делающий ровно то, что мне надо (функция GetAttachments). Вернее, не делающий — потому что функция в документации написана с ошибками.

Первая ошибка была простой: вместо users() там написали user(). Эту опечатку я быстро поправил. А дальше всё оказалось интереснее, потому что при попытке запустить эту штуку она стала сваливаться с ошибкой KeyError, не находя в ответе сервера нужных полей.

Оказывается, при запросе содержимого сообщения может быть возвращено либо само вложение, либо его идентификатор. В последнем случае потребуется ещё один запрос, чтобы получить сам файл. В документации это отражено, а в примере почему-то нет. Мне пришлось переписать функцию GetAttachments, исправив там эти ошибки. (Отмечу, что проблема возникла не только у меня.)

In [10]:
import base64
from apiclient import errors

# based on Python example from 
# https://developers.google.com/gmail/api/v1/reference/users/messages/attachments/get
# which is licensed under Apache 2.0 License

def GetAttachments(service, user_id, msg_id, prefix=""):
    """Get and store attachment from Message with given id.

    Args:
    service: Authorized Gmail API service instance.
    user_id: User's email address. The special value "me"
    can be used to indicate the authenticated user.
    msg_id: ID of Message containing attachment.
    prefix: prefix which is added to the attachment filename on saving
    """
    try:
        message = service.users().messages().get(userId=user_id, id=msg_id).execute()

        for part in message['payload']['parts']:
            if part['filename']:
                if 'data' in part['body']:
                    data=part['body']['data']
                else:
                    att_id=part['body']['attachmentId']
                    att=gmail_service.users().messages().attachments().get(userId=user_id, messageId=msg_id,id=att_id).execute()
                    data=att['data']
                file_data = base64.urlsafe_b64decode(data.encode('UTF-8'))
                path = prefix+part['filename']

                with open(path, 'w') as f:
                    f.write(file_data)
    except errors.HttpError, error:
        print 'An error occurred: %s' % error

Ну а теперь осталось пройтись по всем найденным сообщениям и скормить их этой функции.

In [14]:
for i,msg in enumerate(messages):
    m_id=msg['id']
    try:
        GetAttachments(gmail_service, 'me', m_id, str(i)+"_")
        print "Processed ", m_id
    except KeyError, e:
        if 'parts' in str(e):
            print "No attachment in message ", m_id
        else:
            print "Something wrong in message ", m_id, e
No attachment in message  14a0c2c700797cea
Processed  14a0c252f30f6044
Processed  14a0c251c9d10413
Processed  14a0c250f665376f
Processed  14a0c248554a20bd
Processed  14a0c2454a35781f
Processed  14a0c244b886a139
Processed  14a0c24475140728
Processed  14a0c2418028e956
Processed  14a0c2412ee9fc15
Processed  14a0c2407d038886
Processed  14a0c23ab73103ce
Processed  14a0c23aaf3bfa2c
Processed  14a0c22e99305778
Processed  14a0c22dcd995583
Processed  14a0c22a4e4bdc54
Processed  14a0c226f9acbc67
Processed  14a0c2223d19dbb0
Processed  14a0c21fd99ca422
Processed  14a0c214e7049505
Processed  14a0c210519b93ba
Processed  14a0c20df1ace576
Processed  14a0c20b219633be
Processed  14a0c20480583663
Processed  14a0c1ec6253ab04
Processed  14a0c1ea34af8e1b
Processed  14a0c1e94466cc63
Processed  14a0c1dc007bed33
Processed  14a0c1d956902f07
Processed  14a0c1a397b030a5
Processed  14a0c0f1780c35ae
Processed  14a0c057d40d0fce
Processed  14a0bff90bfcdef0
Processed  14a0bfef58d2e139
Processed  14a0bed15212713c

Одно сообщение попало в список по ошибке (не содержало вложений), а остальные успешно обработались и сохранились!

Конечно, я потратил больше времени на освоение Gmail API, чем ушло бы на сохранение этих файлов вручную, но зато это было гораздо увлекательнее! Если это не кажется вам достойным объяснением, предложу другое: если мне понадобится в будущем что-то в массовом порядке сделать со своей почтой, мне теперь будет гораздо проще.

P.S. Возвращение к классике

Если вас интересует более классический подход с использованием стандартных почтовых протоколов, аналогичная задача решается, например, здесь.

Комментарии