gogcli/internal/cmd/gmail_mime.go

332 lines
9.1 KiB
Go

package cmd
import (
"bytes"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"mime"
"net/mail"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
type mailAttachment struct {
Path string
Filename string
MIMEType string
Data []byte
}
type rfc822Config struct {
allowMissingTo bool
}
type mailOptions struct {
From string
To []string
Cc []string
Bcc []string
ReplyTo string
Subject string
Body string
BodyHTML string
InReplyTo string
References string
AdditionalHeaders map[string]string
Attachments []mailAttachment
}
func buildRFC822(opts mailOptions, cfg *rfc822Config) ([]byte, error) {
allowMissingTo := cfg != nil && cfg.allowMissingTo
if strings.TrimSpace(opts.From) == "" {
return nil, errors.New("missing From")
}
if len(opts.To) == 0 && !allowMissingTo {
return nil, errors.New("missing To")
}
if strings.TrimSpace(opts.Subject) == "" {
return nil, errors.New("missing Subject")
}
var b bytes.Buffer
if err := validateHeaderValue(opts.From); err != nil {
return nil, fmt.Errorf("invalid From: %w", err)
}
for _, a := range append(append([]string{}, opts.To...), append(opts.Cc, opts.Bcc...)...) {
if err := validateHeaderValue(a); err != nil {
return nil, fmt.Errorf("invalid address: %w", err)
}
}
writeHeader(&b, "From", opts.From)
if len(opts.To) > 0 {
writeHeader(&b, "To", strings.Join(opts.To, ", "))
}
if len(opts.Cc) > 0 {
writeHeader(&b, "Cc", strings.Join(opts.Cc, ", "))
}
if len(opts.Bcc) > 0 {
writeHeader(&b, "Bcc", strings.Join(opts.Bcc, ", "))
}
if strings.TrimSpace(opts.ReplyTo) != "" {
if err := validateHeaderValue(opts.ReplyTo); err != nil {
return nil, fmt.Errorf("invalid Reply-To: %w", err)
}
writeHeader(&b, "Reply-To", strings.TrimSpace(opts.ReplyTo))
}
if err := validateHeaderValue(opts.Subject); err != nil {
return nil, fmt.Errorf("invalid Subject: %w", err)
}
writeHeader(&b, "Subject", encodeHeaderIfNeeded(opts.Subject))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
if !hasHeader(opts.AdditionalHeaders, "Message-ID") && !hasHeader(opts.AdditionalHeaders, "Message-Id") {
messageID, err := randomMessageID(opts.From)
if err != nil {
return nil, err
}
writeHeader(&b, "Message-ID", messageID)
}
writeHeader(&b, "MIME-Version", "1.0")
if strings.TrimSpace(opts.InReplyTo) != "" {
if err := validateHeaderValue(opts.InReplyTo); err != nil {
return nil, fmt.Errorf("invalid In-Reply-To: %w", err)
}
writeHeader(&b, "In-Reply-To", strings.TrimSpace(opts.InReplyTo))
}
if strings.TrimSpace(opts.References) != "" {
if err := validateHeaderValue(opts.References); err != nil {
return nil, fmt.Errorf("invalid References: %w", err)
}
writeHeader(&b, "References", strings.TrimSpace(opts.References))
}
for k, v := range opts.AdditionalHeaders {
if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" {
if err := validateHeaderValue(v); err != nil {
return nil, fmt.Errorf("invalid header %s: %w", k, err)
}
writeHeader(&b, k, v)
}
}
plainBody := normalizeCRLF(opts.Body)
htmlBody := normalizeCRLF(opts.BodyHTML)
hasPlain := strings.TrimSpace(plainBody) != ""
hasHTML := strings.TrimSpace(htmlBody) != ""
if len(opts.Attachments) == 0 {
switch {
case hasPlain && hasHTML:
altBoundary, err := randomBoundary()
if err != nil {
return nil, err
}
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/alternative; boundary=%q", altBoundary))
b.WriteString("\r\n")
writeTextPart(&b, altBoundary, "text/plain; charset=\"utf-8\"", plainBody)
writeTextPart(&b, altBoundary, "text/html; charset=\"utf-8\"", htmlBody)
b.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary))
return b.Bytes(), nil
case hasHTML && !hasPlain:
writeHeader(&b, "Content-Type", "text/html; charset=\"utf-8\"")
writeHeader(&b, "Content-Transfer-Encoding", "7bit")
b.WriteString("\r\n")
writeBodyWithTrailingCRLF(&b, htmlBody)
return b.Bytes(), nil
default:
writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"")
writeHeader(&b, "Content-Transfer-Encoding", "7bit")
b.WriteString("\r\n")
writeBodyWithTrailingCRLF(&b, plainBody)
return b.Bytes(), nil
}
}
mixedBoundary, err := randomBoundary()
if err != nil {
return nil, err
}
writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q", mixedBoundary))
b.WriteString("\r\n")
// Body part
b.WriteString(fmt.Sprintf("--%s\r\n", mixedBoundary))
switch {
case hasPlain && hasHTML:
altBoundary, err := randomBoundary()
if err != nil {
return nil, err
}
b.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%q\r\n\r\n", altBoundary))
writeTextPart(&b, altBoundary, "text/plain; charset=\"utf-8\"", plainBody)
writeTextPart(&b, altBoundary, "text/html; charset=\"utf-8\"", htmlBody)
b.WriteString(fmt.Sprintf("--%s--\r\n", altBoundary))
case hasHTML && !hasPlain:
b.WriteString("Content-Type: text/html; charset=\"utf-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
writeBodyWithTrailingCRLF(&b, htmlBody)
default:
b.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
writeBodyWithTrailingCRLF(&b, plainBody)
}
// Attachments
for _, a := range opts.Attachments {
if a.Filename == "" {
a.Filename = filepath.Base(a.Path)
}
if a.MIMEType == "" {
a.MIMEType = mime.TypeByExtension(strings.ToLower(filepath.Ext(a.Filename)))
if a.MIMEType == "" {
a.MIMEType = "application/octet-stream"
}
}
if len(a.Data) == 0 {
data, err := os.ReadFile(a.Path)
if err != nil {
return nil, err
}
a.Data = data
}
b.WriteString(fmt.Sprintf("\r\n--%s\r\n", mixedBoundary))
b.WriteString(fmt.Sprintf("Content-Type: %s\r\n", a.MIMEType))
b.WriteString("Content-Transfer-Encoding: base64\r\n")
b.WriteString(fmt.Sprintf("Content-Disposition: attachment; %s\r\n\r\n", contentDispositionFilename(a.Filename)))
b.WriteString(wrapBase64(a.Data))
b.WriteString("\r\n")
}
b.WriteString(fmt.Sprintf("--%s--\r\n", mixedBoundary))
return b.Bytes(), nil
}
func writeHeader(b *bytes.Buffer, name, value string) {
b.WriteString(name)
b.WriteString(": ")
b.WriteString(value)
b.WriteString("\r\n")
}
func wrapBase64(b []byte) string {
s := base64.StdEncoding.EncodeToString(b)
const width = 76
var out strings.Builder
for len(s) > width {
out.WriteString(s[:width])
out.WriteString("\r\n")
s = s[width:]
}
if len(s) > 0 {
out.WriteString(s)
}
return out.String()
}
func writeBodyWithTrailingCRLF(b *bytes.Buffer, body string) {
b.WriteString(body)
if !strings.HasSuffix(body, "\r\n") {
b.WriteString("\r\n")
}
}
func writeTextPart(b *bytes.Buffer, boundary string, contentType string, body string) {
_, _ = fmt.Fprintf(b, "--%s\r\n", boundary)
_, _ = fmt.Fprintf(b, "Content-Type: %s\r\n", contentType)
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
writeBodyWithTrailingCRLF(b, body)
}
func randomBoundary() (string, error) {
var b [18]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return "gogcli_" + base64.RawURLEncoding.EncodeToString(b[:]), nil
}
func validateHeaderValue(v string) error {
if strings.Contains(v, "\r") || strings.Contains(v, "\n") {
return errors.New("header value contains newline")
}
return nil
}
func hasHeader(headers map[string]string, name string) bool {
for k := range headers {
if strings.EqualFold(k, name) {
return true
}
}
return false
}
func randomMessageID(from string) (string, error) {
domain := "gogcli.local"
if addr, err := mail.ParseAddress(strings.TrimSpace(from)); err == nil && addr != nil {
if at := strings.LastIndex(addr.Address, "@"); at != -1 && at+1 < len(addr.Address) {
domain = strings.TrimSpace(addr.Address[at+1:])
}
} else if at := strings.LastIndex(from, "@"); at != -1 && at+1 < len(from) {
domain = strings.TrimSpace(from[at+1:])
domain = strings.Trim(domain, " >")
}
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
local := base64.RawURLEncoding.EncodeToString(b[:])
return fmt.Sprintf("<%s@%s>", local, domain), nil
}
func encodeHeaderIfNeeded(v string) string {
if isASCII(v) {
return v
}
return mime.QEncoding.Encode("utf-8", v)
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
return true
}
func normalizeCRLF(s string) string {
// Normalize to CRLF for RFC 5322 / MIME messages.
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
return strings.ReplaceAll(s, "\n", "\r\n")
}
func contentDispositionFilename(filename string) string {
filename = strings.TrimSpace(filename)
if filename == "" {
return `filename="attachment"`
}
if isASCII(filename) {
return fmt.Sprintf("filename=%q", filename)
}
// RFC 5987 / RFC 2231 style.
return "filename*=UTF-8''" + rfc5987Encode(filename)
}
func rfc5987Encode(s string) string {
// url.QueryEscape uses '+' for spaces; RFC 5987 wants %20.
esc := url.QueryEscape(s)
return strings.ReplaceAll(esc, "+", "%20")
}