package imap import ( "errors" "fmt" "strings" "time" ) // appendTimeout bounds the whole connect+login+append round-trip so a stalled // or malicious remote server cannot block the request goroutine indefinitely. const appendTimeout = 2 * time.Minute // ErrAppendRejected signals that the remote IMAP server accepted the connection // and login but refused the APPEND itself — e.g. read-only mailbox, missing // insert permission or an exceeded quota (NO [CANNOT] / NO [PERMISSIONDENIED] / // NO [OVERQUOTA]). Callers should surface this to the user as a 4xx with a clear // message rather than a generic 500 (PROJ-70). var ErrAppendRejected = errors.New("imap: append rejected by remote server") // AppendToMailbox connects to the given IMAP account, logs in and appends the // raw RFC-2822 message into the target mailbox (v1: always "INBOX"). It is used // by the self-service restore feature (PROJ-70) and never touches the archive's // own read-only IMAP server — it only writes to the user's external mailbox. // // Distinguishable failure classes: // - connect/login errors are returned wrapped as-is, // - a server-side APPEND rejection is returned wrapped around ErrAppendRejected // so the API layer can map it to a 4xx with the server's own message. func AppendToMailbox(host string, port int, tlsMode, username, password, mailbox string, raw []byte) error { if mailbox == "" { mailbox = "INBOX" } c, err := Connect(host, port, tlsMode) if err != nil { return fmt.Errorf("imap append: connect: %w", err) } defer c.Close() // Bound the entire operation with a single deadline on the raw connection. _ = c.raw.SetDeadline(time.Now().Add(appendTimeout)) defer c.ClearDeadline() if err := c.Login(username, password).Wait(); err != nil { return fmt.Errorf("imap append: login: %w", err) } defer func() { _ = c.Logout().Wait() }() appendCmd := c.Append(mailbox, int64(len(raw)), nil) if _, err := appendCmd.Write(raw); err != nil { return classifyAppendErr(err) } if err := appendCmd.Close(); err != nil { return classifyAppendErr(err) } if _, err := appendCmd.Wait(); err != nil { return classifyAppendErr(err) } return nil } // classifyAppendErr wraps server-side rejections (permission/quota/read-only) // around ErrAppendRejected so the API layer can respond with a 4xx instead of a // generic 500. Detection is based on the well-known IMAP response codes that a // server returns in a tagged NO response (RFC 3501/5530), matched in the error // text since go-imap surfaces them there. func classifyAppendErr(err error) error { if err == nil { return nil } upper := strings.ToUpper(err.Error()) for _, code := range []string{"CANNOT", "PERMISSIONDENIED", "NOPERM", "OVERQUOTA", "READ-ONLY", "READONLY", "ALERT"} { if strings.Contains(upper, code) { return fmt.Errorf("%w: %s", ErrAppendRejected, err.Error()) } } return fmt.Errorf("imap append: %w", err) }