diff options
-rw-r--r-- | filter_rspamd.go | 238 |
1 files changed, 238 insertions, 0 deletions
diff --git a/filter_rspamd.go b/filter_rspamd.go new file mode 100644 index 0000000..b0ecede --- /dev/null +++ b/filter_rspamd.go @@ -0,0 +1,238 @@ +// Copyright (c) 2019 Sunil Nimmagadda <sunil@nimmagadda.net> +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "net/http" + "net/mail" + "os" + "strings" +) + +const rspamdURL = "http://localhost:11333/checkv2" + +var stdout *log.Logger + +type session struct { + ch <-chan string + control map[string]string + id string + payload *strings.Builder +} + +type rspamdResponse struct { + Score float32 + RequiredScore float32 `json:"required_score"` + Subject string + Action string + DKIMSig string `json:"dkim-signature"` +} + +func linkConnect(s *session, args []string) { + rdns, laddr := args[6], args[8] + s.control["Pass"] = "all" + p := strings.Split(laddr, ":") + if p[0] != "local" { + s.control["Ip"] = p[0] + } + if rdns != "" { + s.control["Hostname"] = rdns + } +} + +func linkIdentify(s *session, args []string) { + s.control["Helo"] = args[6] +} + +func txBegin(s *session, args []string) { + s.control["Queue-Id"] = args[6] +} + +func txMail(s *session, args []string) { + mail_from, status := args[7], args[8] + if status == "ok" { + s.control["From"] = mail_from + } +} + +func txRcpt(s *session, args []string) { + rcpt_to, status := args[7], args[8] + if status == "ok" { + s.control["Rcpt"] = rcpt_to + } +} + +func txData(s *session, args []string) { + status := args[7] + if status == "ok" { + s.control = nil + } +} + +func txCleanup(s *session, args []string) { + s.control = nil +} + +func filterCommit(s *session, args []string) { + token := args[5] + reason := <-s.ch + if reason != "" { + stdout.Printf("filter-result|%s|%s|reject|%s\n", + token, s.id, reason) + return + } + stdout.Printf("filter-result|%s|%s|proceed\n", token, s.id) +} + +func filterDataLine(s *session, args []string) { + token, line := args[5], args[7] + if line != "." { + s.payload.WriteString(line) + s.payload.WriteString("\n") + return + } + s.ch = dataOutput(s.control, token, s.id, s.payload.String()) +} + +func rspamdPost(hdrs map[string]string, data string) (*rspamdResponse, error) { + r := strings.NewReader(data) + client := &http.Client{} + req, err := http.NewRequest("POST", rspamdURL, r) + if err != nil { + return nil, err + } + for k, v := range hdrs { + req.Header.Add(k, v) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + rr := &rspamdResponse{} + if err := json.NewDecoder(resp.Body).Decode(rr); err != nil { + return nil, err + } + return rr, nil +} + +func dataOutput(headers map[string]string, + token, id, data string) <-chan string { + ch := make(chan string) + go func() { + resp, err := rspamdPost(headers, data) + if err != nil { + log.Fatal(err) + } + log.Printf("%v\n", resp) + m, err := mail.ReadMessage(strings.NewReader(data)) + if err != nil { + log.Fatal(err) + } + rejectReason := "" + switch resp.Action { + case "add header": + m.Header["X-Spam"] = []string{"yes"} + m.Header["X-Spam-Score"] = []string{ + fmt.Sprintf("%v / %v", + resp.Score, resp.RequiredScore)} + case "rewrite subject": + m.Header["Subject"] = []string{resp.Subject} + case "reject": + rejectReason = "550 message rejected" + case "greylist": + rejectReason = "421 greylisted" + case "soft reject": + rejectReason = "451 try again later" + } + // Write DKIM-Signature header first if present + if resp.DKIMSig != "" { + stdout.Printf("filter-dataline|%s|%s|%s: %s\n", + token, id, "DKIM-Signature", resp.DKIMSig) + } + // preserve order? + for k, v := range m.Header { + stdout.Printf("filter-dataline|%s|%s|%s: %s\n", + token, id, k, strings.Join(v, ",")) + } + // Blank line seperates headers and body + stdout.Printf("filter-dataline|%s|%s|\n", token, id) + s := bufio.NewScanner(m.Body) + for s.Scan() { + stdout.Printf("filter-dataline|%s|%s|%s\n", + token, id, s.Text()) + } + stdout.Printf("filter-dataline|%s|%s|%s\n", token, id, ".") + ch <- rejectReason + }() + return ch +} + +func main() { + log.SetFlags(0) + log.SetPrefix("filter_rspamd: ") + stdout = log.New(os.Stdout, "", 0) + registry := map[string]struct { + kind string + fn func(*session, []string) + }{ + "link-connect": {"report", linkConnect}, + "link-disconnect": {"report", nil}, + "link-identify": {"report", linkIdentify}, + "tx-begin": {"report", txBegin}, + "tx-data": {"report", txData}, + "tx-mail": {"report", txMail}, + "tx-rcpt": {"report", txRcpt}, + "tx-commit": {"report", txCleanup}, + "tx-rollback": {"report", txCleanup}, + "commit": {"filter", filterCommit}, + "data-line": {"filter", filterDataLine}, + } + for k, v := range registry { + fmt.Printf("register|%s|smtp-in|%s\n", v.kind, k) + } + fmt.Println("register|ready") + sessions := map[string]*session{} + var event, id string + stdin := bufio.NewScanner(os.Stdin) + for stdin.Scan() { + fields := strings.Split(stdin.Text(), "|") + switch fields[0] { + case "report": + id = fields[5] + case "filter": + id = fields[6] + default: + log.Fatalf("Unknown kind: %s", fields[0]) + } + event = fields[4] + switch event { + case "link-disconnect": + delete(sessions, id) + case "link-connect": + sessions[id] = &session{ + control: map[string]string{}, + id: id, + payload: &strings.Builder{}} + fallthrough + default: + registry[event].fn(sessions[id], fields) + } + } +} |