3// see https://en.wikipedia.org/wiki/Naive_Bayes_spam_filtering
4// - todo: better html parsing?
5// - todo: try reading text in pdf?
6// - 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.
16 "golang.org/x/net/html"
18 "github.com/mjl-/mox/message"
21func (f *Filter) tokenizeMail(path string) (bool, map[string]struct{}, error) {
22 mf, err := os.Open(path)
24 return false, nil, err
28 f.log.Check(err, "closing message file")
32 return false, nil, err
34 p, _ := message.EnsurePart(f.log.Logger, false, mf, fi.Size())
35 words, err := f.ParseMessage(p)
36 return true, words, err
39// ParseMessage reads a mail and returns a map with words.
40func (f *Filter) ParseMessage(p message.Part) (map[string]struct{}, error) {
41 metaWords := map[string]struct{}{}
42 textWords := map[string]struct{}{}
43 htmlWords := map[string]struct{}{}
45 hdrs, err := p.Header()
47 return nil, fmt.Errorf("parsing headers: %v", err)
50 // Add words from the header, annotated with <field>+":".
51 // todo: add whether header is dkim-verified?
52 for k, l := range hdrs {
55 case "From", "To", "Cc", "Bcc", "Reply-To", "Subject", "Sender", "Return-Path":
56 // case "Subject", "To":
60 words := map[string]struct{}{}
61 f.tokenizeText(strings.NewReader(h), words)
62 for w := range words {
66 metaWords[k+":"+w] = struct{}{}
71 if err := f.mailParse(p, metaWords, textWords, htmlWords); err != nil {
72 return nil, fmt.Errorf("parsing message: %w", err)
75 for w := range metaWords {
76 textWords[w] = struct{}{}
78 for w := range htmlWords {
79 textWords[w] = struct{}{}
85// mailParse looks through the mail for the first text and html parts, and tokenizes their words.
86func (f *Filter) mailParse(p message.Part, metaWords, textWords, htmlWords map[string]struct{}) error {
87 ct := p.MediaType + "/" + p.MediaSubType
89 if ct == "TEXT/HTML" {
90 err := f.tokenizeHTML(p.ReaderUTF8OrBinary(), metaWords, htmlWords)
91 // log.Printf("html parsed, words %v", htmlWords)
94 if ct == "" || strings.HasPrefix(ct, "TEXT/") {
95 err := f.tokenizeText(p.ReaderUTF8OrBinary(), textWords)
96 // log.Printf("text parsed, words %v", textWords)
100 // Nested message, happens for forwarding.
101 if err := p.SetMessageReaderAt(); err != nil {
102 return fmt.Errorf("setting reader on nested message: %w", err)
104 return f.mailParse(*p.Message, metaWords, textWords, htmlWords)
106 for _, sp := range p.Parts {
107 if err := f.mailParse(sp, metaWords, textWords, htmlWords); err != nil {
114func looksRandom(s string) bool {
115 // Random strings, eg 2fvu9stm9yxhnlu. ASCII only and a many consonants in a stretch.
117 const consonants = "bcdfghjklmnpqrstvwxyzBCDFGHJKLMNPQRSTVWXYZ23456789" // 0 and 1 may be used as o and l/i
119 for _, c := range s {
123 if strings.ContainsRune(consonants, c) {
138func looksNumeric(s string) bool {
139 s = strings.TrimPrefix(s, "0x") // Hexadecimal.
140 var digits, hex, other, digitstretch, maxdigitstretch int
141 for _, c := range s {
142 if c >= '0' && c <= '9' {
146 } else if c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' {
151 if digitstretch > maxdigitstretch {
152 maxdigitstretch = digitstretch
155 if digitstretch > maxdigitstretch {
156 maxdigitstretch = digitstretch
158 return maxdigitstretch >= 4 || other == 0 && maxdigitstretch >= 3
161func (f *Filter) tokenizeText(r io.Reader, words map[string]struct{}) error {
162 b := &strings.Builder{}
173 s = strings.Trim(s, "'")
175 for _, c := range s {
176 if !unicode.IsDigit(c) {
182 if !(nondigit && len(s) > 2) {
193 // todo: do something for URLs, parse them? keep their domain only?
195 if f.Threegrams && prev2 != "" && prev != "" {
196 words[prev2+" "+prev+" "+s] = struct{}{}
198 if f.Twograms && prev != "" {
199 words[prev+" "+s] = struct{}{}
202 words[s] = struct{}{}
208 br := bufio.NewReader(r)
210 peekLetter := func() bool {
211 c, _, err := br.ReadRune()
213 return err == nil && unicode.IsLetter(c)
217 c, _, err := br.ReadRune()
224 if !unicode.IsLetter(c) && !unicode.IsDigit(c) && (c != '\'' || b.Len() > 0 && peekLetter()) {
227 b.WriteRune(unicode.ToLower(c))
234// tokenizeHTML parses html, and tokenizes its text into words.
235func (f *Filter) tokenizeHTML(r io.Reader, meta, words map[string]struct{}) error {
236 htmlReader := &htmlTextReader{
237 t: html.NewTokenizer(r),
238 meta: map[string]struct{}{},
240 return f.tokenizeText(htmlReader, words)
243type htmlTextReader struct {
245 meta map[string]struct{}
251func (r *htmlTextReader) Read(buf []byte) (n int, err error) {
252 // 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.
253 // todo: deal with inline elements? they shouldn't cause a word break.
255 give := func(nbuf []byte) (int, error) {
262 if len(nbuf) < cap(r.buf) {
263 r.buf = r.buf[:len(nbuf)]
265 r.buf = make([]byte, len(nbuf), 3*len(nbuf)/2)
280 case html.ErrorToken:
284 if len(r.tagStack) > 0 {
285 switch r.tagStack[len(r.tagStack)-1] {
286 case "script", "style", "svg":
294 case html.StartTagToken:
295 tagBuf, moreAttr := r.t.TagName()
296 tag := string(tagBuf)
297 //log.Printf("tag %q %v", tag, r.tagStack)
299 if tag == "img" && moreAttr {
302 key, val, moreAttr = r.t.TagAttr()
303 if string(key) == "alt" && len(val) > 0 {
309 // Empty elements, https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
311 case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr":
315 r.tagStack = append(r.tagStack, tag)
316 case html.EndTagToken:
317 // log.Printf("tag pop %v", r.tagStack)
318 if len(r.tagStack) > 0 {
319 r.tagStack = r.tagStack[:len(r.tagStack)-1]
321 case html.SelfClosingTagToken:
322 case html.CommentToken:
323 case html.DoctypeToken: