knadh/listmonk · error

campaigns.fieldInvalidSendAt

Error message

campaigns.fieldInvalidSendAt

What it means

When the campaign has a send_at date set (c.SendAt.Valid), validateCampaignFields requires it to be strictly in the future: if c.SendAt.Time.Before(time.Now()) it returns campaigns.fieldInvalidSendAt. The library refuses to schedule a campaign in the past.

Source

Thrown at cmd/campaigns.go:714

	}

	// If no content-type is specified, default to richtext.
	if c.ContentType != models.CampaignContentTypeRichtext &&
		c.ContentType != models.CampaignContentTypeHTML &&
		c.ContentType != models.CampaignContentTypePlain &&
		c.ContentType != models.CampaignContentTypeVisual &&
		c.ContentType != models.CampaignContentTypeMarkdown {
		c.ContentType = models.CampaignContentTypeRichtext
	}

	if c.ContentType != models.CampaignContentTypeVisual {
		c.BodySource.Valid = false
	}

	// If there's a "send_at" date, it should be in the future.
	if c.SendAt.Valid {
		if c.SendAt.Time.Before(time.Now()) {
			return c, errors.New(a.i18n.T("campaigns.fieldInvalidSendAt"))
		}
	}

	if len(c.ListIDs) == 0 {
		return c, errors.New(a.i18n.T("campaigns.fieldInvalidListIDs"))
	}

	if !a.manager.HasMessenger(c.Messenger) {
		// If it's a specific SMTP, but it's no longer available (removed/disabled), fall back to general email messenger.
		if strings.HasPrefix(c.Messenger, "email-") {
			c.Messenger = "email"
		} else {
			return c, errors.New(a.i18n.Ts("campaigns.fieldInvalidMessenger", "name", c.Messenger))
		}
	}

	camp := models.Campaign{Body: c.Body, TemplateBody: tplTag}
	if err := c.CompileTemplate(a.manager.TemplateFuncs(&camp)); err != nil {

View on GitHub (pinned to 670c01717d)

Solutions

  1. Generate send_at at the last possible moment before the request using UTC RFC3339 and verify it is > time.Now().
  2. If you don't want scheduling, omit send_at / send null so SendAt stays invalid (unset).
  3. Update the campaign to a new future date if the original slot has already passed.
  4. Account for timezone: send full ISO-8601 with offset instead of naive local datetime strings.

Example fix

// before
{"send_at": "2026-01-01T09:00:00Z"}
// after
{"send_at": time.Now().Add(30 * time.Minute).UTC().Format(time.RFC3339)}
Defensive patterns

Strategy: validation

Validate before calling

function assertFutureSendAt(sendAt) {
  if (sendAt == null) return; // unset is fine
  const t = new Date(sendAt);
  if (isNaN(t.getTime()) || t.getTime() <= Date.now()) {
    throw new Error('send_at must be a valid RFC3339 date in the future');
  }
}

Type guard

function isFutureDate(v: unknown): v is string {
  if (typeof v !== 'string') return false;
  const t = new Date(v);
  return !isNaN(t.getTime()) && t.getTime() > Date.now();
}

Try / catch

try {
  await api.createCampaign({ send_at: sendAt, ...rest });
} catch (e) {
  if (e.message.includes('fieldInvalidSendAt')) {
    throw new Error(`send_at ${sendAt} is in the past relative to the server clock`);
  }
  throw e;
}

Prevention

When it happens

Trigger: CreateCampaign or UpdateCampaign with a send_at timestamp earlier than the server's current time, e.g. sending a RFC3339 string generated earlier or in a timezone that resolves before server 'now'.

Common situations: Clock skew between client and server, client generating the timestamp in a different timezone (naive local time parsed as UTC), or retrying a stale payload minutes later so the date is now past.

Related errors


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