Перейти к содержимому

Почтовый ящик в helpdesk

Обновлено 17 октября 2026

Что получится: модуль helpdesk на Python. Он подключает ящик поддержки к каналу «Почта». Каждое новое письмо становится заявкой, а письма одной цепочки собираются в одну заявку. Оператор отвечает из helpdesk, ответ уходит в ту же цепочку, обработанное письмо переносится в папку «Обработано». Если ответ не дошёл, заявка помечается.

письмо клиента ──► incomingMessageReceived ──► заявка (по threadId)
                   emailMessageData             │
                                                ▼
оператор ──► replyEmail (в цепочку) ──► moveMessages «Обработано»
                                                │
отчёт о недоставке ──► outgoingMessageStatus: failed ──► пометка в заявке

Что понадобится

Шаг 1. Подключите ящик

Для Gmail, Outlook, Яндекса и Mail Gratio вернёт ссылку на страницу входа сервиса — её открывает человек, который знает ящик:

curl

curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/connectMailbox/$GRATIO_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"email": "support@school.ru"}'

Python

import os
import requests

BASE = f"{os.environ['GRATIO_URL']}/waInstance{os.environ['GRATIO_ID']}"
TOKEN = os.environ["GRATIO_TOKEN"]

url = f"{BASE}/connectMailbox/{TOKEN}"
payload = {"email": "support@school.ru"}
response = requests.post(url, json=payload, timeout=60)
print(response.status_code, response.json())

Node.js

const { GRATIO_URL, GRATIO_ID, GRATIO_TOKEN } = process.env;
const base = `${GRATIO_URL}/waInstance${GRATIO_ID}`;

const url = `${base}/connectMailbox/${GRATIO_TOKEN}`;
const payload = { email: "support@school.ru" };
const response = await fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
console.log(response.status, await response.json());

PHP

<?php
$base = getenv('GRATIO_URL') . '/waInstance' . getenv('GRATIO_ID');
$url = $base . '/connectMailbox/' . getenv('GRATIO_TOKEN');
$payload = ['email' => 'support@school.ru'];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
]);
$response = json_decode(curl_exec($ch), true);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), PHP_EOL;
print_r($response);

Вы увидите:

{"connected": false, "auth": "oauth", "email": "support@school.ru", "provider": "yandex",
 "providerTitle": "Яндекс", "authUrl": "https://oauth.yandex.ru/authorize?…", "expiresAt": 1792221600}

Откройте authUrl, войдите и разрешите доступ. Ссылка живёт 20 минут. Когда вход закончен, getStateInstance ответит authorized.

Для почты на хостинге или своём сервере передайте пароль — вход проверится сразу и ответ будет "connected": true. Все варианты и отказы — connectMailbox.

Шаг 2. Настройте ящик

Уведомления — только о «Входящих» и без рассылок; подпись — для всех ответов. Папка «Обработано» — для разобранных писем:

curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/setSettings/$GRATIO_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "emailFolders": "inbox",
    "emailSkipBulk": "yes",
    "emailSignature": "Служба поддержки\nШкола робототехники"
  }'
curl -X POST "$GRATIO_URL/waInstance$GRATIO_ID/createFolder/$GRATIO_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name": "Обработано"}'

Шаг 3. Письма — в заявки

Файл helpdesk.py. Заявка — цепочка писем (threadId). Если клиент отвечает в ту же цепочку, письмо добавляется к существующей заявке.

import os
import sqlite3
import uuid
import requests
from fastapi import FastAPI, Header, HTTPException

BASE = f"{os.environ['GRATIO_URL']}/waInstance{os.environ['GRATIO_ID']}"
TOKEN = os.environ["GRATIO_TOKEN"]
HOOK_TOKEN = os.environ["GRATIO_HOOK_TOKEN"]

db = sqlite3.connect("helpdesk.db", check_same_thread=False)
db.executescript("""
create table if not exists tickets (thread_id text primary key, email text, subject text,
  status text default 'new', problem text);
create table if not exists letters (id_message text primary key, thread_id text, direction text,
  text text, mailbox text, uid int, at int);
""")
app = FastAPI()


def call(method: str, payload: dict) -> dict:
    r = requests.post(f"{BASE}/{method}/{TOKEN}", json=payload, timeout=90)
    r.raise_for_status()
    return r.json()


