Перенаправляем почту в Telegram

В виду того, что спам всё ещё не вышел из моды, а всякая малварь не перестаёт захватывать сайты с целью использования сервера в качестве “говномёта”, в некоторых датацентрах и в сети интернет провайдеров домашнего интернета встречается ограничение исходящих соединений на 25 порт. Конечно, можно использовать какой-нибудь внешний smtp по шифрованному 465, но в моём случае в этом было мало проку. Мне нужны были только отчёты и уведомления со всех виртуалок на сервере и гарантия их доставки. Немного подумав, я решил, что интереснее всего было бы получать из в мой любимый на сегодняшний день, мессенджер Telegram.
Погуглив по сабжу, я практически сразу наткнулся про проект smtp2tg.

smpt2tg написан на языке Go, так что для начала нужно установить его. Первым делом, я попытался сделать это через пакетный менеджер и таким образом убил лишние полчаса. Поняв, что так ничего не будет работать, я грохнул всё ранее установленное и пошёл наипростейшим путём.

wget https://golang.org/dl/go1.15.6.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.15.6.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
echo "export PATH=$PATH:/usr/local/go/bin" >> ~/.bashrc

Теперь, убедимся, что мы засунули язык в нужное место. :)

Добавляем к этому делу пакеты-зависимости.

go get github.com/veqryn/go-email/email
go get github.com/spf13/viper
go get gopkg.in/telegram-bot-api.v4
go get github.com/ircop/smtp2tg

Необходимо создать конфиг для программы

Можно расположить его в /etc/smtp2tg.toml

[bot]
token = "ключ телеграм бота"

[receivers]
"*" = "id чата/канала/группы для wildcard"
"somemail@ex.uz" = "id чата/канала/группы для конкретного адреса"

[smtp]
listen = "0.0.0.0:25"
name = "ex.uz"

[logging]
file = "/var/log/smtp2tg.log"
debug = false

Теперь можно запустить прогу

/root/go/bin/smtp2tg -c /etc/smtp2tg.toml &
Как мы можем видеть, программа успешно запустилась и слушает 25 порт.

Любая почта, попавшая в этот smtp сервер будет направлена в соответствии с конфигом на id соответствующий адресату, либо по правилу “ловить всё”.

Если вам нужен настоящий почтовый ящик, просто натравите MX запись вашего домена на сервер, с запущенным smtp2tg. Однако, моя задача заключалась в том, чтобы отлавливать всю почту, которая уходит с моего сервера в любом направлении. Так, как порт 25 для меня всё равно заблокирован на выход, я не раздумывая добавил на гипервизоре правило dnat.

iptables -t nat -I PREROUTING -p tcp -d 0.0.0.0/0 --dport 25 -j DNAT --to-destination IP_СЕРВЕРА:25

Теперь эта прога будет перехватывать вообще всё, независимо от того, какой MX отрезолвился на адресе получателя.

На этом можно было бы и закрыть консольку, но обнаружилась одна нервирующая фича. Дело в том, что при парсинге письма, smtp2tg ищет в нём заголовок, в котором указан тип содержимого. Так вот, к примеру, функция mail() в php ничего такого в письмо по умолчанию не кладёт, а по сей причине кое-что до нас может не долететь, ибо будет отфильтровано как “пустой конверт”.

Меня такой расклад совершенно не устроил, и я, абсолютно не зная языка Go, полез что-то то там исправлять. :) Результат моих стараний можно увидеть ниже. Это слегка допиленная версия программы, которая в добавок теперь ещё и показывать тему письма, а так же, адрес отправителя.

package main

import (
    "os"
    "strconv"
    "strings"
    "flag"
    "bytes"
    "log"
    "net"
    "gopkg.in/telegram-bot-api.v4"
    "github.com/spf13/viper"
    "github.com/veqryn/go-email/email"
    "github.com/ircop/smtp2tg/smtpd"
)

var receivers map[string]string
var bot *tgbotapi.BotAPI
var debug bool

