mesh/merge-gate out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk e22326240833, the trunk is now 482232e8c51c; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
ADR 0359 decided one module holds the operator's mail accounts. This is its first stage: an imap adapter that keeps IDLE on the inbox and polls the other folders read-only, indexes each message in its own database, keeps each attachment once by its SHA-256 in its bucket, and says every added, changed or removed message as a metadata-only event. Bodies and attachments are served only by its tools, to callers the trusted setting grants names. Its health waits for the account's own secret until it is given at the desk (issue 710).
126 lines
3.7 KiB
Go
126 lines
3.7 KiB
Go
package main
|
|
|
|
// A message's text and its attachments, read from its MIME source. The text is kept in the index for search and served
|
|
// through mail_read; each attachment goes to the attachment store once, by its SHA-256 (novox/hq ADR 0359 §3).
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"html"
|
|
"io"
|
|
"mime"
|
|
"regexp"
|
|
"strings"
|
|
|
|
_ "github.com/emersion/go-message/charset" // the charsets mail is written in, beyond UTF-8 and ASCII
|
|
"github.com/emersion/go-message/mail"
|
|
)
|
|
|
|
// Part is one attachment as read from a message.
|
|
type Part struct {
|
|
Name string
|
|
Type string
|
|
Data []byte
|
|
}
|
|
|
|
// MostBodyText bounds the text kept per message: a longer body is cut, and says so.
|
|
const MostBodyText = 256 << 10
|
|
|
|
// Parsed is a message's text and its attachments.
|
|
type Parsed struct {
|
|
Text string
|
|
Attachments []Part
|
|
}
|
|
|
|
// ParseMessage reads a message's text (its plain part; else its HTML part as text) and its attachments. A message the
|
|
// reader cannot follow keeps what was read before the fault, and the error says what.
|
|
func ParseMessage(raw []byte) (Parsed, error) {
|
|
var p Parsed
|
|
r, err := mail.CreateReader(bytes.NewReader(raw))
|
|
if r == nil {
|
|
return p, err
|
|
}
|
|
var plain, htmlText string
|
|
for {
|
|
part, err := r.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return finish(p, plain, htmlText), err
|
|
}
|
|
switch h := part.Header.(type) {
|
|
case *mail.InlineHeader:
|
|
ct, params, _ := h.ContentType()
|
|
_, disp, _ := h.ContentDisposition()
|
|
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
|
|
if readErr != nil {
|
|
return finish(p, plain, htmlText), readErr
|
|
}
|
|
name := params["name"]
|
|
if name == "" {
|
|
name = disp["filename"]
|
|
}
|
|
switch {
|
|
case ct == "text/plain" && plain == "" && name == "":
|
|
plain = string(body)
|
|
case ct == "text/html" && htmlText == "" && name == "":
|
|
htmlText = HTMLText(string(body))
|
|
case !strings.HasPrefix(ct, "text/") || name != "":
|
|
// An inline image or a named inline file is an attachment too.
|
|
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
|
|
}
|
|
case *mail.AttachmentHeader:
|
|
ct, _, _ := h.ContentType()
|
|
name, _ := h.Filename()
|
|
body, readErr := io.ReadAll(io.LimitReader(part.Body, 64<<20))
|
|
if readErr != nil {
|
|
return finish(p, plain, htmlText), readErr
|
|
}
|
|
p.Attachments = append(p.Attachments, Part{Name: name, Type: orOctets(ct), Data: body})
|
|
}
|
|
}
|
|
return finish(p, plain, htmlText), nil
|
|
}
|
|
|
|
func finish(p Parsed, plain, htmlText string) Parsed {
|
|
p.Text = plain
|
|
if strings.TrimSpace(p.Text) == "" {
|
|
p.Text = htmlText
|
|
}
|
|
p.Text = strings.ToValidUTF8(p.Text, "�")
|
|
if len(p.Text) > MostBodyText {
|
|
p.Text = strings.ToValidUTF8(p.Text[:MostBodyText], "") + "\n[the text goes on; it was cut at 256 KiB]"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func orOctets(ct string) string {
|
|
if ct == "" {
|
|
return "application/octet-stream"
|
|
}
|
|
if t, _, err := mime.ParseMediaType(ct); err == nil {
|
|
return t
|
|
}
|
|
return ct
|
|
}
|
|
|
|
var (
|
|
scriptsAndStyles = regexp.MustCompile(`(?is)<(script|style|head)\b.*?</(script|style|head)>`)
|
|
breaks = regexp.MustCompile(`(?i)<(br|/p|/div|/tr|/li|/h[1-6])\b[^>]*>`)
|
|
tags = regexp.MustCompile(`(?s)<[^>]*>`)
|
|
blankLines = regexp.MustCompile(`\n[ \t]*\n([ \t]*\n)+`)
|
|
)
|
|
|
|
// HTMLText is an HTML body as plain text: no script, style or markup, the entities read, paragraphs kept as lines.
|
|
// Text only, so nothing of the sender's markup ever reaches whoever reads it.
|
|
func HTMLText(s string) string {
|
|
s = scriptsAndStyles.ReplaceAllString(s, "")
|
|
s = breaks.ReplaceAllString(s, "\n")
|
|
s = tags.ReplaceAllString(s, "")
|
|
s = html.UnescapeString(s)
|
|
s = strings.ReplaceAll(s, "\r", "")
|
|
s = blankLines.ReplaceAllString(s, "\n\n")
|
|
return strings.TrimSpace(s)
|
|
}
|