Skip to content

Instantly share code, notes, and snippets.

@kookxiang
Created July 18, 2023 15:55
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kookxiang/fac9e275974f359ab15ca08702c5192b to your computer and use it in GitHub Desktop.
Save kookxiang/fac9e275974f359ab15ca08702c5192b to your computer and use it in GitHub Desktop.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
"github.com/flashmob/go-guerrilla"
"github.com/flashmob/go-guerrilla/backends"
"github.com/flashmob/go-guerrilla/mail"
"github.com/jhillyerd/enmime"
"github.com/urfave/cli/v2"
)
var (
Version = "UNKNOWN_RELEASE"
validRecipientRegexp = regexp.MustCompile(`^(-?\d+)@telegram\.org$`)
)
type SmtpConfig struct {
smtpListen string
smtpPrimaryHost string
}
type TelegramConfig struct {
telegramBotToken string
telegramApiTimeoutSeconds float64
}
type TelegramAPIMessageResult struct {
Ok bool `json:"ok"`
Result *TelegramAPIMessage `json:"result"`
}
type TelegramAPIMessage struct {
// https://core.telegram.org/bots/api#message
MessageId json.Number `json:"message_id"`
}
func GetHostname() string {
hostname, err := os.Hostname()
if err != nil {
panic(fmt.Sprintf("Unable to detect hostname: %s", err))
}
return hostname
}
func main() {
app := cli.NewApp()
app.Name = "smtp_to_telegram"
app.Usage = "A small program which listens for SMTP and sends all incoming Email messages to Telegram."
app.Version = Version
app.Action = func(c *cli.Context) error {
smtpConfig := &SmtpConfig{
smtpListen: c.String("smtp-listen"),
smtpPrimaryHost: c.String("smtp-primary-host"),
}
telegramConfig := &TelegramConfig{
telegramBotToken: c.String("telegram-bot-token"),
telegramApiTimeoutSeconds: c.Float64("telegram-api-timeout-seconds"),
}
d, err := SmtpStart(smtpConfig, telegramConfig)
if err != nil {
panic(fmt.Sprintf("start error: %s", err))
}
sigHandler(d)
return nil
}
app.Flags = []cli.Flag{
&cli.StringFlag{
Name: "smtp-listen",
Value: "127.0.0.1:2525",
Usage: "SMTP: TCP address to listen to",
EnvVars: []string{"ST_SMTP_LISTEN"},
},
&cli.StringFlag{
Name: "smtp-primary-host",
Value: GetHostname(),
Usage: "SMTP: primary host",
EnvVars: []string{"ST_SMTP_PRIMARY_HOST"},
},
&cli.StringFlag{
Name: "telegram-bot-token",
Usage: "Telegram: bot token",
EnvVars: []string{"ST_TELEGRAM_BOT_TOKEN"},
Required: true,
},
&cli.Float64Flag{
Name: "telegram-api-timeout-seconds",
Usage: "HTTP timeout used for requests to the Telegram API",
Value: 30,
EnvVars: []string{"ST_TELEGRAM_API_TIMEOUT_SECONDS"},
},
}
err := app.Run(os.Args)
if err != nil {
fmt.Printf("%s\n", err)
os.Exit(1)
}
}
func SmtpStart(
smtpConfig *SmtpConfig, telegramConfig *TelegramConfig) (guerrilla.Daemon, error) {
cfg := &guerrilla.AppConfig{}
cfg.AllowedHosts = []string{"."}
sc := guerrilla.ServerConfig{
IsEnabled: true,
ListenInterface: smtpConfig.smtpListen,
MaxSize: 67108864, // 64 MB
}
cfg.Servers = append(cfg.Servers, sc)
cfg.BackendConfig = backends.BackendConfig{
"save_workers_size": 3,
"save_process": "HeadersParser|Header|Hasher|TelegramBot",
"log_received_mails": true,
"primary_mail_host": smtpConfig.smtpPrimaryHost,
}
daemon := guerrilla.Daemon{Config: cfg}
daemon.AddProcessor("TelegramBot", TelegramBotProcessorFactory(telegramConfig))
err := daemon.Start()
return daemon, err
}
func TelegramBotProcessorFactory(
telegramConfig *TelegramConfig) func() backends.Decorator {
return func() backends.Decorator {
// https://github.com/flashmob/go-guerrilla/wiki/Backends,-configuring-and-extending
return func(p backends.Processor) backends.Processor {
return backends.ProcessWith(
func(e *mail.Envelope, task backends.SelectTask) (backends.Result, error) {
if task == backends.TaskSaveMail {
err := SendEmailToTelegram(e, telegramConfig)
if err != nil {
return backends.NewResult(fmt.Sprintf("554 Error: %s", err)), err
}
return p.Process(e, task)
}
return p.Process(e, task)
},
)
}
}
}
func SendEmailToTelegram(e *mail.Envelope,
telegramConfig *TelegramConfig) error {
if len(e.RcptTo) != 1 {
return errors.New("only one recipient is supported")
}
message, err := FormatEmail(e)
if err != nil {
return err
}
matches := validRecipientRegexp.FindStringSubmatch(e.RcptTo[0].String())
if matches == nil {
return errors.New("invalid recipient")
}
chatId := matches[1]
client := http.Client{
Timeout: time.Duration(telegramConfig.telegramApiTimeoutSeconds*1000) * time.Millisecond,
}
err = SendMessageToChat(message, chatId, telegramConfig, &client)
if err != nil {
// If unable to send at least one message -- reject the whole email.
return errors.New(SanitizeBotToken(err.Error(), telegramConfig.telegramBotToken))
}
return nil
}
func SendMessageToChat(message string, chatId string, telegramConfig *TelegramConfig, client *http.Client) error {
resp, err := client.PostForm(
fmt.Sprintf(
"https://api.telegram.org/bot%s/sendMessage?disable_web_page_preview=true",
telegramConfig.telegramBotToken,
),
url.Values{"chat_id": {chatId}, "text": {message}},
)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return errors.New(fmt.Sprintf(
"Non-200 response from Telegram: (%d) %s",
resp.StatusCode,
EscapeMultiLine(body),
))
}
j, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading json body of sendMessage: %v", err)
}
result := &TelegramAPIMessageResult{}
err = json.Unmarshal(j, result)
if err != nil {
return fmt.Errorf("error parsing json body of sendMessage: %v", err)
}
if result.Ok != true {
return fmt.Errorf("ok != true: %s", j)
}
return nil
}
func FormatEmail(e *mail.Envelope) (string, error) {
reader := e.NewReader()
env, err := enmime.ReadEnvelope(reader)
if err != nil {
return "", fmt.Errorf("%s\n\nError occurred during email parsing: %v", e, err)
}
text := env.Text
if text == "" {
text = e.Data.String()
}
return FormatMessage(env.GetHeader("subject"), text), nil
}
func FormatMessage(subject string, text string) string {
return strings.TrimSpace(subject + "\n\n" + strings.TrimSpace(text))
}
func EscapeMultiLine(b []byte) string {
// Apparently errors returned by smtp must not contain newlines,
// otherwise the data after the first newline is not getting
// to the parsed message.
s := string(b)
s = strings.Replace(s, "\r", "\\r", -1)
s = strings.Replace(s, "\n", "\\n", -1)
return s
}
func SanitizeBotToken(s string, botToken string) string {
return strings.Replace(s, botToken, "***", -1)
}
func sigHandler(d guerrilla.Daemon) {
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel,
syscall.SIGTERM,
syscall.SIGQUIT,
syscall.SIGINT,
syscall.SIGKILL,
os.Kill,
)
for range signalChannel {
go func() {
select {
// exit if graceful shutdown not finished in 60 sec.
case <-time.After(time.Second * 60):
os.Exit(1)
}
}()
d.Shutdown()
return
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment