Outbound message

An outbound message is the durable record of one message a mailbox originates rather than answers. It carries its own recipients, cc and subject, writes no threading headers, and walks the same delivery states as a reply attempt.

Overview#

An outbound message is the durable record of one message a mailbox originates rather than answers. No inbound message supplies its recipients or subject, so it carries its own, and it writes no threading headers: there is no thread to join yet. Everything else is the reply attempt contract — one idempotency key, one provider mutation, terminal evidence recorded before the call returns.

It is created by skrzynka message send (CLI) or POST /v1/mailboxes/{mailbox_id}/outbound (API), and read back by skrzynka message outbound (CLI), GET /v1/outbound, or GET /v1/outbound/{outbound_id}.

Shape#

The resource contains id, mailbox_id, idempotency_key, recipients, optional cc, subject, the plain-text body, status, optional provider_message_id, optional error_code/error_message, and created_at, updated_at, and optional sent_at timestamps.

recipients and cc are comma-separated bare addresses, normalized before the row is written: each value is split on commas, trimmed, validated as an address, and deduplicated in the order given. cc is null when no address survives normalization. A display name is refused here rather than at the provider, where the refusal would arrive after the row was already claimed.

The row lives in the outbound_messages table added by schema 3. It cascades from its mailbox, so removing a mailbox removes its outbound evidence, and its idempotency_key is unique across the whole table.

State machine#

text
pending -> sending -> sent
                   -> failed
                   -> uncertain

Skrzynka inserts pending before resolving credentials or opening SMTP, immediately records sending, and then records the terminal evidence. sent carries the local provider_message_id and means the configured SMTP server accepted the message; it does not prove final recipient delivery. A credential failure or an SMTP refusal records failed with that code and sentence. Lost terminal SMTP evidence records uncertain with OUTBOUND_UNCERTAIN; on database open, any outbound row left in sending is recovered the same way, with send was interrupted before terminal SMTP evidence was recorded.

uncertain is never resent automatically, because SMTP may already have accepted the message. The operator inspects provider Sent mail first.

Idempotency#

The row is claimed by its idempotency key before anything leaves the process, so a repeated call returns the first attempt instead of handing the provider a second copy. Reusing the key for a different mailbox, recipient set, cc, subject, or normalized body is refused. A retry after failed or uncertain requires a new key and is therefore a deliberate new mutation.

CLI callers may omit --idempotency-key; Skrzynka generates a fresh UUID for that invocation. Retrying automation should supply and retain its own key.

Message construction#

The message uses the mailbox's display name and address as From, the stored recipients as To, the stored cc as Cc, the stored subject verbatim with no Re: prefixing, and a new local Message-ID. No In-Reply-To or References header is written. The body is UTF-8 plain text; the SMTP server accepts or refuses its size. SMTP requires STARTTLS or implicit TLS and uses the password or Gmail XOAUTH2 credential resolved for that one send.

Mailbox selection#

The CLI accepts either the mailbox UUID or the mailbox's own address as --mailbox, because operators know the address they send from. Address matching is case-insensitive. Two mailboxes can legitimately carry one address — the same account reached through a different Skarbiec item, say a provider relay beside a delegated Gmail row — and picking the first of them would send from whichever sorted earlier, so an address that names more than one mailbox is refused with the count and every matching id.

The HTTP API takes the mailbox UUID in the path and performs no address resolution.

Refusals#

  • IDEMPOTENCY_KEY_INVALID — idempotency_key must contain non-whitespace characters only.
  • IDEMPOTENCY_KEY_REUSED — idempotency key already belongs to a different outbound message.
  • IDEMPOTENCY_CONFLICT — outbound message request already exists.
  • OUTBOUND_SUBJECT_INVALID — subject must not be empty.
  • OUTBOUND_BODY_INVALID — outbound body must not be empty, or message body must not be empty when the stored body fails the transport check.
  • OUTBOUND_RECIPIENT_INVALID — <ADDRESS> is not a valid email address, at least one recipient address is required, outbound message must name at least one recipient, or outbound recipient is not a valid email address.
  • OUTBOUND_MESSAGE_INVALID — outbound message could not be encoded as an email message.
  • OUTBOUND_FILE_INVALID — message body file could not be read, message body file must be a regular file no larger than 256 KiB, or message body file must contain valid UTF-8 text.
  • MAILBOX_SELECTOR_AMBIGUOUS — <SELECTOR> names <COUNT> mailboxes (<ID>, <ID>); select one by id.
  • MAILBOX_PROFILE_INVALID — mailbox sending address is invalid.
  • SMTP_TLS_FAILED, SMTP_REJECTED, and SMTP_UNCERTAIN describe provider-facing failure boundaries; see the runbook.

A refused request that never reached sending leaves no row when validation failed before the claim, and a claimed row that failed afterwards is retained with its error_code and error_message for inspection.