sender.go

  1package sender
  2
  3import (
  4	"bytes"
  5	"crypto/rand"
  6	"crypto/tls"
  7	"crypto/x509"
  8	"encoding/base64"
  9	"encoding/pem"
 10	"errors"
 11	"fmt"
 12	"mime"
 13	"mime/multipart"
 14	"mime/quotedprintable"
 15	"net/smtp"
 16	"net/textproto"
 17	"os"
 18	"path/filepath"
 19	"strings"
 20	"time"
 21
 22	"github.com/floatpane/matcha/clib"
 23	"github.com/floatpane/matcha/config"
 24	"github.com/yuin/goldmark"
 25	"github.com/yuin/goldmark/ast"
 26	"github.com/yuin/goldmark/text"
 27	"go.mozilla.org/pkcs7"
 28)
 29
 30// xoauth2Auth implements the SMTP XOAUTH2 authentication mechanism for OAuth2.
 31// See https://developers.google.com/gmail/imap/xoauth2-protocol
 32type xoauth2Auth struct {
 33	username, token string
 34}
 35
 36func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
 37	resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
 38	return "XOAUTH2", []byte(resp), nil
 39}
 40
 41func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
 42	if more {
 43		// Server sent an error challenge; respond with empty to finish.
 44		return []byte{}, nil
 45	}
 46	return nil, nil
 47}
 48
 49// loginAuth implements the SMTP LOGIN authentication mechanism.
 50// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
 51type loginAuth struct {
 52	username, password string
 53}
 54
 55func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
 56	return "LOGIN", nil, nil
 57}
 58
 59func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
 60	if !more {
 61		return nil, nil
 62	}
 63	prompt := strings.TrimSpace(string(fromServer))
 64	switch strings.ToLower(prompt) {
 65	case "username:":
 66		return []byte(a.username), nil
 67	case "password:":
 68		return []byte(a.password), nil
 69	default:
 70		return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
 71	}
 72}
 73
 74// generateMessageID creates a unique Message-ID header.
 75func generateMessageID(from string) string {
 76	buf := make([]byte, 16)
 77	_, err := rand.Read(buf)
 78	if err != nil {
 79		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 80	}
 81	return fmt.Sprintf("<%x@%s>", buf, from)
 82}
 83
 84// containsMarkup returns true if the string contains Markdown or HTML elements.
 85func containsMarkup(body string) bool {
 86	// Parse the Markdown into an AST. We will consider most AST node kinds as
 87	// markup, but treat bare/autolinks (raw URLs) as plaintext for this
 88	// detection: if a link node's visible text equals its destination (or is
 89	// the destination wrapped in <>), we allow it.
 90	source := []byte(body)
 91	md := goldmark.New()
 92	reader := text.NewReader(source)
 93	doc := md.Parser().Parse(reader)
 94
 95	var hasMarkup bool
 96	ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
 97		if !entering {
 98			return ast.WalkContinue, nil
 99		}
100
101		switch node.Kind() {
102		case ast.KindDocument, ast.KindParagraph, ast.KindText:
103			// not considered formatting
104			return ast.WalkContinue, nil
105		case ast.KindLink:
106			// Check if this is an autolink/raw URL: the link's text equals the
107			// destination. If so, don't treat it as markup for our purposes.
108			linkNode, ok := node.(*ast.Link)
109			if !ok {
110				hasMarkup = true
111				return ast.WalkStop, nil
112			}
113
114			// Collect the visible text of the link
115			var b strings.Builder
116			for c := node.FirstChild(); c != nil; c = c.NextSibling() {
117				if txt, ok := c.(*ast.Text); ok {
118					b.Write(txt.Segment.Value(source))
119				} else {
120					// non-text content inside link -> treat as markup
121					hasMarkup = true
122					return ast.WalkStop, nil
123				}
124			}
125			linkText := b.String()
126			dest := string(linkNode.Destination)
127
128			// Normalize common autolink representations and allow them.
129			if linkText == dest || linkText == "<"+dest+">" {
130				return ast.WalkContinue, nil
131			}
132
133			// Otherwise treat as markup
134			hasMarkup = true
135			return ast.WalkStop, nil
136		default:
137			hasMarkup = true
138			return ast.WalkStop, nil
139		}
140	})
141	return hasMarkup
142}
143
144// detectPlaintextOnly returns true when the body contains only plain text
145// (no images, no attachments, no markdown/HTML formatting that requires multipart).
146func detectPlaintextOnly(body string, images, attachments map[string][]byte) bool {
147	if len(images) > 0 || len(attachments) > 0 {
148		return false
149	}
150	return !containsMarkup(body)
151}
152
153// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
154func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error {
155	smtpServer := account.GetSMTPServer()
156	smtpPort := account.GetSMTPPort()
157
158	if smtpServer == "" {
159		return fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
160	}
161
162	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
163	loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
164
165	fromHeader := account.FetchEmail
166	if account.Name != "" {
167		fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
168	}
169
170	// Set top-level headers (From/To/Subject/Date/etc)
171	headers := map[string]string{
172		"From":         fromHeader,
173		"To":           strings.Join(to, ", "),
174		"Subject":      subject,
175		"Date":         time.Now().Format(time.RFC1123Z),
176		"Message-ID":   generateMessageID(account.FetchEmail),
177		"MIME-Version": "1.0",
178	}
179
180	if len(cc) > 0 {
181		headers["Cc"] = strings.Join(cc, ", ")
182	}
183
184	if inReplyTo != "" {
185		headers["In-Reply-To"] = inReplyTo
186		if len(references) > 0 {
187			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
188		} else {
189			headers["References"] = inReplyTo
190		}
191	}
192
193	// prepare final message buffer and S/MIME payload placeholder
194	var msg bytes.Buffer
195	headerOrder := []string{"From", "To", "Cc", "Subject", "Date", "Message-ID", "MIME-Version", "In-Reply-To", "References"}
196	for _, k := range headerOrder {
197		if v, ok := headers[k]; ok {
198			fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
199		}
200	}
201
202	var payloadToEncrypt []byte
203	var innerBodyBytes []byte
204	var err error
205
206	// Detect plaintext-only mode
207	plaintextOnly := detectPlaintextOnly(plainBody, images, attachments)
208
209	// If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing)
210	if plaintextOnly {
211		if len(images) > 0 || len(attachments) > 0 {
212			return errors.New("plaintext-only messages cannot contain attachments or inline images")
213		}
214
215		// Build quoted-printable encoded body
216		var encBody bytes.Buffer
217		qp := quotedprintable.NewWriter(&encBody)
218		fmt.Fprint(qp, plainBody)
219		qp.Close()
220		encodedBody := encBody.Bytes()
221
222		// Build the canonical MIME part (headers + body) used for signing/encryption
223		var partBuf bytes.Buffer
224		fmt.Fprintf(&partBuf, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
225		fmt.Fprintf(&partBuf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
226		partBuf.Write(encodedBody)
227		canonicalPart := partBuf.Bytes()
228
229		if signSMIME {
230			if account.SMIMECert == "" || account.SMIMEKey == "" {
231				return errors.New("S/MIME certificate or key path is missing")
232			}
233
234			certData, err := os.ReadFile(account.SMIMECert)
235			if err != nil {
236				return err
237			}
238			keyData, err := os.ReadFile(account.SMIMEKey)
239			if err != nil {
240				return err
241			}
242
243			certBlock, _ := pem.Decode(certData)
244			if certBlock == nil {
245				return errors.New("failed to parse certificate PEM")
246			}
247			cert, err := x509.ParseCertificate(certBlock.Bytes)
248			if err != nil {
249				return err
250			}
251
252			keyBlock, _ := pem.Decode(keyData)
253			if keyBlock == nil {
254				return errors.New("failed to parse private key PEM")
255			}
256			privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
257			if err != nil {
258				privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
259				if err != nil {
260					return err
261				}
262			}
263
264			// canonicalize the part (normalize newlines)
265			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
266			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
267
268			signedData, err := pkcs7.NewSignedData(canonicalBody)
269			if err != nil {
270				return err
271			}
272			if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
273				return err
274			}
275			detachedSig, err := signedData.Finish()
276			if err != nil {
277				return err
278			}
279
280			var rb [12]byte
281			var outerBoundary string
282			if _, rerr := rand.Read(rb[:]); rerr == nil {
283				outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
284			} else {
285				// fallback to time-based boundary if crypto/rand fails
286				outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
287			}
288			var signedMsg bytes.Buffer
289			fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
290			fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
291			fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
292			signedMsg.Write(canonicalBody)
293			fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
294			fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
295			fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
296			fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
297			signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
298			fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
299
300			if encryptSMIME {
301				payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
302				payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
303			} else {
304				msg.Write(signedMsg.Bytes())
305			}
306		} else {
307			// Not signing: either encrypt the canonical part or send as plain single-part
308			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
309			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
310			if encryptSMIME {
311				payloadToEncrypt = canonicalBody
312			} else {
313				// Write Content-Type and body as top-level single part
314				fmt.Fprintf(&msg, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
315				fmt.Fprintf(&msg, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
316				msg.Write(encodedBody)
317			}
318		}
319
320	} else {
321		// --- Non-plaintext path: build multipart/mixed with related/alternative, images and attachments ---
322		var innerMsg bytes.Buffer
323		innerWriter := multipart.NewWriter(&innerMsg)
324		innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
325
326		// --- Body Part (multipart/related) ---
327		relatedHeader := textproto.MIMEHeader{}
328		relatedBoundary := "related-" + innerWriter.Boundary()
329		relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
330		relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
331		if err != nil {
332			return err
333		}
334		relatedWriter := multipart.NewWriter(relatedPartWriter)
335		relatedWriter.SetBoundary(relatedBoundary)
336
337		// --- Alternative Part (text and html) ---
338		altHeader := textproto.MIMEHeader{}
339		altBoundary := "alt-" + innerWriter.Boundary()
340		altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
341		altPartWriter, err := relatedWriter.CreatePart(altHeader)
342		if err != nil {
343			return err
344		}
345		altWriter := multipart.NewWriter(altPartWriter)
346		altWriter.SetBoundary(altBoundary)
347
348		// Plain text part
349		textHeader := textproto.MIMEHeader{
350			"Content-Type":              {"text/plain; charset=UTF-8"},
351			"Content-Transfer-Encoding": {"quoted-printable"},
352		}
353		textPart, err := altWriter.CreatePart(textHeader)
354		if err != nil {
355			return err
356		}
357		qpText := quotedprintable.NewWriter(textPart)
358		fmt.Fprint(qpText, plainBody)
359		qpText.Close()
360
361		// HTML part
362		htmlHeader := textproto.MIMEHeader{
363			"Content-Type":              {"text/html; charset=UTF-8"},
364			"Content-Transfer-Encoding": {"quoted-printable"},
365		}
366		htmlPart, err := altWriter.CreatePart(htmlHeader)
367		if err != nil {
368			return err
369		}
370		qpHTML := quotedprintable.NewWriter(htmlPart)
371		fmt.Fprint(qpHTML, htmlBody)
372		qpHTML.Close()
373
374		altWriter.Close() // Finish the alternative part
375
376		// --- Inline Images ---
377		for cid, data := range images {
378			ext := filepath.Ext(strings.Split(cid, "@")[0])
379			mimeType := mime.TypeByExtension(ext)
380			if mimeType == "" {
381				mimeType = "application/octet-stream"
382			}
383
384			imgHeader := textproto.MIMEHeader{}
385			imgHeader.Set("Content-Type", mimeType)
386			imgHeader.Set("Content-Transfer-Encoding", "base64")
387			imgHeader.Set("Content-ID", "<"+cid+">")
388			imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
389
390			imgPart, err := relatedWriter.CreatePart(imgHeader)
391			if err != nil {
392				return err
393			}
394			// Encode raw image bytes to base64, then wrap at 76 chars per MIME rules
395			encodedImg := base64.StdEncoding.EncodeToString(data)
396			imgPart.Write([]byte(clib.WrapBase64(encodedImg)))
397		}
398
399		relatedWriter.Close() // Finish the related part
400
401		// --- Attachments ---
402		for filename, data := range attachments {
403			mimeType := mime.TypeByExtension(filepath.Ext(filename))
404			if mimeType == "" {
405				mimeType = "application/octet-stream"
406			}
407
408			partHeader := textproto.MIMEHeader{}
409			partHeader.Set("Content-Type", mimeType)
410			partHeader.Set("Content-Transfer-Encoding", "base64")
411			partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
412
413			attachmentPart, err := innerWriter.CreatePart(partHeader)
414			if err != nil {
415				return err
416			}
417			encodedData := base64.StdEncoding.EncodeToString(data)
418			// MIME requires base64 to be line-wrapped at 76 characters
419			attachmentPart.Write([]byte(clib.WrapBase64(encodedData)))
420		}
421
422		innerWriter.Close() // Finish the inner message
423
424		innerBodyBytes = append([]byte(innerHeaders), innerMsg.Bytes()...)
425
426		// If not signing, and not encrypting, write the multipart body now
427		if !signSMIME && !encryptSMIME {
428			fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
429			msg.Write(innerMsg.Bytes())
430		}
431	}
432
433	// Handle S/MIME Detached Signing for non-plaintext messages
434	if signSMIME && len(innerBodyBytes) > 0 {
435		if account.SMIMECert == "" || account.SMIMEKey == "" {
436			return errors.New("S/MIME certificate or key path is missing")
437		}
438
439		certData, err := os.ReadFile(account.SMIMECert)
440		if err != nil {
441			return err
442		}
443		keyData, err := os.ReadFile(account.SMIMEKey)
444		if err != nil {
445			return err
446		}
447
448		certBlock, _ := pem.Decode(certData)
449		if certBlock == nil {
450			return errors.New("failed to parse certificate PEM")
451		}
452		cert, err := x509.ParseCertificate(certBlock.Bytes)
453		if err != nil {
454			return err
455		}
456
457		keyBlock, _ := pem.Decode(keyData)
458		if keyBlock == nil {
459			return errors.New("failed to parse private key PEM")
460		}
461		privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
462		if err != nil {
463			privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
464			if err != nil {
465				return err
466			}
467		}
468
469		canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
470		canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
471
472		signedData, err := pkcs7.NewSignedData(canonicalBody)
473		if err != nil {
474			return err
475		}
476		if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
477			return err
478		}
479		detachedSig, err := signedData.Finish()
480		if err != nil {
481			return err
482		}
483
484		var rb [12]byte
485		var outerBoundary string
486		if _, rerr := rand.Read(rb[:]); rerr == nil {
487			outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
488		} else {
489			// fallback to time-based boundary if crypto/rand fails
490			outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
491		}
492		var signedMsg bytes.Buffer
493		fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
494		fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
495		fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
496		signedMsg.Write(canonicalBody)
497		fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
498		fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
499		fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
500		fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
501		signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
502		fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
503
504		if encryptSMIME {
505			payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
506			payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
507		} else {
508			msg.Write(signedMsg.Bytes())
509		}
510	}
511
512	// Handle S/MIME Encryption
513	if encryptSMIME {
514		// Include the sender's own email so it can be decrypted in the Sent folder
515		allRecipients := append([]string{account.Email}, to...)
516		allRecipients = append(allRecipients, cc...)
517		allRecipients = append(allRecipients, bcc...)
518
519		cfgDir, _ := config.GetConfigDir()
520		certsDir := filepath.Join(cfgDir, "certs")
521		var certs []*x509.Certificate
522
523		for _, em := range allRecipients {
524			em = strings.TrimSpace(em)
525			if strings.Contains(em, "<") {
526				parts := strings.Split(em, "<")
527				if len(parts) == 2 {
528					em = strings.TrimSuffix(parts[1], ">")
529				}
530			}
531
532			var certPath string
533			// If this is our own account, use the path from settings rather than requiring it in the certs folder
534			if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
535				certPath = account.SMIMECert
536			} else {
537				certPath = filepath.Join(certsDir, em+".pem")
538			}
539
540			if certData, err := os.ReadFile(certPath); err == nil {
541				if block, _ := pem.Decode(certData); block != nil {
542					if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
543						certs = append(certs, cert)
544					}
545				}
546			}
547		}
548
549		if len(certs) == 0 {
550			return errors.New("cannot encrypt: no valid public certificates found for recipients")
551		}
552
553		encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
554		if err != nil {
555			return err
556		}
557
558		msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
559		msg.WriteString("Content-Transfer-Encoding: base64\r\n")
560		msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
561		msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
562	}
563
564	// Combine all recipients for the envelope
565	allRecipients := append([]string{}, to...)
566	allRecipients = append(allRecipients, cc...)
567	allRecipients = append(allRecipients, bcc...)
568
569	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
570
571	tlsConfig := &tls.Config{
572		ServerName:         smtpServer,
573		InsecureSkipVerify: account.Insecure,
574	}
575
576	var c *smtp.Client
577
578	// Port 465 uses implicit TLS (the connection starts with TLS).
579	// All other ports use plain TCP with optional STARTTLS upgrade.
580	if smtpPort == 465 {
581		conn, err := tls.Dial("tcp", addr, tlsConfig)
582		if err != nil {
583			return err
584		}
585		c, err = smtp.NewClient(conn, smtpServer)
586		if err != nil {
587			conn.Close()
588			return err
589		}
590	} else {
591		var err error
592		c, err = smtp.Dial(addr)
593		if err != nil {
594			return err
595		}
596	}
597	defer c.Close()
598
599	if err = c.Hello("localhost"); err != nil {
600		return err
601	}
602
603	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
604	if smtpPort != 465 {
605		if ok, _ := c.Extension("STARTTLS"); ok {
606			if err = c.StartTLS(tlsConfig); err != nil {
607				return err
608			}
609		}
610	}
611
612	// Authenticate using the best available mechanism.
613	// c.Extension("AUTH") returns the list of supported mechanisms.
614	if ok, mechs := c.Extension("AUTH"); ok {
615		mechList := strings.ToUpper(mechs)
616
617		if account.IsOAuth2() {
618			// Use XOAUTH2 for OAuth2-enabled accounts
619			token, tokenErr := config.GetOAuth2Token(account.Email)
620			if tokenErr != nil {
621				return fmt.Errorf("oauth2: %w", tokenErr)
622			}
623			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
624		} else if strings.Contains(mechList, "PLAIN") {
625			err = c.Auth(plainAuth)
626		} else if strings.Contains(mechList, "LOGIN") {
627			err = c.Auth(loginAuthFallback)
628		} else {
629			// Fall back to PLAIN and let the server decide
630			err = c.Auth(plainAuth)
631		}
632		if err != nil {
633			return err
634		}
635	}
636
637	// Send Envelope
638	if err = c.Mail(account.Email); err != nil {
639		return err
640	}
641	for _, r := range allRecipients {
642		if err = c.Rcpt(r); err != nil {
643			return err
644		}
645	}
646
647	// Write Data
648	w, err := c.Data()
649	if err != nil {
650		return err
651	}
652	_, err = w.Write(msg.Bytes())
653	if err != nil {
654		return err
655	}
656	err = w.Close()
657	if err != nil {
658		return err
659	}
660
661	return c.Quit()
662}