func main() {

    configFilePath := flag.String("c", "./smtp2tg.toml", "Config file location")
    //pidFilePath := flag.String("p", "/var/run/smtp2tg.pid", "Pid file location")
    flag.Parse()
    
    // Load & parse config
    viper.SetConfigFile(*configFilePath)
    err := viper.ReadInConfig()
    if( err != nil ) {
	log.Fatal(err.Error())
    }
    
    // Logging
    logfile := viper.GetString("logging.file")
    if( logfile == "" ) {
	log.Println("No logging.file defined in config, outputting to stdout")
    } else {
	lf, err := os.OpenFile(logfile, os.O_APPEND | os.O_CREATE | os.O_RDWR, 0666)
	if( err != nil ) {
	    log.Fatal(err.Error())
	}
	log.SetOutput(lf)
    }
    
    // Debug?
    debug = viper.GetBool("logging.debug")
    
    
    receivers = viper.GetStringMapString("receivers")
    if( receivers["*"] == "" ) {
	log.Fatal("No wildcard receiver (*) found in config.")
    }
    
    var token string = viper.GetString("bot.token")
    if( token == "" ) {
	log.Fatal("No bot.token defined in config")
    }
    
    var listen string = viper.GetString("smtp.listen")
    var name string = viper.GetString("smtp.name")
    if( listen == "" ) {
	log.Fatal("No smtp.listen defined in config.")
    }
    if( name == "" ) {
	log.Fatal("No smtp.name defined in config.")
    }
    
    // Initialize TG bot
    bot, err = tgbotapi.NewBotAPI( token )
    if( err != nil ) {
	log.Fatal(err.Error())
    }
    log.Printf("Bot authorized as %s", bot.Self.UserName )
    
    
    log.Printf("Initializing smtp server on %s...", listen)
    // Initialize SMTP server
    err_ := smtpd.ListenAndServe(listen, mailHandler, "mail2tg", "", debug)
    if( err_ != nil ) {
	log.Fatal(err_.Error())
    }
}
    
func mailHandler(origin net.Addr, from string, to []string, data []byte) {
    
    from = strings.Trim(from, " ")
    to[0] = strings.Trim(to[0], " ")
    to[0] = strings.Trim(to[0], "<")
    to[0] = strings.Trim(to[0], ">")
    msg, err := email.ParseMessage(bytes.NewReader(data))
    if( err != nil ) {
	log.Printf("[MAIL ERROR]: %s", err.Error())
	return
    }
    subject := msg.Header.Get("Subject")
	myBytes := msg.Body
    log.Printf("Received mail from '%s' for '%s' with subject '%s'", from, to[0], subject)
    
    // Find receivers and send to TG
    var tgid string
    if( receivers[to[0]] != "" ) {
	tgid = receivers[to[0]]
    } else {
	tgid = receivers["*"]
    }
    
    textMsgs := msg.MessagesContentTypePrefix("text")
    images := msg.MessagesContentTypePrefix("image")

    if len(textMsgs) == 0 && len(images) == 0 {
		if len(myBytes) == 0 {
			log.Printf("mail doesn't contain text or image")
			return
		}
    }

    log.Printf("Relaying message to: %v", tgid)
    
    i, err := strconv.ParseInt(tgid, 10, 64)
    if( err != nil ) {
	log.Printf("[ERROR]: wrong telegram id: not int64")
	return
    }
    
    if len(textMsgs) > 0 {
        bodyStr := "&#x1f4ec; from: "+from + " &#x1f4ac; "+ subject + " &#x1f4ac;\r\n\r\n" + string(textMsgs[0].Body)
        tgMsg := tgbotapi.NewMessage(i, bodyStr)
        tgMsg.ParseMode = tgbotapi.ModeMarkdown
        _, err = bot.Send(tgMsg)
        if err != nil {
            log.Printf("[ERROR]: telegram message send: '%s'", err.Error())
            return
        }
    } else if len(myBytes) > 0 {
	    bodyStr := "&#x1f4ec; from: "+from + " &#x1f4ac; "+ subject + " &#x1f4ac;\r\n\r\n" +string(myBytes)
        tgMsg := tgbotapi.NewMessage(i, bodyStr)
        tgMsg.ParseMode = tgbotapi.ModeMarkdown
        _, err = bot.Send(tgMsg)
        if err != nil {
            log.Printf("[ERROR]: telegram message send: '%s'", err.Error())
            return
        }
	}
	


    // TODO Better to use 'sendMediaGroup' to send all attachments as a
    // single message, but go telegram api has not implemented it yet
    // https://github.com/go-telegram-bot-api/telegram-bot-api/issues/143    
    for _, part := range msg.MessagesContentTypePrefix("image") {
        _, params, err := part.Header.ContentDisposition()
        if err != nil {
            log.Printf("[ERROR]: content disposition parse: '%s'", err.Error())
            return
        }
        text := params["filename"]
        tgFile := tgbotapi.FileBytes{Name: text, Bytes: part.Body}
        tgMsg := tgbotapi.NewPhotoUpload(i, tgFile)
        tgMsg.Caption = text
        // It's not a separate message, so disable notification
        tgMsg.DisableNotification = true
        _, err = bot.Send(tgMsg)
        if err != nil {
            log.Printf("[ERROR]: telegram photo send: '%s'", err.Error())
            return
        }
    }
}

Понятия не имею, почему изначально разработчик решил, что всё это не нужно, но в оригинальном коде предусмотрена доставка только тела письма.

Собирается этот код следующим образом:

mkdir ~/smtp2tg/
vi ~/smtp2tg/main.go

Вставляем вышеупомянутый код в main.go и запускаем “go build”

Запускаем, проверяем, получаем примерно такой результат:

Осталось только пихнуть в автозагрузку. (например в rc.local ?)

Наверное я допишу статью, когда найду нормальный способ демонизировать этот процесс под centos 7. А пока, отдаю на ваш суд, как говорится “as is”.