def text_of(data: dict) -> str:
    return ((data.get("textMessageData") or {}).get("textMessage")
            or (data.get("extendedTextMessageData") or {}).get("text") or "")


def on_letter(body: dict) -> None:
    data = body["messageData"]
    mail = data["emailMessageData"]
    thread = mail["threadId"] or body["idMessage"]
    db.execute("insert or ignore into tickets (thread_id, email, subject) values (?, ?, ?)",
               (thread, body["senderData"]["chatId"], mail["subject"]))
    db.execute("update tickets set status = 'new' where thread_id = ? and status = 'answered'",
               (thread,))
    db.execute("insert or ignore into letters values (?, ?, 'in', ?, ?, ?, ?)",
               (body["idMessage"], thread, text_of(data), mail["mailbox"], mail["uid"], body["timestamp"]))

mailbox и uid — где письмо лежит в ящике: по ним его надёжно найдут методы почты. Текст в уведомлении — без цитаты и подписи. Письмо целиком с html и вложениями — getEmail.

Шаг 4. Ответ оператора

replyEmail отвечает в цепочку письма: на адрес Reply-To или отправителя, с цитатой и подписью из настроек. После ответа письмо клиента переносится в «Обработано». clientMessageId защищает от второго письма при повторе запроса.

def answer(thread_id: str, text: str) -> dict:
    mailbox, uid = db.execute(
        """select mailbox, uid from letters
           where thread_id = ? and direction = 'in' order by at desc limit 1""",
        (thread_id,)).fetchone()
    sent = call("replyEmail", {"folder": mailbox, "uid": uid, "message": text,
                               "clientMessageId": f"ticket-{thread_id}-{uuid.uuid4()}"})
    db.execute("insert or ignore into letters values (?, ?, 'out', ?, null, null, strftime('%s','now'))",
               (sent["idMessage"], thread_id, text))
    db.execute("update tickets set status = 'answered' where thread_id = ?", (thread_id,))
    db.commit()
    call("moveMessages", {"folder": mailbox, "uids": [uid], "to": "Обработано"})
    return sent

Шаг 5. Недоставка

Если почтовый сервер не принял ответ, придёт отчёт о недоставке — уведомление outgoingMessageStatus со статусом failed и причиной в description:

def on_status(body: dict) -> None:
    if body.get("status") != "failed":
        return
    row = db.execute("select thread_id from letters where id_message = ?", (body["idMessage"],)).fetchone()
    if row:
        db.execute("update tickets set status = 'problem', problem = ? where thread_id = ?",
                   (body.get("description", "не доставлено"), row[0]))

Шаг 6. Вебхук и запуск

@app.post("/gratio/hook")
def hook(body: dict, authorization: str = Header("")):
    if authorization != f"Bearer {HOOK_TOKEN}":
        raise HTTPException(status_code=401)
    if body["typeWebhook"] == "incomingMessageReceived" and "emailMessageData" in body["messageData"]:
        on_letter(body)
    elif body["typeWebhook"] == "outgoingMessageStatus":
        on_status(body)
    db.commit()
    return {"ok": True}


@app.get("/tickets")
def tickets():
    rows = db.execute("select thread_id, email, subject, status, problem from tickets").fetchall()
    return [dict(zip(("thread", "email", "subject", "status", "problem"), r)) for r in rows]


@app.post("/tickets/{thread_id}/answer")
def answer_endpoint(thread_id: str, payload: dict):
    return answer(thread_id, payload["text"])

Сложите фрагменты шагов 3–6 в helpdesk.py по порядку и запустите:

uvicorn helpdesk:app --port 8080
  1. Напишите на ящик поддержки с личной почты. Вы увидите заявку: curl localhost:8080/tickets.
  2. Ответьте: curl -X POST localhost:8080/tickets/<thread>/answer -H 'Content-Type: application/json' -d '{"text": "Здравствуйте! Смотрим."}'. Вы увидите письмо в личной почте — в той же цепочке, с подписью, а письмо клиента в ящике поддержки — в папке «Обработано».
  3. Ответьте из личной почты. Вы увидите у заявки снова статус new.

Что ещё добавить

  • Заявки с форм сайта — правило emailForms разберёт поля «Имя», «Телефон» и другие: Заявки с форм сайта.
  • Вложения — ссылки на каждый файл письма в getEmail; файл в ответ — files в replyEmail.
  • Старые письма до подключения — searchMessages по папкам ящика.

Что дальше

Была ли статья полезна?