Created
July 18, 2023 15:55
-
-
Save kookxiang/fac9e275974f359ab15ca08702c5192b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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