openapi: 3.1.0
info:
  title: smartest.email automation steps, enrollment management and reporting (preview)
  version: '2026-10-20'
  description: |
    Contract for the automation preview's capabilities, manual enrollment,
    enrollment management, duplication and reporting endpoints.
    Requires ENABLE_PREVIEW_FEATURES=true on a non-production server. Production
    startup rejects this broad flag; these endpoints are not publicly enabled.
    The browser editor also requires NEXT_PUBLIC_ENABLE_PREVIEW_FEATURES=true.

    Supported flows: active signup-form or manual trigger, once per contact,
    add_tag/remove_tag/send_email/update_field/wait/wait_until actions, one tag or contact-text
    condition with Yes/No paths or one weighted A/B split, and exit. Tags must belong to the workspace.
    Removing an absent tag membership succeeds without changing other tags.
    Duplicate worker signals never repeat a committed action.

    update_field sets literal text on firstName, lastName, phone, or one custom key.
    Its strict FieldUpdateConfig requires value, including when it is empty.
    Empty text clears the value (a custom key remains present with an empty string).
    Values preserve spaces and line breaks, with no placeholder expansion, Unicode
    normalization or type coercion. They allow 0–500 Unicode characters. Custom keys
    allow 1–100 characters without leading/trailing Unicode whitespace; dots are
    literal, not nested paths. NUL is forbidden in keys and values. Email, source,
    subscription consent, tenant and system fields cannot be updated by this step.
    A selected custom value of any previous type is replaced with text; all other
    custom keys remain unchanged. Data is read under the contact lock. The update,
    step history and cursor commit atomically, so retries cannot reapply a committed
    change after later contact edits. History contains completion, never field values.
    Paused or stopped enrollments and unsubscribed contacts cannot perform the
    update. A field
    update qualifies as the journey's required action; all graph limits still apply.

    wait_until waits for a contact to have a selected workspace tag. Configure
    tagId, duration and unit (minutes, hours or days), with a whole duration from
    one minute to 30 days. It is a waiting step, not a qualifying tag/email/field-update action.
    Its single unlabeled forward edge runs only when the condition is observed
    before the deadline. The worker checks immediately, then about every 30 seconds.
    The deadline starts on first execution and persists across retries, restarts
    and pauses. Pausing does not extend it. A deadline already reached takes
    precedence over current tag membership: the enrollment exits with timed_out
    history and later steps do not run. Deleted or inaccessible tags fail the step.

    condition config follows ConditionConfig below. Legacy {tagId} still means
    has_tag; new tag conditions explicitly select has_tag or not_has_tag. Field
    conditions support firstName, lastName, phone, source, and literal custom keys.
    Text comparisons are case-sensitive and preserve spaces, without coercion or
    Unicode normalization. Missing/null custom fields are empty strings. Non-text
    custom values always select No, including for neq and empty/not-empty checks.
    Values are read when the step executes. The chosen outcome and cursor commit
    atomically and are not reconsidered by retries or later contact edits. History
    contains the outcome, never the evaluated contact value. Invalid configuration
    fails activation; invalid saved configuration fails execution without advancing.

    split uses the strict SplitConfig below: percentageA is a whole number from
    1 to 99, and B receives 100 minus percentageA. Assignment is approximately
    weighted, not an exact cohort quota. Version 1 deterministically assigns an
    enrollment using its UUID and the split step ID. The chosen A/B outcome,
    history and cursor commit atomically; a committed choice is never reassigned
    on retries. A copied journey has new enrollments and does not copy assignments.
    There is no automatic winner selection. Each split has exactly two distinct
    forward targets, labelled A and B. A journey can contain at most one branch
    step in total: either a condition or a split. The 20-step, once-per-contact,
    final-exit and required tag/email/field-update action limits remain in force.
    A split is not a qualifying action and cannot bypass consent or workspace checks.
servers:
  - url: http://localhost:8080/api/v1
    description: Local development preview
