package junk // see https://en.wikipedia.org/wiki/Naive_Bayes_spam_filtering // - todo: better html parsing? // - todo: try reading text in pdf? // - todo: try to detect language, have words per language? can be in the same dictionary. currently my dictionary is biased towards treating english as spam. import ( "bufio" "fmt" "io" "os" "strings" "unicode" "golang.org/x/net/html" "github.com/mjl-/mox/message" ) func (f *Filter) tokenizeMail(path string) (bool, map[string]struct{}, error) { mf, err := os.Open(path) if err != nil { return false, nil, err } defer func() { err := mf.Close() f.log.Check(err, "closing message file") }() fi, err := mf.Stat() if err != nil { return false, nil, err } p, _ := message.EnsurePart(mf, fi.Size()) words, err := f.ParseMessage(p) return true, words, err } // ParseMessage reads a mail and returns a map with words. func (f *Filter) ParseMessage(p message.Part) (map[string]struct{}, error) { metaWords := map[string]struct{}{} textWords := map[string]struct{}{} htmlWords := map[string]struct{}{} hdrs, err := p.Header() if err != nil { return nil, fmt.Errorf("parsing headers: %v", err) } // Add words from the header, annotated with +":". // todo: add whether header is dkim-verified? for k, l := range hdrs { for _, h := range l { switch k { case "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Sender", "Return-Path": // case "Subject", "To": default: continue } words := map[string]struct{}{} f.tokenizeText(strings.NewReader(h), words) for w := range words { if len(w) <= 3 { continue } metaWords[k+":"+w] = struct{}{} } } } if err := f.mailParse(p, metaWords, textWords, htmlWords); err != nil { return nil, fmt.Errorf("parsing message: %w", err) } for w := range metaWords { textWords[w] = struct{}{} } for w := range htmlWords { textWords[w] = struct{}{} } return textWords, nil } // mailParse looks through the mail for the first text and html parts, and tokenizes their words. func (f *Filter) mailParse(p message.Part, metaWords, textWords, htmlWords map[string]struct{}) error { ct := p.MediaType + "/" + p.MediaSubType if ct == "TEXT/HTML" { err := f.tokenizeHTML(p.Reader(), metaWords, htmlWords) // log.Printf("html parsed, words %v", htmlWords) return err } if ct == "" || strings.HasPrefix(ct, "TEXT/") { err := f.tokenizeText(p.Reader(), textWords) // log.Printf("text parsed, words %v", textWords) return err } if p.Message != nil { // Nested message, happens for forwarding. if err := p.SetMessageReaderAt(); err != nil { return fmt.Errorf("setting reader on nested message: %w", err) } return f.mailParse(*p.Message, metaWords, textWords, htmlWords) } for _, sp := range p.Parts { if err := f.mailParse(sp, metaWords, textWords, htmlWords); err != nil { return err } } return nil } func looksRandom(s string) bool { // Random strings, eg 2fvu9stm9yxhnlu. ASCII only and a many consonants in a stretch. stretch := 0 const consonants = "bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ23456789" // 0 and 1 may be used as o and l/i stretches := 0 for _, c := range s { if c >= 0x80 { return false } if strings.ContainsRune(consonants, c) { stretch++ continue } if stretch >= 6 { stretches++ } stretch = 0 } if stretch >= 6 { stretches++ } return stretches > 0 } func looksNumeric(s string) bool { s = strings.TrimPrefix(s, "0x") // Hexadecimal. var digits, hex, other, digitstretch, maxdigitstretch int for _, c := range s { if c >= '0' && c <= '9' { digits++ digitstretch++ continue } else if c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' { hex++ } else { other++ } if digitstretch > maxdigitstretch { maxdigitstretch = digitstretch } } if digitstretch > maxdigitstretch { maxdigitstretch = digitstretch } return maxdigitstretch >= 4 || other == 0 && maxdigitstretch >= 3 } func (f *Filter) tokenizeText(r io.Reader, words map[string]struct{}) error { b := &strings.Builder{} var prev string var prev2 string add := func() { defer b.Reset() if b.Len() <= 2 { return } s := b.String() s = strings.Trim(s, "'") var nondigit bool for _, c := range s { if !unicode.IsDigit(c) { nondigit = true break } } if !(nondigit && len(s) > 2) { return } if looksRandom(s) { return } if looksNumeric(s) { return } // todo: do something for URLs, parse them? keep their domain only? if f.Threegrams && prev2 != "" && prev != "" { words[prev2+" "+prev+" "+s] = struct{}{} } if f.Twograms && prev != "" { words[prev+" "+s] = struct{}{} } if f.Onegrams { words[s] = struct{}{} } prev2 = prev prev = s } br := bufio.NewReader(r) peekLetter := func() bool { c, _, err := br.ReadRune() br.UnreadRune() return err == nil && unicode.IsLetter(c) } for { c, _, err := br.ReadRune() if err == io.EOF { break } if err != nil { return err } if !unicode.IsLetter(c) && !unicode.IsDigit(c) && (c != '\'' || b.Len() > 0 && peekLetter()) { add() } else { b.WriteRune(unicode.ToLower(c)) } } add() return nil } // tokenizeHTML parses html, and tokenizes its text into words. func (f *Filter) tokenizeHTML(r io.Reader, meta, words map[string]struct{}) error { htmlReader := &htmlTextReader{ t: html.NewTokenizer(r), meta: map[string]struct{}{}, } return f.tokenizeText(htmlReader, words) } type htmlTextReader struct { t *html.Tokenizer meta map[string]struct{} tagStack []string buf []byte err error } func (r *htmlTextReader) Read(buf []byte) (n int, err error) { // todo: deal with invalid html better. the tokenizer is just tokenizing, we need to fix up the nesting etc. eg, rules say some elements close certain open elements. // todo: deal with inline elements? they shouldn't cause a word break. give := func(nbuf []byte) (int, error) { n := len(buf) if n > len(nbuf) { n = len(nbuf) } copy(buf, nbuf[:n]) nbuf = nbuf[n:] if len(nbuf) < cap(r.buf) { r.buf = r.buf[:len(nbuf)] } else { r.buf = make([]byte, len(nbuf), 3*len(nbuf)/2) } copy(r.buf, nbuf) return n, nil } if len(r.buf) > 0 { return give(r.buf) } if r.err != nil { return 0, r.err } for { switch r.t.Next() { case html.ErrorToken: r.err = r.t.Err() return 0, r.err case html.TextToken: if len(r.tagStack) > 0 { switch r.tagStack[len(r.tagStack)-1] { case "script", "style", "svg": continue } } buf := r.t.Text() if len(buf) > 0 { return give(buf) } case html.StartTagToken: tagBuf, moreAttr := r.t.TagName() tag := string(tagBuf) //log.Printf("tag %q %v", tag, r.tagStack) if tag == "img" && moreAttr { var key, val []byte for moreAttr { key, val, moreAttr = r.t.TagAttr() if string(key) == "alt" && len(val) > 0 { return give(val) } } } // Empty elements, https://developer.mozilla.org/en-US/docs/Glossary/Empty_element switch tag { case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr": continue } r.tagStack = append(r.tagStack, tag) case html.EndTagToken: // log.Printf("tag pop %v", r.tagStack) if len(r.tagStack) > 0 { r.tagStack = r.tagStack[:len(r.tagStack)-1] } case html.SelfClosingTagToken: case html.CommentToken: case html.DoctypeToken: } } }