From f3fcf95edc0f3fcd2a4465cbe0f2a64bae2e1bc1 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 9 Nov 2026 09:12:39 +0100 Subject: [PATCH 1/6] Replace the OpenAPI 3.0 nullable keyword with 3.1 null types Decisions: - The documents declare OpenAPI 3.1, whose JSON Schema 2020-12 schemas ignore nullable, so these fields rejected null - Flow form type: [X, 'null'], the form contacts and email-sending already use - Three array items lose the keyword instead: they are never null, and a nullable item would make generators type the elements as optional - The item beside a $ref never took effect, not even under 3.0, which ignores $ref siblings Co-Authored-By: Claude Opus 5.5 --- specs/account-management.openapi.yml | 12 ++-- specs/contacts.openapi.yml | 9 +-- specs/email-sending.openapi.yml | 90 ++++++++++------------------ specs/sandbox.openapi.yml | 24 +++----- 4 files changed, 44 insertions(+), 91 deletions(-) diff --git a/specs/account-management.openapi.yml b/specs/account-management.openapi.yml index 51d2e71..8efd732 100644 --- a/specs/account-management.openapi.yml +++ b/specs/account-management.openapi.yml @@ -1632,9 +1632,8 @@ paths: type: object properties: expires_at: - type: string + type: [string, 'null'] format: date-time - nullable: true description: |- When the new token expires (ISO 8601 date-time, must be in the future). Must be no more than 5 years in the future. @@ -2533,9 +2532,8 @@ components: type: string description: Name of the user or token that created this token expires_at: - type: string + type: [string, 'null'] format: date-time - nullable: true description: When the token expires (ISO 8601); null if the token does not expire resources: type: array @@ -2569,9 +2567,8 @@ components: type: string description: Name of the user or token that created this token expires_at: - type: string + type: [string, 'null'] format: date-time - nullable: true description: When the token expires (ISO 8601); null if the token does not expire resources: type: array @@ -2626,9 +2623,8 @@ components: maxLength: 100 description: Display name for the token expires_at: - type: string + type: [string, 'null'] format: date-time - nullable: true description: |- When the token expires (ISO 8601 date-time, must be in the future). Must be no more than 5 years in the future. diff --git a/specs/contacts.openapi.yml b/specs/contacts.openapi.yml index 0aecc0a..58e4545 100644 --- a/specs/contacts.openapi.yml +++ b/specs/contacts.openapi.yml @@ -3063,8 +3063,7 @@ components: maxLength: 255 additionalProperties: oneOf: - - type: string - nullable: true + - type: [string, 'null'] - type: number - type: boolean example: @@ -3092,8 +3091,7 @@ components: maxLength: 255 additionalProperties: oneOf: - - type: string - nullable: true + - type: [string, 'null'] - type: number - type: boolean example: @@ -3123,8 +3121,7 @@ components: type: string format: date-time url: - type: string - nullable: true + type: [string, 'null'] format: uri description: The URL of the exported contacts file. Only available when the export is finished. ContactExportRequest: diff --git a/specs/email-sending.openapi.yml b/specs/email-sending.openapi.yml index a22bd01..47a8d24 100644 --- a/specs/email-sending.openapi.yml +++ b/specs/email-sending.openapi.yml @@ -4709,15 +4709,12 @@ components: type: string enum: [delivered, not_delivered, enqueued, opted_out] subject: - type: string - nullable: true + type: [string, 'null'] rfc_message_id: - type: string - nullable: true + type: [string, 'null'] description: Value of the RFC 5322 `Message-ID` header. in_reply_to: - type: string - nullable: true + type: [string, 'null'] description: Value of the RFC 5322 `In-Reply-To` header. references: type: array @@ -4725,8 +4722,7 @@ components: type: string description: Values of the RFC 5322 `References` header. thread_id: - type: string - nullable: true + type: [string, 'null'] description: | ID of the inbound thread this message belongs to, or null if the message is not part of one. Use it with the Inbound @@ -4739,11 +4735,9 @@ components: type: string format: date-time client_ip: - type: string - nullable: true + type: [string, 'null'] category: - type: string - nullable: true + type: [string, 'null'] custom_variables: type: object sending_stream: @@ -4752,8 +4746,7 @@ components: sending_domain_id: type: integer template_id: - type: integer - nullable: true + type: [integer, 'null'] template_variables: type: object opens_count: @@ -4764,9 +4757,8 @@ components: type: integer description: Total number of messages matching the filters (before pagination). next_page_cursor: - type: string + type: [string, 'null'] format: uuid - nullable: true description: Message UUID to use as search_after for the next page. Null if no more pages. example: messages: @@ -4813,15 +4805,12 @@ components: type: string enum: [delivered, not_delivered, enqueued, opted_out] subject: - type: string - nullable: true + type: [string, 'null'] rfc_message_id: - type: string - nullable: true + type: [string, 'null'] description: Value of the RFC 5322 `Message-ID` header. in_reply_to: - type: string - nullable: true + type: [string, 'null'] description: Value of the RFC 5322 `In-Reply-To` header. references: type: array @@ -4829,8 +4818,7 @@ components: type: string description: Values of the RFC 5322 `References` header. thread_id: - type: string - nullable: true + type: [string, 'null'] description: | ID of the inbound thread this message belongs to, or null if the message is not part of one. Use it with the Inbound Threads @@ -4843,11 +4831,9 @@ components: type: string format: date-time client_ip: - type: string - nullable: true + type: [string, 'null'] category: - type: string - nullable: true + type: [string, 'null'] custom_variables: type: object sending_stream: @@ -4856,8 +4842,7 @@ components: sending_domain_id: type: integer template_id: - type: integer - nullable: true + type: [integer, 'null'] template_variables: type: object opens_count: @@ -5026,14 +5011,11 @@ components: additionalProperties: false properties: sending_ip: - type: string - nullable: true + type: [string, 'null'] recipient_mx: - type: string - nullable: true + type: [string, 'null'] email_service_provider: - type: string - nullable: true + type: [string, 'null'] example: sending_ip: 192.0.2.1 recipient_mx: gmail-smtp-in.l.google.com @@ -5045,8 +5027,7 @@ components: additionalProperties: false properties: web_ip_address: - type: string - nullable: true + type: [string, 'null'] example: web_ip_address: 198.51.100.50 @@ -5056,11 +5037,9 @@ components: additionalProperties: false properties: click_url: - type: string - nullable: true + type: [string, 'null'] web_ip_address: - type: string - nullable: true + type: [string, 'null'] example: click_url: 'https://example.com/track/click/abc123' web_ip_address: 198.51.100.50 @@ -5071,23 +5050,17 @@ components: additionalProperties: false properties: sending_ip: - type: string - nullable: true + type: [string, 'null'] recipient_mx: - type: string - nullable: true + type: [string, 'null'] email_service_provider: - type: string - nullable: true + type: [string, 'null'] email_service_provider_status: - type: string - nullable: true + type: [string, 'null'] email_service_provider_response: - type: string - nullable: true + type: [string, 'null'] bounce_category: - type: string - nullable: true + type: [string, 'null'] example: sending_ip: 192.0.2.1 recipient_mx: mx.example.com @@ -5102,8 +5075,7 @@ components: additionalProperties: false properties: spam_feedback_type: - type: string - nullable: true + type: [string, 'null'] example: spam_feedback_type: abuse @@ -5113,8 +5085,7 @@ components: additionalProperties: false properties: web_ip_address: - type: string - nullable: true + type: [string, 'null'] example: web_ip_address: 198.51.100.50 @@ -5124,8 +5095,7 @@ components: additionalProperties: false properties: reject_reason: - type: string - nullable: true + type: [string, 'null'] example: reject_reason: Policy rejection diff --git a/specs/sandbox.openapi.yml b/specs/sandbox.openapi.yml index 373703f..f7777a4 100644 --- a/specs/sandbox.openapi.yml +++ b/specs/sandbox.openapi.yml @@ -3679,7 +3679,6 @@ paths: type: array items: type: object - nullable: true properties: Pts: type: number @@ -3857,7 +3856,6 @@ paths: type: array items: type: object - nullable: true properties: error_line: type: number @@ -5209,11 +5207,9 @@ paths: content_type: type: string content_id: - type: string - nullable: true + type: [string, 'null'] transfer_encoding: - type: string - nullable: true + type: [string, 'null'] attachment_size: type: integer created_at: @@ -5395,11 +5391,9 @@ paths: content_type: type: string content_id: - type: string - nullable: true + type: [string, 'null'] transfer_encoding: - type: string - nullable: true + type: [string, 'null'] attachment_size: type: integer created_at: @@ -5591,8 +5585,7 @@ components: template_id: type: integer template_variables: - type: object - nullable: true + type: [object, 'null'] additionalProperties: true html_body_size: type: integer @@ -5668,7 +5661,6 @@ components: sandboxes: type: array items: - nullable: true $ref: '#/components/schemas/TestingSandbox' permissions: type: object @@ -5724,8 +5716,7 @@ components: username: type: string password: - type: string - nullable: true + type: [string, 'null'] description: Password is only available if you have admin permissions for the sandbox. max_size: type: integer @@ -5762,8 +5753,7 @@ components: emails_unread_count: type: integer last_message_sent_at: - nullable: true - type: string + type: [string, 'null'] smtp_ports: type: array items: From 69f6c5084a676d97083399d8507b45ee16ddad5c Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 9 Nov 2026 09:12:39 +0100 Subject: [PATCH 2/6] Fail the lint on the OpenAPI 3.0 nullable keyword Decisions: - An unknown keyword is valid in a 3.1 schema, so the built-in rules let nullable through - The rule reads the unresolved documents, or a nullable beside a $ref vanishes with the resolved ref Co-Authored-By: Claude Opus 5.5 --- .spectral.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.spectral.yaml b/.spectral.yaml index 4121409..38addce 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -10,3 +10,15 @@ rules: # past `null` values in examples ("Cannot read properties of null"). duplicated-entry-in-enum: off + # OpenAPI 3.1 schemas are JSON Schema 2020-12, which has no `nullable`: a schema that + # still carries the 3.0 keyword rejects null without any warning. + no-nullable-keyword: + message: "`nullable` is ignored in OpenAPI 3.1: add 'null' to the type instead, e.g. type: [string, 'null']" + severity: error + formats: [oas3.1] + # Unresolved, so a `nullable` next to a `$ref` is reported too instead of vanishing with the resolved ref. + resolved: false + given: $..nullable + then: + function: undefined + From 692a67e5e5d0f77d7f798032cc1fd3f35d1b9c87 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 9 Nov 2026 09:59:49 +0100 Subject: [PATCH 3/6] Allow a null expires_at on account access API tokens Decisions: - The account accesses endpoints return the token's expires_at as stored, so a token that never expires comes back as null, as the other API token schemas already allow Co-Authored-By: Claude Opus 5.5 --- specs/account-management.openapi.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/account-management.openapi.yml b/specs/account-management.openapi.yml index 8efd732..3813c70 100644 --- a/specs/account-management.openapi.yml +++ b/specs/account-management.openapi.yml @@ -2401,9 +2401,9 @@ components: type: string description: The token value with all but the last characters masked expires_at: - type: string + type: [string, 'null'] format: date-time - description: Api token expiration time + description: Api token expiration time; null if the token does not expire description: Entity that has permissions to the resources type: object resources: From 783d72f29b974942ce14dcc5ed30c93f1e96c3d6 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Mon, 9 Nov 2026 09:59:49 +0100 Subject: [PATCH 4/6] Lint the nullable keyword in OpenAPI 3.2 specs too Decisions: - 3.2 schemas are JSON Schema 2020-12 as in 3.1, so a rule scoped to 3.1 alone would stop firing without notice once the specs move to 3.2 Co-Authored-By: Claude Opus 5.5 --- .spectral.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.spectral.yaml b/.spectral.yaml index 38addce..64d5cd3 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -13,12 +13,11 @@ rules: # OpenAPI 3.1 schemas are JSON Schema 2020-12, which has no `nullable`: a schema that # still carries the 3.0 keyword rejects null without any warning. no-nullable-keyword: - message: "`nullable` is ignored in OpenAPI 3.1: add 'null' to the type instead, e.g. type: [string, 'null']" + message: "`nullable` is ignored since OpenAPI 3.1: add 'null' to the type instead, e.g. type: [string, 'null']" severity: error - formats: [oas3.1] + formats: [oas3.1, oas3.2] # Unresolved, so a `nullable` next to a `$ref` is reported too instead of vanishing with the resolved ref. resolved: false given: $..nullable then: function: undefined - From 4810f9370457b87c221bce02ad37115f80c654e1 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Wed, 11 Nov 2026 10:50:57 +0100 Subject: [PATCH 5/6] Allow null template fields on Sandbox messages and Email Logs Decisions: - A Sandbox message not sent from a template has a null template_id, like its template_variables - Email Logs render custom_variables and template_variables as null when the stored message has none - The examples of messages sent without a template show null instead of 0 or an empty object Co-Authored-By: Claude Opus 5.5 --- specs/email-sending.openapi.yml | 10 +++++----- specs/sandbox.openapi.yml | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/specs/email-sending.openapi.yml b/specs/email-sending.openapi.yml index 47a8d24..4276a48 100644 --- a/specs/email-sending.openapi.yml +++ b/specs/email-sending.openapi.yml @@ -4739,7 +4739,7 @@ components: category: type: [string, 'null'] custom_variables: - type: object + type: [object, 'null'] sending_stream: type: string enum: [transactional, bulk] @@ -4748,7 +4748,7 @@ components: template_id: type: [integer, 'null'] template_variables: - type: object + type: [object, 'null'] opens_count: type: integer clicks_count: @@ -4789,7 +4789,7 @@ components: sending_stream: transactional sending_domain_id: 3938 template_id: null - template_variables: {} + template_variables: null opens_count: 0 clicks_count: 0 total_count: 150 @@ -4835,7 +4835,7 @@ components: category: type: [string, 'null'] custom_variables: - type: object + type: [object, 'null'] sending_stream: type: string enum: [transactional, bulk] @@ -4844,7 +4844,7 @@ components: template_id: type: [integer, 'null'] template_variables: - type: object + type: [object, 'null'] opens_count: type: integer clicks_count: diff --git a/specs/sandbox.openapi.yml b/specs/sandbox.openapi.yml index f7777a4..26b9316 100644 --- a/specs/sandbox.openapi.yml +++ b/specs/sandbox.openapi.yml @@ -2520,7 +2520,7 @@ paths: is_read: false created_at: '2022-07-01T19:29:59.295Z' updated_at: '2022-07-01T19:29:59.295Z' - template_id: 0 + template_id: null template_variables: null html_body_size: 150 text_body_size: 100 @@ -3235,7 +3235,7 @@ paths: is_read: false created_at: '2022-08-19T11:34:33.841Z' updated_at: '2022-08-19T11:34:33.841Z' - template_id: 0 + template_id: null template_variables: null html_body_size: 150 text_body_size: 100 @@ -5556,7 +5556,7 @@ components: type: object description: | Email message captured in a Sandbox. Body URLs are often under `/api/testing_message_parts/...` rather than nested under `/api/accounts/.../messages/...`. - `blacklists_report_info` may contain only `result` or the full report. `template_variables` is set when the message was sent from a template. + `blacklists_report_info` may contain only `result` or the full report. `template_id` and `template_variables` are set when the message was sent from a template. properties: id: type: integer @@ -5583,7 +5583,7 @@ components: updated_at: type: string template_id: - type: integer + type: [integer, 'null'] template_variables: type: [object, 'null'] additionalProperties: true From 26ba7bc4924894672eccf402f640059e6cd2f308 Mon Sep 17 00:00:00 2001 From: Maciej Walusiak Date: Wed, 11 Nov 2026 10:50:57 +0100 Subject: [PATCH 6/6] Match the nullable lint on any OpenAPI 3.x spec Decisions: - Naming oas3.2 makes Spectral releases older than its 1.9.0 formats package refuse the whole ruleset - oas3 also covers 3.2 and loads on any version; every spec here is 3.1 Co-Authored-By: Claude Opus 5.5 --- .spectral.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.spectral.yaml b/.spectral.yaml index 64d5cd3..bc702ce 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -15,7 +15,8 @@ rules: no-nullable-keyword: message: "`nullable` is ignored since OpenAPI 3.1: add 'null' to the type instead, e.g. type: [string, 'null']" severity: error - formats: [oas3.1, oas3.2] + # oas3 rather than [oas3.1, oas3.2]: spectral-formats before 1.9.0 has no oas3.2 and won't load the ruleset. + formats: [oas3] # Unresolved, so a `nullable` next to a `$ref` is reported too instead of vanishing with the resolved ref. resolved: false given: $..nullable