Since spam is still trendy and malware continues to hijack websites to use the server as a «shit-thrower,» some data centers and home internet providers restrict outgoing connections to port 25. Of course, you could use an external SMTP service over encrypted port 465, but in my case, that wasn’t very helpful. I only needed reports and notifications from all virtual machines on the server and a guarantee of their delivery. After some thought, I decided the most interesting way to receive them was through my current favorite messenger, Telegram.
After googling the subject, I almost immediately came across the smtp2tg project.
smpt2tg is written in Go, so you need to install it first. My first attempt was to install it through the package manager, which wasted an extra half hour. Realizing that wouldn’t work that way, I deleted everything I’d installed and took the simplest route.
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 Now, let’s make sure we’ve got our tongue in the right place. :)

Let’s add dependency packages to this matter.
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 It is necessary to create a config for the program
You can place it in /etc/smtp2tg.toml
[bot]
token = "ключ телеграм бота"
[receivers]
"*" = "id чата/канала/группы для wildcard"
"[email protected]" = "id чата/канала/группы для конкретного адреса"
[smtp]
listen = "0.0.0.0:25"
name = "ex.uz"
[logging]
file = "/var/log/smtp2tg.log"
debug = false Now you can run the program
/root/go/bin/smtp2tg -c /etc/smtp2tg.toml & 
Any mail received by this SMTP server will be directed in accordance with the configuration to the ID corresponding to the recipient, or according to the «catch all» rule.
If you need a real mailbox, simply point your domain’s MX record to the server running smtp2tg. However, my goal was to intercept all mail leaving my server in any direction. Since port 25 is blocked for me anyway, I immediately added the dnat rule on the hypervisor.
iptables -t nat -I PREROUTING -p tcp -d 0.0.0.0/0 --dport 25 -j DNAT --to-destination IP_СЕРВЕРА:25 Now this program will intercept absolutely everything, regardless of which MX resolved on the recipient’s address.
I could have closed the console at this point, but I discovered an unnerving feature. When parsing an email, smtp2tg looks for a header that specifies the content type. For example, the mail() function in PHP doesn’t include any such content in the email by default, and for this reason, some content might not reach us, being filtered as an «empty envelope.»
I wasn’t happy with this setup at all, so, completely unfamiliar with Go, I set out to fix something. :) The result of my efforts can be seen below. It’s a slightly modified version of the program, which now also displays the email subject and the sender’s address.
<pre class="wp-block-syntaxhighlighter-code">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 := "📬 from: "+from + " 💬 "+ subject + " 💬\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 := "📬 from: "+from + " 💬 "+ subject + " 💬\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
}
}
}
</pre> I have no idea why the developer initially decided that all this was unnecessary, but the original code only provided for the delivery of the body of the email.
This code is assembled as follows:
mkdir ~/smtp2tg/
vi ~/smtp2tg/main.go Paste the above code into main.go and run «go build»
We launch it, check it, and get something like this:

All that’s left is to put it into startup (for example, in rc.local?)
I’ll probably finish this article when I find a decent way to daemonize this process on CentOS 7. For now, I’ll leave it to your judgment, as they say, «as is.»
