237 lines
7.9 KiB
Go
237 lines
7.9 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
const forwardingHeaderName = "X-LanQin-Forwarded-By"
|
||
|
||
func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) {
|
||
targets, userID, mailboxAddress, err := a.inboundForwardingTargets(ctx, mailboxID)
|
||
if err != nil {
|
||
a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err)
|
||
return
|
||
}
|
||
if len(targets) == 0 || userID == "" || mailboxAddress == "" {
|
||
return
|
||
}
|
||
self := normalizeEmail(mailboxAddress)
|
||
filteredTargets := make([]string, 0, len(targets))
|
||
for _, target := range targets {
|
||
if normalizeEmail(target) == self {
|
||
continue
|
||
}
|
||
filteredTargets = append(filteredTargets, target)
|
||
}
|
||
targets = dedupeEmails(filteredTargets)
|
||
if len(targets) == 0 {
|
||
return
|
||
}
|
||
if len(raw) == 0 {
|
||
raw, err = a.forwardingRawMessage(ctx, messageID)
|
||
if err != nil {
|
||
a.log.Warn("failed to load raw message for forwarding", "message", messageID, "error", err)
|
||
return
|
||
}
|
||
}
|
||
if hasForwardingHeader(raw) {
|
||
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||
return
|
||
}
|
||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||
var rfcMessageID string
|
||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||
if strings.TrimSpace(rfcMessageID) == "" {
|
||
rfcMessageID = messageID
|
||
}
|
||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||
UserID: userID,
|
||
MailboxID: mailboxID,
|
||
SentMessageID: messageID,
|
||
MessageID: rfcMessageID,
|
||
Source: sendSourceForwarding,
|
||
MailFrom: mailboxAddress,
|
||
HeaderFrom: mailboxAddress,
|
||
Recipients: targets,
|
||
MIMEBytes: forwarded,
|
||
Now: a.now().UTC(),
|
||
})
|
||
if err != nil {
|
||
a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","), "error", err)
|
||
return
|
||
}
|
||
if queueID == "" {
|
||
a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||
}
|
||
}
|
||
|
||
func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID string, action MailRuleAction) error {
|
||
var userID, mailboxAddress string
|
||
if err := a.db.QueryRowContext(ctx, `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&userID, &mailboxAddress); err != nil {
|
||
return err
|
||
}
|
||
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
self := normalizeEmail(mailboxAddress)
|
||
filteredTargets := make([]string, 0, len(targets))
|
||
for _, target := range targets {
|
||
if normalizeEmail(target) == self {
|
||
continue
|
||
}
|
||
filteredTargets = append(filteredTargets, target)
|
||
}
|
||
targets = dedupeEmails(filteredTargets)
|
||
if len(targets) == 0 {
|
||
return nil
|
||
}
|
||
raw, err := a.forwardingRawMessage(ctx, messageID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if hasForwardingHeader(raw) {
|
||
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||
return nil
|
||
}
|
||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.config().PublicHostname)
|
||
var rfcMessageID string
|
||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||
if strings.TrimSpace(rfcMessageID) == "" {
|
||
rfcMessageID = messageID
|
||
}
|
||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||
UserID: userID,
|
||
MailboxID: mailboxID,
|
||
SentMessageID: messageID,
|
||
MessageID: ruleForwardQueueMessageID(rfcMessageID, targets),
|
||
Source: sendSourceRuleForwarding,
|
||
MailFrom: mailboxAddress,
|
||
HeaderFrom: mailboxAddress,
|
||
Recipients: targets,
|
||
MIMEBytes: forwarded,
|
||
Now: a.now().UTC(),
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if queueID == "" {
|
||
a.log.Warn("rule forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
|
||
var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
|
||
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
|
||
FROM mailboxes mb
|
||
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id
|
||
LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id
|
||
WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &mailboxTargetsJSON, &accountTarget, &accountTargetsJSON)
|
||
if err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
targets := dedupeEmails(append(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)...))
|
||
if len(targets) == 0 {
|
||
return nil, userID, mailboxAddress, nil
|
||
}
|
||
verifiedTargets := make([]string, 0, len(targets))
|
||
for _, target := range targets {
|
||
verified, err := a.forwardingEmailVerified(ctx, userID, target)
|
||
if err != nil {
|
||
return nil, "", "", err
|
||
}
|
||
if verified {
|
||
verifiedTargets = append(verifiedTargets, target)
|
||
}
|
||
}
|
||
return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
|
||
}
|
||
|
||
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
|
||
msg, err := a.storedMessageByID(ctx, messageID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(msg.RawPath) != "" {
|
||
if ok, err := a.pathIsUnderMaildirRoot(msg.RawPath); err == nil && ok {
|
||
if raw, err := os.ReadFile(msg.RawPath); err == nil {
|
||
return raw, nil
|
||
}
|
||
}
|
||
}
|
||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return BuildMIME(MIMEMessage{
|
||
From: msg.From,
|
||
FromName: msg.FromName,
|
||
To: msg.To,
|
||
CC: msg.CC,
|
||
BCC: msg.BCC,
|
||
Subject: msg.Subject,
|
||
Text: msg.BodyText,
|
||
HTML: msg.BodyHTML,
|
||
MessageID: msg.MessageID,
|
||
Date: messageDate(msg),
|
||
Attachments: attachments,
|
||
})
|
||
}
|
||
|
||
func splitRuleForwardTargets(value string) []string {
|
||
return strings.FieldsFunc(value, func(r rune) bool {
|
||
return unicode.IsSpace(r) || r == ',' || r == ',' || r == ';' || r == ';'
|
||
})
|
||
}
|
||
|
||
func ruleForwardQueueMessageID(messageID string, targets []string) string {
|
||
base := strings.TrimSpace(messageID)
|
||
if base == "" {
|
||
base = newID("ruleforward")
|
||
}
|
||
sum := sha256.Sum256([]byte(strings.Join(dedupeEmails(targets), ",")))
|
||
return base + "#rule-forward-" + hex.EncodeToString(sum[:])[:12]
|
||
}
|
||
|
||
func hasForwardingHeader(raw []byte) bool {
|
||
header := raw
|
||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||
header = raw[:idx]
|
||
} else if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||
header = raw[:idx]
|
||
}
|
||
return strings.Contains(strings.ToLower(string(header)), strings.ToLower(forwardingHeaderName)+":")
|
||
}
|
||
|
||
func addForwardingHeaders(raw []byte, mailboxAddress, hostname string) []byte {
|
||
hostname = strings.TrimSpace(hostname)
|
||
if hostname == "" {
|
||
hostname = "lanqin.local"
|
||
}
|
||
header := fmt.Sprintf("%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n", forwardingHeaderName, hostname, normalizeEmail(mailboxAddress))
|
||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||
out := make([]byte, 0, len(raw)+len(header))
|
||
out = append(out, raw[:idx]...)
|
||
out = append(out, []byte("\r\n"+header)...)
|
||
out = append(out, raw[idx+2:]...)
|
||
return out
|
||
}
|
||
if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||
lfHeader := strings.ReplaceAll(header, "\r\n", "\n")
|
||
out := make([]byte, 0, len(raw)+len(lfHeader))
|
||
out = append(out, raw[:idx]...)
|
||
out = append(out, []byte("\n"+lfHeader)...)
|
||
out = append(out, raw[idx+1:]...)
|
||
return out
|
||
}
|
||
return append([]byte(header+"\r\n"), raw...)
|
||
}
|