security: [{ bearerAuth: [] }]
paths:
  /journeys/capabilities:
    get:
      operationId: getJourneyCapabilities
      summary: Read the current user's automation permissions
      description: |
        Requires journey:view. These flags reflect the current user's role;
        actions also validate the particular journey's state, tenant-owned
        resources, contact eligibility and available plan capacity.
      responses:
        '200':
          description: Permission flags for automation controls
          content:
            application/json:
              schema:
                type: object
                required: [canCreate, canEdit, canDelete, canActivate, canViewAnalytics, canEnroll, canStopEnrollments]
                properties:
                  canCreate: { type: boolean }
                  canEdit: { type: boolean }
                  canDelete: { type: boolean }
                  canActivate: { type: boolean }
                  canViewAnalytics: { type: boolean }
                  canEnroll:
                    type: boolean
                    description: Requires journey:view, journey:activate and contact:view together.
                  canStopEnrollments:
                    type: boolean
                    description: Requires journey:view, journey:activate and contact:view together. The enrollment must also be active without a sending claim.
        '401': { description: Authentication required }
        '403': { description: Missing journey:view permission or current role unavailable }
  /journeys/{id}/duplicate:
    parameters: [{ $ref: '#/components/parameters/JourneyID' }]
    post:
      operationId: duplicateJourney
      summary: Copy a journey into an editable draft
      description: |
        Requires journey:view and journey:create plus available runtime plan
        capacity. No request body. Copies the saved definition and appends a
        Copy suffix to its name, with a new ID, creator and timestamps. Always
        draft: no enrollments, history, delivery claims or active state are
        copied. Changes to the copy do not alter the original. Validate and
        activate the draft separately before contacts can enter it.
      responses:
        '201':
          description: New draft JourneyDTO
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Journey' }
        '400': { description: Invalid journey UUID }
        '401': { description: Authentication required }
        '403': { description: Missing permission or plan capacity reached }
        '404': { description: Journey missing or belongs to another workspace }
        '429': { description: Rate limit reached }
        '500': { description: Duplication failed }
  /journeys/{id}/analytics:
    parameters: [{ $ref: '#/components/parameters/JourneyID' }]
    get:
      operationId: getJourneyAnalytics
      summary: Read enrollment totals and step outcomes
      description: |
        Requires analytics:view; scoped to the authenticated workspace. Each step
        counts at most once per enrollment. Reached (entered) includes recorded
        outcomes and the persisted current cursor. Pending includes queued work,
        live SMTP claims, waiting tag conditions and steps awaiting a prior wait's delay. Completed
        means the action committed: waits count when their delay is scheduled,
        branches when decided, emails only when SMTP accepted them. SMTP
        acceptance does not prove inbox delivery. Failed includes failed,
        bounced, deferred and uncertain outcomes; exited includes skipped emails
        eligibility stops and wait_until timeouts. A waiting condition is completed
        only after its tag is observed before the deadline. Uncertain sends are never replayed automatically.
        Older enrollments may lack non-email history. Missing activity is not
        inferred from graph order. Results follow the saved flow order.
      responses:
        '200':
          description: Enrollment counts and step metrics
          content:
            application/json:
              schema:
                type: object
                required: [totalEnrolled, active, completed, exited, failed, stepMetrics]
                properties:
                  totalEnrolled: { $ref: '#/components/schemas/Count' }
                  active: { $ref: '#/components/schemas/Count' }
                  completed: { $ref: '#/components/schemas/Count' }
                  exited: { $ref: '#/components/schemas/Count' }
                  failed: { $ref: '#/components/schemas/Count' }
                  stepMetrics:
                    type: array
                    items:
                      type: object
                      required: [stepId, entered, completed, pending, failed, exited, aCount, bCount]
                      properties:
                        stepId: { type: string }
                        entered: { $ref: '#/components/schemas/Count' }
                        completed: { $ref: '#/components/schemas/Count' }
                        pending: { $ref: '#/components/schemas/Count' }
                        failed: { $ref: '#/components/schemas/Count' }
                        exited: { $ref: '#/components/schemas/Count' }
                        aCount:
                          allOf: [{ $ref: '#/components/schemas/Count' }]
                          description: Enrollments with a recorded A outcome for this split; zero for other steps.
                        bCount:
                          allOf: [{ $ref: '#/components/schemas/Count' }]
                          description: Enrollments with a recorded B outcome for this split; zero for other steps.
        '401': { description: Authentication required }
        '403': { description: Missing analytics:view permission }
        '404': { description: Journey missing or belongs to another workspace }
        '500': { description: Reporting failed; database errors are not reported as zero activity }
  /journeys/{id}/enrollments:
    parameters: [{ $ref: '#/components/parameters/JourneyID' }]
    post:
      operationId: enrollJourneyContacts
      summary: Enroll existing eligible contacts in an active manual journey
      description: |
        Requires journey:view, journey:activate and contact:view. The journey
        must be active with triggerType manual, empty triggerConfig {}, and
        enrollmentRule once. Its complete flow is validated again before any
        contacts are enrolled. Paused journeys reject new enrollments; existing
        enrollments retain their cursor and resume when the journey is activated.

        Submit 1 to 100 distinct, tenant-owned contact UUIDs. Missing or foreign
        journeys or contacts return a generic 404 and leave the entire batch
        unchanged. Invalid, duplicate, empty or excessive IDs return 400.

        Results preserve request order. Any earlier enrollment, including a
        completed, exited or failed one, returns already_enrolled and is never
        restarted. Retrying a request cannot enroll the same contact twice.
        Otherwise, a contact with subscribed=false or an unsubscribedAt value
        is ineligible/unsubscribed. Suppression evidence, including a withdrawn
        subscription on a same-workspace contact with the same email address,
        a bounce record or a complained/bounced/unsubscribed email event, returns
        ineligible/suppressed. Enrollment changes neither consent nor contact
        subscription state. Eligibility is checked again before email delivery.

        New enrollments persist a due cursor for the existing worker. Emails
        and other actions may run immediately. This endpoint does not send a
        test email or automatically replay an uncertain SMTP outcome.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contactIds]
              additionalProperties: false
              properties:
                contactIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  uniqueItems: true
                  items: { type: string, format: uuid }
            example:
              contactIds: [11111111-1111-4111-8111-111111111111]
      responses:
        '200':
          description: Ordered per-contact results, including already-enrolled and ineligible contacts
          content:
            application/json:
              schema:
                type: object
                required: [enrolled, alreadyEnrolled, ineligible, results]
                properties:
                  enrolled: { $ref: '#/components/schemas/Count' }
                  alreadyEnrolled: { $ref: '#/components/schemas/Count' }
                  ineligible: { $ref: '#/components/schemas/Count' }
                  results:
                    type: array
                    items:
                      type: object
                      required: [contactId, status]
                      properties:
                        contactId: { type: string, format: uuid }
                        status: { type: string, enum: [enrolled, already_enrolled, ineligible] }
                        enrollmentId: { type: string, format: uuid }
                        reason: { type: string, enum: [unsubscribed, suppressed] }
        '400': { description: Malformed request or invalid, missing, duplicate or excessive contact IDs }
        '401': { description: Authentication required }
        '403': { description: Missing journey:view, journey:activate or contact:view permission }
        '404': { description: Journey or contact missing or belongs to another workspace; entire batch unchanged }
        '409': { description: Only active manual journeys accept contact enrollment }
        '422': { description: Current journey definition is invalid }
        '429': { description: Rate limit reached }
        '500': { description: Enrollment failed; retrying is safe and cannot restart prior enrollments }
    get:
      operationId: getJourneyEnrollments
      summary: Read enrollment progress and execution history
      description: |
        Requires journey:view. Ordered by enteredAt descending, then ID
        descending. Page below 1 normalizes to 1. Missing pageSize defaults to
        50; values below 1 normalize to 20 and above 100 clamp to 100. History
        contains observed execution/delivery times, not a reconstructed path.
        A live sending claim has no exitedAt. Records may outlive their contact.
        An optional status filter and literal email search apply before counting
        and pagination. Without a search, records whose contact was deleted are
        still included. Search only matches current contacts in this workspace.
        Manual stops retain earlier step and branch history and expose the
        original stopReason and stoppedAt separately from the stable exitReason.
      parameters:
        - { in: query, name: page, schema: { type: integer, default: 1 } }
        - { in: query, name: pageSize, schema: { type: integer, default: 50 } }
        - in: query
          name: status
          description: Omit to include every enrollment status.
          schema: { type: string, enum: [active, completed, exited, failed] }
        - in: query
          name: search
          description: Case-insensitive literal substring of a current contact's email address, trimmed before matching. Maximum 254 Unicode characters; control characters and invalid UTF-8 are rejected. SQL wildcard characters are treated as ordinary text.
          schema: { type: string, maxLength: 254 }
      responses:
        '200':
          description: A page of enrollment records
          content:
            application/json:
              schema:
                type: object
                required: [data, total, page, pageSize, totalPages]
                properties:
                  total: { $ref: '#/components/schemas/Count' }
                  page: { type: integer, minimum: 1 }
                  pageSize: { type: integer, minimum: 1, maximum: 100 }
                  totalPages: { $ref: '#/components/schemas/Count' }
                  data: { type: array, items: { $ref: '#/components/schemas/Enrollment' } }
        '400':
          description: Invalid journey UUID, status or email search. Invalid filters use code invalid_filter.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationError' }
        '401': { description: Authentication required }
        '403': { description: Missing journey:view permission }
        '404': { description: Journey missing or belongs to another workspace }
        '500': { description: Enrollment retrieval failed }
  /journeys/{id}/enrollments/{enrollmentId}/stop:
    parameters:
      - { $ref: '#/components/parameters/JourneyID' }
      - { in: path, name: enrollmentId, required: true, schema: { type: string, format: uuid } }
    post:
      operationId: stopJourneyEnrollment
      summary: Stop future steps for one active enrollment
      description: |
        Requires journey:view, journey:activate and contact:view together.
        Both the journey and enrollment must belong to the authenticated workspace,
        and the enrollment must belong to the specified journey.

        Records an operator's reason and changes an active enrollment to exited,
        with exitReason "stopped by user". Clears its scheduled work while retaining
        earlier step history, branch outcomes and email delivery records. Completed
        actions and sent messages are not reversed. Subscription consent is unchanged.
        The contact cannot enter this once-per-contact journey again.

        An enrollment with any email in sending state cannot be stopped while its
        SMTP result is pending. Wait for that result and refresh before retrying.
        Naturally completed, exited or failed enrollments also return a conflict.
        Retrying a previously successful manual stop returns 200 with alreadyStopped
        true and the original reason and timestamp; it does not overwrite the reason
        or write another stop audit entry.

        The JSON body must contain only one reason property. Unknown or duplicate
        properties, trailing JSON values and non-string reasons are rejected. Leading
        and trailing whitespace is removed. The resulting reason must contain 1 to
        500 Unicode characters. Line feeds (LF) and tabs are allowed; carriage
        returns (CR) and other control characters are rejected. Reasons are user
        content, not translation keys.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              additionalProperties: false
              properties:
                reason: { type: string, minLength: 1, maxLength: 500 }
            example:
              reason: Contact asked to leave this onboarding journey.
      responses:
        '200':
          description: Enrollment stopped, or the original successful stop returned unchanged
          content:
            application/json:
              schema:
                type: object
                required: [enrollmentId, status, reason, stoppedAt, alreadyStopped]
                properties:
                  enrollmentId: { type: string, format: uuid }
                  status: { type: string, const: exited }
                  reason: { type: string }
                  stoppedAt: { type: string, format: date-time }
                  alreadyStopped: { type: boolean }
        '400':
          description: Invalid UUID or malformed/unknown/duplicate JSON uses code invalid_request. Missing, null or invalid reason uses code invalid_reason.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationError' }
        '401': { description: Authentication required }
        '403': { description: Missing journey:view, journey:activate or contact:view permission }
        '404': { description: Journey or enrollment missing, mismatched or belongs to another workspace }
        '409':
          description: enrollment_sending while an email result is pending, or enrollment_terminal after a natural terminal outcome
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationError' }
        '429': { description: Rate limit reached }
        '500': { description: Stop failed; retrying is safe }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
  parameters:
    JourneyID: { in: path, name: id, required: true, schema: { type: string, format: uuid } }
  schemas:
    Count: { type: integer, minimum: 0 }
    AutomationError:
      type: object
      required: [error]
      properties:
        error: { type: string, description: Human-readable error message. }
        code:
          type: string
          enum: [invalid_filter, invalid_request, invalid_reason, enrollment_sending, enrollment_terminal]
          description: Stable code for enrollment filter and stop errors, where supplied.
    Journey:
      type: object
      required: [id, name, status, triggerType, triggerConfig, steps, enrollmentRule, createdBy, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        status: { type: string, const: draft }
        triggerType: { type: string }
        triggerConfig: { type: object }
        enrollmentRule: { type: string }
        steps:
          type: array
          items: { $ref: '#/components/schemas/Step' }
        createdBy: { type: string, format: uuid }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    Step:
      type: object
      description: |
        Drafts can be incomplete. Activation validates tenant-owned resources,
        supported action configuration, maximum 20 steps, forward paths, at
        most one condition or split in total and final exit. Duplication preserves any unsupported
        draft definition without activating it. For an activation-ready condition,
        config must match ConditionConfig; incomplete drafts may not match yet.
        An update_field step must match FieldUpdateConfig before activation.
        A split step must match SplitConfig and have exactly two connections with
        conditions A and B leading to different forward targets. Both paths must
        reach the final exit. Splits do not satisfy the required tag/email/field-update action.
      properties:
        id: { type: string }
        type: { type: string }
        name: { type: string }
        config: { type: object }
        position: { type: object, properties: { x: { type: number }, y: { type: number } } }
        connections:
          type: array
          items:
            type: object
            properties:
              from: { type: string }
              to: { type: string }
              condition: { type: string, enum: ['', 'yes', 'no', 'A', 'B'], description: Omitted or empty for ordinary steps; yes/no for a condition and A/B for a split. }
      examples:
        - id: finish-onboarding
          type: remove_tag
          name: Remove onboarding tag
          config: { tagId: 11111111-1111-4111-8111-111111111111 }
          position: { x: 0, y: 280 }
          connections: [{ from: finish-onboarding, to: exit }]
        - id: wait-for-onboarding
          type: wait_until
          name: Wait for onboarding completion
          config: { tagId: 11111111-1111-4111-8111-111111111111, duration: 7, unit: days }
          position: { x: 0, y: 140 }
          connections: [{ from: wait-for-onboarding, to: finish-onboarding }]
        - id: set-onboarding-stage
          type: update_field
          name: Mark onboarding complete
          config: { field: custom, customField: onboarding.stage, value: completed }
          connections: [{ from: set-onboarding-stage, to: exit }]
        - id: clear-phone
          type: update_field
          name: Clear phone number
          config: { field: phone, value: '' }
          connections: [{ from: clear-phone, to: exit }]
        - id: language-check
          type: condition
          name: Dutch-speaking contact
          config: { kind: field, field: custom, customField: language, operator: eq, value: nl }
          connections:
            - { from: language-check, to: dutch-email, condition: 'yes' }
            - { from: language-check, to: default-email, condition: 'no' }
        - id: welcome-split
          type: split
          name: Compare two welcome messages
          config: { percentageA: 30 }
          connections:
            - { from: welcome-split, to: welcome-a, condition: 'A' }
            - { from: welcome-split, to: welcome-b, condition: 'B' }
    SplitConfig:
      type: object
      additionalProperties: false
      required: [percentageA]
      description: |
        Strict configuration for a weighted A/B split. Only percentageA is accepted;
        B is always 100 minus A. Null, strings, booleans, fractional values, missing
        or unknown properties, and percentages outside 1–99 are rejected. Assignment
        is approximate and stable per enrollment and step; no exact quota or automatic
        winner is provided. A split counts toward the single permitted branch step.
      properties:
        percentageA:
          type: integer
          minimum: 1
          maximum: 99
          description: Approximate percentage of enrollments assigned to path A.
    FieldUpdateConfig:
      type: object
      additionalProperties: false
      required: [field, value]
      description: |
        Set or clear one allowed contact field with literal text. The value property
        is mandatory; an omitted or null value is not a request to clear. Empty text
        clears a built-in field or stores an empty string at the exact custom key.
        Custom values of any previous type are replaced; other custom keys remain.
        Email, source and subscription consent are not supported targets.
      properties:
        field: { type: string, enum: [firstName, lastName, phone, custom] }
        customField:
          type: string
          minLength: 1
          maxLength: 100
          pattern: '^[^\u0000]*$'
          description: Literal key; no leading/trailing Unicode whitespace and no NUL.
        value:
          type: string
          maxLength: 500
          pattern: '^[^\u0000]*$'
          description: Exact Unicode text including spaces and line breaks; empty clears. NUL is forbidden.
      allOf:
        - if: { properties: { field: { const: custom } } }
          then: { required: [customField] }
          else: { not: { required: [customField] } }
    ConditionConfig:
      description: |
        Strict config for a condition step. Exactly one alternative must match;
        unknown or incompatible properties are rejected. Empty-check operators
        omit value. Built-in fields omit customField. Keys use their literal
        spelling and are not interpreted as nested paths.
      oneOf:
        - $ref: '#/components/schemas/LegacyTagCondition'
        - $ref: '#/components/schemas/TagCondition'
        - $ref: '#/components/schemas/FieldCondition'
    LegacyTagCondition:
      type: object
      additionalProperties: false
      required: [tagId]
      properties:
        tagId: { type: string, format: uuid, description: Workspace-owned tag; legacy has-tag condition. }
    TagCondition:
      type: object
      additionalProperties: false
      required: [kind, operator, tagId]
      properties:
        kind: { type: string, const: tag }
        operator: { type: string, enum: [has_tag, not_has_tag] }
        tagId: { type: string, format: uuid }
    FieldCondition:
      type: object
      additionalProperties: false
      required: [kind, field, operator]
      properties:
        kind: { type: string, const: field }
        field: { type: string, enum: [firstName, lastName, phone, source, custom] }
        customField:
          type: string
          minLength: 1
          maxLength: 100
          description: Literal custom-field key; no leading/trailing Unicode whitespace.
        operator: { type: string, enum: [eq, neq, contains, is_empty, is_not_empty] }
        value:
          type: string
          minLength: 1
          maxLength: 500
          description: Unicode characters; case and all spaces are significant.
      allOf:
        - if: { properties: { field: { const: custom } } }
          then: { required: [customField] }
          else: { not: { required: [customField] } }
        - if: { properties: { operator: { enum: [eq, neq, contains] } } }
          then: { required: [value] }
          else: { not: { required: [value] } }
    Enrollment:
      type: object
      required: [id, journeyId, contactId, currentStep, status, enteredAt]
      properties:
        id: { type: string, format: uuid }
        journeyId: { type: string, format: uuid }
        contactId: { type: string, format: uuid }
        contactEmail: { type: string, format: email }
        currentStep: { type: string }
        status: { type: string, enum: [active, completed, exited, failed] }
        enteredAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time }
        nextRunAt: { type: string, format: date-time }
        exitReason: { type: string }
        stopReason:
          type: string
          description: Original operator-provided reason for a manual stop; absent for other exits.
        stoppedAt:
          type: string
          format: date-time
          description: Timestamp of the original manual stop; absent for other exits.
        latestEmailStatus: { type: string, enum: [sending, sent, skipped, bounced, failed, deferred, uncertain] }
        latestEmailError: { type: string }
        branchOutcomes:
          type: array
          items:
            type: object
            properties:
              stepId: { type: string }
              outcome: { type: string, enum: ['yes', 'no', 'A', 'B'], description: Recorded condition decision or weighted split assignment. }
              decidedAt: { type: string, format: date-time }
        stepHistory:
          type: array
          items:
            type: object
            required: [stepId, enteredAt, outcome]
            properties:
              stepId: { type: string }
              enteredAt: { type: string, format: date-time }
              exitedAt: { type: string, format: date-time }
              deadlineAt:
                type: string
                format: date-time
                description: Immutable deadline for a wait_until step, retained with its final outcome. Waiting rows omit exitedAt; stopped or finished rows include it.
              outcome:
                type: string
                enum: [completed, scheduled, waiting, timed_out, 'yes', 'no', 'A', 'B', sending, sent, skipped, bounced, failed, deferred, uncertain, exited]
