knadh/listmonk · error

error unmarshalling Forwardemail notification: %v

Error message

error unmarshalling Forwardemail notification: %v

What it means

After the HMAC signature is verified, Forwardemail's ProcessBounce unmarshals the request body into the forwardemailNotif struct. This error is thrown when the (signature-valid) body is not valid JSON or field types don't match the struct — e.g. `bounced_at` not an RFC3339 time or `bounce` not an object.

Source

Thrown at internal/bounce/webhooks/forwardemail.go:73

	sig, err := hex.DecodeString(sigHex)
	if err != nil {
		return nil, fmt.Errorf("invalid signature encoding: %v", err)
	}

	// Generate HMAC using the request body and secret key
	mac := hmac.New(sha256.New, p.hmacKey)
	mac.Write(body)
	expectedSignature := mac.Sum(nil)

	// Compare the generated signature with the provided signature
	if !hmac.Equal(expectedSignature, sig) {
		return nil, errors.New("invalid signature")
	}

	// Parse the JSON payload
	var n forwardemailNotif
	if err := json.Unmarshal(body, &n); err != nil {
		return nil, fmt.Errorf("error unmarshalling Forwardemail notification: %v", err)
	}

	// Categorize the bounce type
	typ := models.BounceTypeSoft
	hardBounceCategories := []string{"block", "recipient", "virus", "spam"}
	for _, category := range hardBounceCategories {
		if n.Bounce.Category == category {
			typ = models.BounceTypeHard
			break
		}
	}

	campUUID := ""
	if v, ok := n.Headers["X-Listmonk-Campaign"]; ok {
		campUUID = v
	}

	return []models.Bounce{{

View on GitHub (pinned to 670c01717d)

Solutions

  1. Validate the body is valid JSON (`json.Valid(body)`) before sending/expecting success, and log the Unmarshal error to find the offending field.
  2. Ensure `bounced_at` is an RFC3339 timestamp string and `bounce` is an object with the expected fields.
  3. Use Forwardemail's documented bounce webhook payload as a fixture in tests.
  4. Check no middleware is altering the request body between signature verification and parsing.

Example fix

// before: wrong field types
body := []byte(`{"recipient":"a@b.com","bounced_at":1725000000}`) // bounced_at as unix int

// after: RFC3339 timestamp
body := []byte(`{"recipient":"a@b.com","bounced_at":"2024-08-30T10:00:00Z"}`)
Defensive patterns

Strategy: validation

Validate before calling

func isForwardemailPayload(body []byte) bool {
    if !json.Valid(body) {
        return false
    }
    var probe struct {
        Recipient string `json:"recipient"`
        BouncedAt string `json:"bounced_at"`
        Bounce    struct {
            Category string `json:"category"`
        } `json:"bounce"`
    }
    if json.Unmarshal(body, &probe) != nil {
        return false
    }
    _, err := time.Parse(time.RFC3339, probe.BouncedAt)
    return probe.Recipient != "" && err == nil
}

Try / catch

bounces, err := fw.ProcessBounce(sig, body)
if err != nil {
    if strings.Contains(err.Error(), "unmarshalling Forwardemail notification") {
        log.Printf("forwardemail payload schema mismatch: %v; body=%s", err, body)
        http.Error(w, "invalid payload", http.StatusBadRequest)
        return
    }
    http.Error(w, "processing failed", http.StatusInternalServerError)
}

Prevention

When it happens

Trigger: Calling ProcessBounce with: an empty body, non-JSON bodies (form-encoded, plain text), JSON that doesn't match the schema (e.g. `bounced_at` as a unix number instead of an RFC3339 string, `bounce` as a string), or bodies signed but produced by a different provider version.

Common situations: Testing with a signed but hand-crafted payload that has wrong field types; Forwardemail changing their webhook schema; a middleware mutating the body after signing (re-compaction usually fine, but trimming can break JSON); posting the raw email instead of the JSON webhook envelope.

Related errors


AI-assisted analysis of knadh/listmonk@670c01717d (2026-09-01). Data as JSON: /api/errors/1128b8db2b793cb6. Report an issue: GitHub.