From d13136cfae0c8919c81bff47875a66454b29b3de Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 12 Aug 2026 23:12:39 +0200 Subject: [PATCH 1/4] Sync SDK with current OpenAPI specifications --- src/client.rs | 4 + src/endpoints.rs | 80 ++++----- src/types.rs | 407 ++++++++++++++++++++++++++++++++++++------- tests/sdk_surface.rs | 95 +++++++++- 4 files changed, 472 insertions(+), 114 deletions(-) diff --git a/src/client.rs b/src/client.rs index ee600ca..ebdca80 100644 --- a/src/client.rs +++ b/src/client.rs @@ -332,6 +332,10 @@ impl ApiClient { Ok(self.client.get_raw("/ping", &[]).await?.trim().to_string()) } + pub async fn blocked_file_types(&self) -> Result { + self.client.get("/blocked-file-types", &[]).await + } + pub fn domains(&self) -> endpoints::Domains<'_> { endpoints::Domains::new(&self.client) } diff --git a/src/endpoints.rs b/src/endpoints.rs index 5ee12e4..023c0a2 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -15,6 +15,7 @@ pub const OPERATION_IDS: &[&str] = &[ "domain.verifySpecificDnsRecord", "domain.updateProjects", "v1.ping", + "v1.blockedFileTypes", "message.index", "message.show", "message.events", @@ -27,9 +28,6 @@ pub const OPERATION_IDS: &[&str] = &[ "project.update", "project.destroy", "project.rotateToken", - "project.updateMembers", - "project.addMember", - "project.removeMember", "route.index", "route.store", "route.show", @@ -43,7 +41,10 @@ pub const OPERATION_IDS: &[&str] = &[ "team.show", "team.update", "team.usage", + "team.roles", "team.members", + "team.members.show", + "team.members.assignment.update", "webhook.index", "webhook.store", "webhook.show", @@ -240,53 +241,6 @@ impl<'a> Projects<'a> { .await } - pub async fn update_members( - &self, - project_id: &str, - payload: &B, - ) -> Result - where - B: Serialize + Sync, - { - self.client - .put( - &format!("/projects/{}/members", segment(project_id)), - payload, - ) - .await - } - - pub async fn add_member( - &self, - project_id: &str, - team_member_id: &str, - ) -> Result { - self.client - .post( - &format!( - "/projects/{}/members/{}", - segment(project_id), - segment(team_member_id) - ), - &empty_body(), - ) - .await - } - - pub async fn remove_member( - &self, - project_id: &str, - team_member_id: &str, - ) -> Result { - self.client - .delete(&format!( - "/projects/{}/members/{}", - segment(project_id), - segment(team_member_id) - )) - .await - } - pub async fn routes( &self, project_id: &str, @@ -422,9 +376,35 @@ impl<'a> Team<'a> { self.client.get("/team/usage", &[]).await } + pub async fn roles(&self) -> Result { + self.client.get("/team/roles", &[]).await + } + pub async fn members(&self, query: Query<'_>) -> Result { self.client.get("/team/members", query).await } + + pub async fn member(&self, user_id: &str) -> Result { + self.client + .get(&format!("/team/members/{}", segment(user_id)), &[]) + .await + } + + pub async fn update_member_assignment( + &self, + user_id: &str, + payload: &B, + ) -> Result + where + B: Serialize + Sync, + { + self.client + .put( + &format!("/team/members/{}/assignment", segment(user_id)), + payload, + ) + .await + } } pub struct Webhooks<'a> { diff --git a/src/types.rs b/src/types.rs index bd7dc5b..870b811 100644 --- a/src/types.rs +++ b/src/types.rs @@ -51,13 +51,6 @@ pub struct SendMailRequest { #[serde(skip_serializing_if = "Option::is_none")] pub route: Option, pub from: String, - pub subject: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tag: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub html: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub text: Option, pub to: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub cc: Option>, @@ -65,18 +58,34 @@ pub struct SendMailRequest { pub bcc: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub reply_to: Option>, + pub subject: String, #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub attachments: Option>, } pub type SendBatchMailRequest = Vec; +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum TlsPolicy { + #[serde(rename = "opportunistic")] + #[default] + Opportunistic, + #[serde(rename = "enforced")] + Enforced, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum AttachmentDelivery { #[serde(rename = "inline")] @@ -86,6 +95,58 @@ pub enum AttachmentDelivery { URL, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum BuiltInTeamRole { + #[serde(rename = "owner")] + #[default] + Owner, + #[serde(rename = "admin")] + Admin, + #[serde(rename = "member")] + Member, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CursorPaginator { + pub data: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub per_page: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub prev_page_url: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum DkimMode { + #[serde(rename = "legacy_txt")] + #[default] + LegacyTxt, + #[serde(rename = "managed_cname")] + ManagedCname, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum DnsRecordPurpose { + #[serde(rename = "return_path")] + #[default] + ReturnPath, + #[serde(rename = "dmarc")] + Dmarc, + #[serde(rename = "dkim_legacy")] + DkimLegacy, + #[serde(rename = "dkim_primary")] + DkimPrimary, + #[serde(rename = "dkim_secondary")] + DkimSecondary, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum DnsRecordStatus { #[serde(rename = "active")] @@ -97,11 +158,26 @@ pub enum DnsRecordStatus { Pending, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum DnsVerificationScope { + #[serde(rename = "required")] + #[default] + Required, + #[serde(rename = "recommended")] + Recommended, + #[serde(rename = "migration")] + Migration, + #[serde(rename = "deprecated")] + Deprecated, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DomainData { pub id: String, pub domain: String, + pub dkim_mode: DkimMode, + pub rotation_ready: bool, #[serde(skip_serializing_if = "Option::is_none")] pub status_changed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -121,6 +197,9 @@ pub struct DomainDnsRecordData { pub fqdn: String, pub content: String, pub status: DnsRecordStatus, + pub purpose: DnsRecordPurpose, + pub verification_scope: DnsVerificationScope, + pub required_for_verification: bool, #[serde(skip_serializing_if = "Option::is_none")] pub verified_at: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -133,6 +212,7 @@ pub struct DomainListData { pub id: String, pub domain: String, pub status: DomainStatus, + pub dkim_mode: DkimMode, #[serde(skip_serializing_if = "Option::is_none")] pub status_changed_at: Option, pub created_at: String, @@ -229,6 +309,8 @@ pub enum MessageEventType { Suppressed, #[serde(rename = "delivered")] Delivered, + #[serde(rename = "auto_replied")] + AutoReplied, #[serde(rename = "soft_bounced")] SoftBounced, #[serde(rename = "hard_bounced")] @@ -266,6 +348,8 @@ pub struct MessageListData { #[serde(rename = "type")] pub r#type: MessageType, pub status: MessageStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub spam_score: Option, pub from_email: String, #[serde(skip_serializing_if = "Option::is_none")] pub from_name: Option, @@ -281,6 +365,8 @@ pub struct MessageListData { pub reply_to: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status_changed_at: Option, pub created_at: String, } @@ -323,12 +409,22 @@ pub enum Plan { Pro, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum ProjectAccessScope { + #[serde(rename = "all")] + #[default] + All, + #[serde(rename = "selected")] + Selected, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ProjectData { pub id: String, pub name: String, pub smtp_enabled: bool, + pub redact_email_content: bool, #[serde(skip_serializing_if = "Option::is_none")] pub default_route_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -346,10 +442,6 @@ pub struct ProjectData { #[serde(skip_serializing_if = "Option::is_none")] pub domains_count: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub team_members: Option>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub team_members_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub last_28_days: Option>, pub created_at: String, pub updated_at: String, @@ -363,12 +455,111 @@ pub struct ProjectListData { pub smtp_enabled: bool, pub routes_count: i64, pub domains_count: i64, - pub team_members_count: i64, pub last_28_days: Box, pub created_at: String, pub updated_at: String, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum RbacConflictCode { + #[serde(rename = "stale_resource")] + #[default] + StaleResource, + #[serde(rename = "owner_protected")] + OwnerProtected, + #[serde(rename = "last_owner")] + LastOwner, + #[serde(rename = "built_in_role_immutable")] + BuiltInRoleImmutable, + #[serde(rename = "custom_role_requires_pro")] + CustomRoleRequiresPro, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub enum RbacPermission { + #[serde(rename = "team:manage")] + #[default] + TeamManage, + #[serde(rename = "billing:manage")] + BillingManage, + #[serde(rename = "security:manage")] + SecurityManage, + #[serde(rename = "audit:read")] + AuditRead, + #[serde(rename = "support:manage")] + SupportManage, + #[serde(rename = "members:read")] + MembersRead, + #[serde(rename = "members:manage")] + MembersManage, + #[serde(rename = "roles:manage")] + RolesManage, + #[serde(rename = "team_tokens:read")] + TeamTokensRead, + #[serde(rename = "team_tokens:manage")] + TeamTokensManage, + #[serde(rename = "team_tokens:rotate")] + TeamTokensRotate, + #[serde(rename = "team_tokens:revoke")] + TeamTokensRevoke, + #[serde(rename = "projects:create")] + ProjectsCreate, + #[serde(rename = "team_suppressions:read")] + TeamSuppressionsRead, + #[serde(rename = "team_suppressions:add")] + TeamSuppressionsAdd, + #[serde(rename = "team_suppressions:remove")] + TeamSuppressionsRemove, + #[serde(rename = "projects:read")] + ProjectsRead, + #[serde(rename = "projects:manage")] + ProjectsManage, + #[serde(rename = "projects:delete")] + ProjectsDelete, + #[serde(rename = "routes:read")] + RoutesRead, + #[serde(rename = "routes:manage")] + RoutesManage, + #[serde(rename = "routes:delete")] + RoutesDelete, + #[serde(rename = "domains:read")] + DomainsRead, + #[serde(rename = "domains:manage")] + DomainsManage, + #[serde(rename = "domains:delete")] + DomainsDelete, + #[serde(rename = "project_tokens:read")] + ProjectTokensRead, + #[serde(rename = "project_tokens:manage")] + ProjectTokensManage, + #[serde(rename = "project_tokens:rotate")] + ProjectTokensRotate, + #[serde(rename = "project_tokens:revoke")] + ProjectTokensRevoke, + #[serde(rename = "webhooks:read")] + WebhooksRead, + #[serde(rename = "webhooks:manage")] + WebhooksManage, + #[serde(rename = "webhooks:delete")] + WebhooksDelete, + #[serde(rename = "webhooks:rotate_secret")] + WebhooksRotateSecret, + #[serde(rename = "stats:read")] + StatsRead, + #[serde(rename = "messages:read")] + MessagesRead, + #[serde(rename = "messages:read_content")] + MessagesReadContent, + #[serde(rename = "messages:send")] + MessagesSend, + #[serde(rename = "suppressions:read")] + SuppressionsRead, + #[serde(rename = "suppressions:add")] + SuppressionsAdd, + #[serde(rename = "suppressions:remove")] + SuppressionsRemove, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum RecordType { #[serde(rename = "TXT")] @@ -400,13 +591,15 @@ pub struct RouteData { #[serde(skip_serializing_if = "Option::is_none")] pub attachment_delivery: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub project: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub webhooks_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub suppressed_recipients_count: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub statistics: Option, + pub statistics: Option>>, pub created_at: String, pub updated_at: String, } @@ -437,6 +630,12 @@ pub struct RouteStatisticData { pub spam_complaint_count: i64, pub inbound_received_count: i64, #[serde(skip_serializing_if = "Option::is_none")] + pub observed_opened_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub human_opened_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy_opened_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub effective_opened_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub machine_opened_count: Option, @@ -483,6 +682,12 @@ pub struct StatsDailyData { #[serde(skip_serializing_if = "Option::is_none")] pub broadcast: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub observed_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub human_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub effective_opened: Option, #[serde(skip_serializing_if = "Option::is_none")] pub machine_opened: Option, @@ -533,6 +738,12 @@ pub struct StatsTotalsData { #[serde(skip_serializing_if = "Option::is_none")] pub broadcast: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub observed_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub human_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub privacy_opened: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub effective_opened: Option, #[serde(skip_serializing_if = "Option::is_none")] pub machine_opened: Option, @@ -562,6 +773,8 @@ pub struct StoreProjectData { pub smtp_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub initial_routes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub short_token: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -578,14 +791,14 @@ pub struct StoreRouteData { pub struct StoreSuppressionData { #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub emails: Option>, pub reason: SuppressionReason, - pub scope: String, + pub scope: SuppressionScope, #[serde(skip_serializing_if = "Option::is_none")] pub route_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub project_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub emails: Option>, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -594,11 +807,11 @@ pub struct StoreWebhookData { pub route_id: String, pub name: String, pub url: String, + pub events: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_machine_events: Option, - pub events: Vec, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -614,6 +827,8 @@ pub struct SuppressedRecipientData { pub project_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub route_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_message: Option>, pub created_at: String, pub updated_at: String, } @@ -644,6 +859,17 @@ pub enum SuppressionScope { Route, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct SuppressionSourceMessageData { + pub id: String, + pub available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum SuppressionType { #[serde(rename = "email")] @@ -673,6 +899,7 @@ pub struct TeamData { #[serde(rename = "type")] pub r#type: TeamType, pub plan: Plan, + pub included_volume: i64, pub tier: i64, #[serde(skip_serializing_if = "Option::is_none")] pub verified_at: Option, @@ -693,14 +920,32 @@ pub struct TeamData { #[serde(default)] pub struct TeamMemberData { pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub user: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub role: Option, + pub name: String, + pub email: String, + pub role: serde_json::Value, + pub project_access: Box, #[serde(skip_serializing_if = "Option::is_none")] pub joined_at: Option, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TeamMemberProjectAccessData { + pub scope: ProjectAccessScope, + pub projects: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TeamRoleData { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_key: Option, + pub permissions: Vec, + pub assignable: bool, +} + #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum TeamType { #[serde(rename = "personal")] @@ -741,24 +986,50 @@ pub struct UpdateProjectData { #[serde(skip_serializing_if = "Option::is_none")] pub smtp_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub redact_email_content: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub default_route_id: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] -pub struct UpdateProjectMembersData { - pub team_member_ids: Vec, +pub struct UpdateRouteData { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbound_settings: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UpdateRouteInboundSettingsData { + #[serde(skip_serializing_if = "Option::is_none")] + pub inbound_domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbound_spam_threshold: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachment_delivery: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] -pub struct UpdateRouteData { +pub struct UpdateRouteSettingsData { #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, + pub track_opens: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub settings: Option, + pub track_clicks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub generate_plaintext_fallback: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_auto_responders: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub inbound_settings: Option, + pub disable_hosted_unsubscribe: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub redact_email_content: Option, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -768,6 +1039,13 @@ pub struct UpdateTeamData { pub name: Option, } +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct UpdateTeamMemberAssignmentData { + pub role_id: String, + pub project_access: serde_json::Value, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct UpdateWebhookData { @@ -776,25 +1054,13 @@ pub struct UpdateWebhookData { #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub include_machine_events: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub events: Option>, } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct UserData { - pub id: String, - pub name: String, - pub email: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub avatar: Option, -} - -pub type VolumeTier = i64; - #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct WebhookData { @@ -880,6 +1146,8 @@ pub enum WebhookEvent { MessageSent, #[serde(rename = "message.delivered")] MessageDelivered, + #[serde(rename = "message.auto_replied")] + MessageAutoReplied, #[serde(rename = "message.hard_bounced")] MessageHardBounced, #[serde(rename = "message.soft_bounced")] @@ -900,6 +1168,10 @@ pub enum WebhookEvent { MessageInbound, #[serde(rename = "message.policy_rejected")] MessagePolicyRejected, + #[serde(rename = "suppression.added")] + SuppressionAdded, + #[serde(rename = "suppression.removed")] + SuppressionRemoved, #[serde(rename = "webhook.test")] WebhookTest, } @@ -963,6 +1235,7 @@ pub struct DomainDestroyResponse { #[serde(default)] pub struct DomainVerifyDnsRecordsResponse { pub message: String, + pub recommended_failed_records: Vec, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -980,7 +1253,20 @@ pub struct DomainUpdateProjectsResponse { pub message: String, } -pub type MessageIndexResponse = serde_json::Value; +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BlockedFileTypesResponse { + pub extensions: Vec, + pub mime_types: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MessageIndexResponse { + pub data: Vec>, + pub links: Vec, + pub meta: serde_json::Value, +} pub type MessageShowResponse = MessageData; @@ -1053,27 +1339,6 @@ pub struct ProjectRotateTokenResponse { pub message: String, } -pub type ProjectUpdateMembersRequest = UpdateProjectMembersData; - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ProjectUpdateMembersResponse { - pub data: Box, - pub message: String, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ProjectAddMemberResponse { - pub message: String, -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ProjectRemoveMemberResponse { - pub message: String, -} - #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct RouteIndexResponse { @@ -1154,7 +1419,11 @@ pub struct SuppressionStoreResponse { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct SuppressionDestroyResponse { + pub success: bool, + pub status: String, pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub confidence: Option, } pub type TeamShowResponse = TeamData; @@ -1170,6 +1439,12 @@ pub struct TeamUpdateResponse { pub type TeamUsageResponse = TeamUsageDetailData; +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TeamRolesResponse { + pub data: Vec>, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct TeamMembersResponse { @@ -1187,6 +1462,12 @@ pub struct TeamMembersResponse { pub prev_page_url: Option, } +pub type TeamMembersShowResponse = TeamMemberData; + +pub type TeamMembersAssignmentUpdateRequest = UpdateTeamMemberAssignmentData; + +pub type TeamMembersAssignmentUpdateResponse = TeamMemberData; + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct WebhookIndexResponse { diff --git a/tests/sdk_surface.rs b/tests/sdk_surface.rs index 59fe2d2..4034caa 100644 --- a/tests/sdk_surface.rs +++ b/tests/sdk_surface.rs @@ -78,6 +78,21 @@ async fn api_entrypoint_uses_bearer_auth_and_raw_ping() { assert!(!request.headers.contains_key("x-lettermint-token")); } +#[tokio::test] +async fn api_lists_blocked_file_types() { + let transport = MockTransport::new(vec![json_response( + serde_json::json!({"extensions": ["exe"], "mime_types": ["application/x-msdownload"]}), + )]); + let api = Lettermint::api_with_transport("api-token", transport.clone()).unwrap(); + + let response = api.blocked_file_types().await.unwrap(); + + assert_eq!(response.extensions, vec!["exe"]); + let request = transport.requests().pop().unwrap(); + assert_eq!(request.url, "https://api.lettermint.co/v1/blocked-file-types"); + assert_eq!(request.method, "GET"); +} + #[tokio::test] async fn fluent_email_builder_sends_and_resets_attachment_payload() { let transport = MockTransport::new(vec![ @@ -207,10 +222,88 @@ async fn message_raw_body_endpoints_return_plain_text() { ); } +#[tokio::test] +async fn team_role_and_member_assignment_endpoints_map_requests() { + let transport = MockTransport::new(vec![ + json_response(serde_json::json!({"data": []})), + json_response(serde_json::json!({"id": "user/id"})), + json_response(serde_json::json!({"id": "user/id"})), + ]); + let api = Lettermint::api_with_transport("api-token", transport.clone()).unwrap(); + + assert!(api.team().roles().await.unwrap().data.is_empty()); + assert_eq!(api.team().member("user/id").await.unwrap().id, "user/id"); + assert_eq!( + api.team() + .update_member_assignment( + "user/id", + &types::UpdateTeamMemberAssignmentData { + role_id: "role_123".into(), + project_access: serde_json::json!({"scope": "all"}), + }, + ) + .await + .unwrap() + .id, + "user/id" + ); + + let requests = transport.requests(); + assert_eq!(requests[0].url, "https://api.lettermint.co/v1/team/roles"); + assert_eq!(requests[1].url, "https://api.lettermint.co/v1/team/members/user%2Fid"); + assert_eq!(requests[2].method, "PUT"); + assert_eq!( + requests[2].url, + "https://api.lettermint.co/v1/team/members/user%2Fid/assignment" + ); + assert!(requests[2] + .body + .as_deref() + .unwrap() + .contains("\"role_id\":\"role_123\"")); +} + +#[test] +fn generated_types_match_current_specs() { + let settings = types::UpdateRouteSettingsData { + tls: Some(types::TlsPolicy::Enforced), + generate_plaintext_fallback: Some(true), + ..Default::default() + }; + let domain = types::DomainData { + dkim_mode: types::DkimMode::ManagedCname, + rotation_ready: true, + ..Default::default() + }; + let recipient = types::SuppressedRecipientData { + source_message: Some(Box::new(types::SuppressionSourceMessageData { + id: "msg_123".into(), + available: true, + ..Default::default() + })), + ..Default::default() + }; + let message = types::MessageListData { + spam_score: Some(2.5), + ..Default::default() + }; + + assert_eq!(settings.tls, Some(types::TlsPolicy::Enforced)); + assert_eq!(domain.dkim_mode, types::DkimMode::ManagedCname); + assert_eq!(recipient.source_message.unwrap().id, "msg_123"); + assert_eq!(message.spam_score, Some(2.5)); +} + #[test] fn documented_operations_are_exposed() { - assert_eq!(lettermint::endpoints::OPERATION_IDS.len(), 49); + assert_eq!(lettermint::endpoints::OPERATION_IDS.len(), 50); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"v1.sendMail")); + assert!(lettermint::endpoints::OPERATION_IDS.contains(&"v1.blockedFileTypes")); + assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.roles")); + assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.members.show")); + assert!( + lettermint::endpoints::OPERATION_IDS.contains(&"team.members.assignment.update") + ); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"webhook.showDelivery")); } From 7dc9768fb814c432442fb8a81f9230eea599a1a4 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Thu, 13 Aug 2026 11:39:42 +0200 Subject: [PATCH 2/4] Complete email settings, attachments, and batch idempotency --- src/email.rs | 47 ++++++++++++++++++++++++++++++--- src/lib.rs | 2 +- src/webhook.rs | 2 +- tests/sdk_surface.rs | 62 ++++++++++++++++++++++++++++++++++---------- tests/webhook.rs | 2 +- 5 files changed, 95 insertions(+), 20 deletions(-) diff --git a/src/email.rs b/src/email.rs index 7d53ad5..488f36e 100644 --- a/src/email.rs +++ b/src/email.rs @@ -1,8 +1,21 @@ use crate::client::EmailClient; use crate::error::Result; -use crate::types::{EmailAttachment, SendBatchMailResponse, SendMailRequest, SendMailResponse}; +use crate::types::{ + EmailAttachment, SendBatchMailResponse, SendMailRequest, SendMailResponse, TlsPolicy, +}; +use serde::Serialize; use std::collections::BTreeMap; +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +pub struct EmailSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub track_opens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_clicks: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, +} + impl EmailClient { pub fn email(&self) -> EmailBuilder<'_> { EmailBuilder::new(self) @@ -15,6 +28,17 @@ impl EmailClient { pub async fn send_batch(&self, payload: &[SendMailRequest]) -> Result { self.client.post("/send/batch", payload).await } + + pub async fn send_batch_with_idempotency_key( + &self, + payload: &[SendMailRequest], + key: impl Into, + ) -> Result { + let headers = BTreeMap::from([("idempotency-key".into(), key.into())]); + self.client + .post_with_headers("/send/batch", payload, Some(headers)) + .await + } } pub struct EmailBuilder<'a> { @@ -107,12 +131,22 @@ impl<'a> EmailBuilder<'a> { self } - pub fn attach(mut self, filename: impl Into, content: impl Into) -> Self { + pub fn attach(self, filename: impl Into, content: impl Into) -> Self { + self.attach_with_options(filename, content, None, None) + } + + pub fn attach_with_options( + mut self, + filename: impl Into, + content: impl Into, + content_id: Option, + content_type: Option, + ) -> Self { let attachment = EmailAttachment { filename: filename.into(), content: content.into(), - content_type: None, - content_id: None, + content_type, + content_id, }; self.payload .attachments @@ -121,6 +155,11 @@ impl<'a> EmailBuilder<'a> { self } + pub fn settings(mut self, settings: EmailSettings) -> Self { + self.payload.settings = Some(serde_json::to_value(settings).expect("settings serialize")); + self + } + pub fn idempotency_key(mut self, key: impl Into) -> Self { self.idempotency_key = Some(key.into()); self diff --git a/src/lib.rs b/src/lib.rs index a452cdc..53cc80b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,6 @@ pub mod types; pub mod webhook; pub use client::{ApiClient, EmailClient, Lettermint}; -pub use email::EmailBuilder; +pub use email::{EmailBuilder, EmailSettings}; pub use error::{Error, Result}; pub use webhook::Webhook; diff --git a/src/webhook.rs b/src/webhook.rs index 47c03fe..134bd48 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -1,5 +1,5 @@ use crate::error::{Error, Result}; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/tests/sdk_surface.rs b/tests/sdk_surface.rs index 4034caa..a2d7bca 100644 --- a/tests/sdk_surface.rs +++ b/tests/sdk_surface.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; use lettermint::client::{AuthMode, HttpRequest, HttpResponse, Transport}; -use lettermint::{Lettermint, types}; +use lettermint::{EmailSettings, Lettermint, types}; use std::sync::{Arc, Mutex}; #[derive(Clone)] @@ -89,7 +89,10 @@ async fn api_lists_blocked_file_types() { assert_eq!(response.extensions, vec!["exe"]); let request = transport.requests().pop().unwrap(); - assert_eq!(request.url, "https://api.lettermint.co/v1/blocked-file-types"); + assert_eq!( + request.url, + "https://api.lettermint.co/v1/blocked-file-types" + ); assert_eq!(request.method, "GET"); } @@ -107,7 +110,19 @@ async fn fluent_email_builder_sends_and_resets_attachment_payload() { .to("first@example.com") .subject("First") .html("

Hello

") - .attach("invoice.pdf", "base64-pdf") + .header("Message-ID", "") + .header("X-LM-Preserve-Message-ID", "true") + .attach_with_options( + "invoice.pdf", + "base64-pdf", + Some("invoice".into()), + Some("application/pdf".into()), + ) + .settings(EmailSettings { + track_opens: Some(false), + track_clicks: Some(true), + tls: Some(types::TlsPolicy::Enforced), + }) .idempotency_key("idem-1") .send() .await @@ -131,6 +146,13 @@ async fn fluent_email_builder_sends_and_resets_attachment_payload() { serde_json::from_str(requests[1].body.as_ref().unwrap()).unwrap(); assert_eq!(first_body["attachments"][0]["filename"], "invoice.pdf"); + assert_eq!(first_body["attachments"][0]["content_id"], "invoice"); + assert_eq!( + first_body["attachments"][0]["content_type"], + "application/pdf" + ); + assert_eq!(first_body["settings"]["tls"], "enforced"); + assert_eq!(first_body["headers"]["X-LM-Preserve-Message-ID"], "true"); assert!(second_body.get("attachments").is_none()); assert_eq!( requests[0].headers.get("idempotency-key").unwrap(), @@ -156,13 +178,24 @@ async fn direct_and_batch_send_support_typed_responses() { assert_eq!(email.send(&payload).await.unwrap().message_id, "msg_1"); assert_eq!( - email.send_batch(&[payload]).await.unwrap()[0].message_id, + email + .send_batch_with_idempotency_key(&[payload], "batch-key") + .await + .unwrap()[0] + .message_id, "msg_2" ); assert_eq!( transport.requests()[1].url, "https://api.lettermint.co/v1/send/batch" ); + assert_eq!( + transport.requests()[1] + .headers + .get("idempotency-key") + .unwrap(), + "batch-key" + ); } #[tokio::test] @@ -250,17 +283,22 @@ async fn team_role_and_member_assignment_endpoints_map_requests() { let requests = transport.requests(); assert_eq!(requests[0].url, "https://api.lettermint.co/v1/team/roles"); - assert_eq!(requests[1].url, "https://api.lettermint.co/v1/team/members/user%2Fid"); + assert_eq!( + requests[1].url, + "https://api.lettermint.co/v1/team/members/user%2Fid" + ); assert_eq!(requests[2].method, "PUT"); assert_eq!( requests[2].url, "https://api.lettermint.co/v1/team/members/user%2Fid/assignment" ); - assert!(requests[2] - .body - .as_deref() - .unwrap() - .contains("\"role_id\":\"role_123\"")); + assert!( + requests[2] + .body + .as_deref() + .unwrap() + .contains("\"role_id\":\"role_123\"") + ); } #[test] @@ -301,9 +339,7 @@ fn documented_operations_are_exposed() { assert!(lettermint::endpoints::OPERATION_IDS.contains(&"v1.blockedFileTypes")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.roles")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.members.show")); - assert!( - lettermint::endpoints::OPERATION_IDS.contains(&"team.members.assignment.update") - ); + assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.members.assignment.update")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"webhook.showDelivery")); } diff --git a/tests/webhook.rs b/tests/webhook.rs index 935f769..9d9e8a8 100644 --- a/tests/webhook.rs +++ b/tests/webhook.rs @@ -1,4 +1,4 @@ -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use lettermint::{Error, Webhook}; use sha2::Sha256; use std::time::{SystemTime, UNIX_EPOCH}; From 19e3a153a96f7b8c43e961431f35ee5808a93204 Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 19 Aug 2026 22:26:53 +0200 Subject: [PATCH 3/4] feat(api): support message tags and route suppression --- src/email.rs | 5 +++++ src/types.rs | 22 +++++++++++----------- tests/sdk_surface.rs | 4 ++++ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/email.rs b/src/email.rs index 488f36e..73ddf9f 100644 --- a/src/email.rs +++ b/src/email.rs @@ -131,6 +131,11 @@ impl<'a> EmailBuilder<'a> { self } + pub fn tags(mut self, tags: Vec) -> Self { + self.payload.tags = Some(tags); + self + } + pub fn attach(self, filename: impl Into, content: impl Into) -> Self { self.attach_with_options(filename, content, None, None) } diff --git a/src/types.rs b/src/types.rs index 870b811..c481f14 100644 --- a/src/types.rs +++ b/src/types.rs @@ -66,6 +66,8 @@ pub struct SendMailRequest { #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub settings: Option, #[serde(skip_serializing_if = "Option::is_none")] pub html: Option, @@ -263,6 +265,7 @@ pub struct MessageData { pub status_changed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + pub tags: Vec, pub from_email: String, #[serde(skip_serializing_if = "Option::is_none")] pub from_name: Option, @@ -294,6 +297,9 @@ pub struct MessageEventData { pub message_id: String, pub event: MessageEventType, #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + pub tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, pub timestamp: String, } @@ -365,6 +371,7 @@ pub struct MessageListData { pub reply_to: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, + pub tags: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub status_changed_at: Option, pub created_at: String, @@ -1025,6 +1032,8 @@ pub struct UpdateRouteSettingsData { #[serde(skip_serializing_if = "Option::is_none")] pub suppress_auto_responders: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_disposable_recipients: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tls: Option, #[serde(skip_serializing_if = "Option::is_none")] pub disable_hosted_unsubscribe: Option, @@ -1274,17 +1283,8 @@ pub type MessageShowResponse = MessageData; #[serde(default)] pub struct MessageEventsResponse { pub data: Vec>, - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - pub per_page: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_page_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_cursor: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_page_url: Option, + pub links: Vec, + pub meta: serde_json::Value, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/tests/sdk_surface.rs b/tests/sdk_surface.rs index a2d7bca..fd5824f 100644 --- a/tests/sdk_surface.rs +++ b/tests/sdk_surface.rs @@ -112,6 +112,9 @@ async fn fluent_email_builder_sends_and_resets_attachment_payload() { .html("

Hello

") .header("Message-ID", "") .header("X-LM-Preserve-Message-ID", "true") + .tags(vec![ + serde_json::json!({"name": "campaign", "value": "welcome-v2"}), + ]) .attach_with_options( "invoice.pdf", "base64-pdf", @@ -153,6 +156,7 @@ async fn fluent_email_builder_sends_and_resets_attachment_payload() { ); assert_eq!(first_body["settings"]["tls"], "enforced"); assert_eq!(first_body["headers"]["X-LM-Preserve-Message-ID"], "true"); + assert_eq!(first_body["tags"][0]["name"], "campaign"); assert!(second_body.get("attachments").is_none()); assert_eq!( requests[0].headers.get("idempotency-key").unwrap(), From f1dcee36a298b6781356c8257a707b396597b92c Mon Sep 17 00:00:00 2001 From: Bjarn Bronsveld Date: Wed, 26 Aug 2026 23:59:49 +0200 Subject: [PATCH 4/4] feat(api): support scheduled message delivery --- src/client.rs | 8 +++++++ src/email.rs | 5 +++++ src/endpoints.rs | 21 +++++++++++++++++++ src/types.rs | 36 +++++++++++++++++++++++++++++++ tests/sdk_surface.rs | 50 +++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index ebdca80..be019c6 100644 --- a/src/client.rs +++ b/src/client.rs @@ -161,6 +161,14 @@ impl HttpClient { self.request("PUT", path, &[], Some(body), None).await } + pub async fn patch(&self, path: &str, body: &B) -> Result + where + T: DeserializeOwned, + B: Serialize + Sync + ?Sized, + { + self.request("PATCH", path, &[], Some(body), None).await + } + pub async fn delete(&self, path: &str) -> Result where T: DeserializeOwned, diff --git a/src/email.rs b/src/email.rs index 73ddf9f..c3f2fa5 100644 --- a/src/email.rs +++ b/src/email.rs @@ -95,6 +95,11 @@ impl<'a> EmailBuilder<'a> { self } + pub fn scheduled_at(mut self, scheduled_at: impl Into) -> Self { + self.payload.scheduled_at = Some(scheduled_at.into()); + self + } + pub fn html(mut self, html: impl Into) -> Self { self.payload.html = Some(html.into()); self diff --git a/src/endpoints.rs b/src/endpoints.rs index 023c0a2..d83d773 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -18,6 +18,8 @@ pub const OPERATION_IDS: &[&str] = &[ "v1.blockedFileTypes", "message.index", "message.show", + "rescheduleMessage", + "cancelScheduledMessage", "message.events", "message.source", "message.html", @@ -155,6 +157,25 @@ impl<'a> Messages<'a> { .await } + pub async fn reschedule( + &self, + message_id: &str, + payload: &types::RescheduleMessageRequest, + ) -> Result { + self.client + .patch(&format!("/messages/{}", segment(message_id)), payload) + .await + } + + pub async fn cancel(&self, message_id: &str) -> Result { + self.client + .post( + &format!("/messages/{}/cancel", segment(message_id)), + &serde_json::json!({}), + ) + .await + } + pub async fn events( &self, message_id: &str, diff --git a/src/types.rs b/src/types.rs index c481f14..24e4b7f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -14,6 +14,8 @@ pub struct EmailAttachment { #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum MessageStatus { + #[serde(rename = "scheduled")] + Scheduled, #[serde(rename = "pending")] #[default] Pending, @@ -43,6 +45,8 @@ pub enum MessageStatus { PolicyRejected, #[serde(rename = "unsubscribed")] Unsubscribed, + #[serde(rename = "canceled")] + Canceled, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -60,6 +64,8 @@ pub struct SendMailRequest { pub reply_to: Option>, pub subject: String, #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option>, @@ -264,6 +270,8 @@ pub struct MessageData { #[serde(skip_serializing_if = "Option::is_none")] pub status_changed_at: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub tag: Option, pub tags: Vec, pub from_email: String, @@ -306,6 +314,14 @@ pub struct MessageEventData { #[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] pub enum MessageEventType { + #[serde(rename = "scheduled")] + Scheduled, + #[serde(rename = "rescheduled")] + Rescheduled, + #[serde(rename = "canceled")] + Canceled, + #[serde(rename = "released")] + Released, #[serde(rename = "queued")] #[default] Queued, @@ -355,6 +371,8 @@ pub struct MessageListData { pub r#type: MessageType, pub status: MessageStatus, #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub spam_score: Option, pub from_email: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -1205,6 +1223,24 @@ pub struct WebhookListData { pub struct SendMailResponse { pub message_id: String, pub status: MessageStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_at: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RescheduleMessageRequest { + pub scheduled_at: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RescheduleMessageResponse { + pub message_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scheduled_at: Option, } pub type SendBatchMailResponse = Vec; diff --git a/tests/sdk_surface.rs b/tests/sdk_surface.rs index fd5824f..3b2ae14 100644 --- a/tests/sdk_surface.rs +++ b/tests/sdk_surface.rs @@ -259,6 +259,48 @@ async fn message_raw_body_endpoints_return_plain_text() { ); } +#[tokio::test] +async fn scheduled_message_endpoints_map_requests() { + let transport = MockTransport::new(vec![ + json_response( + serde_json::json!({"message_id": "message/id", "status": "scheduled", "scheduled_at": "2026-08-27T09:00:00Z"}), + ), + json_response( + serde_json::json!({"message_id": "message/id", "status": "canceled", "scheduled_at": null}), + ), + ]); + let api = Lettermint::api_with_transport("api-token", transport.clone()).unwrap(); + + assert_eq!( + api.messages() + .reschedule( + "message/id", + &types::RescheduleMessageRequest { + scheduled_at: "2026-08-27T09:00:00Z".into() + } + ) + .await + .unwrap() + .status, + Some(types::MessageStatus::Scheduled) + ); + assert_eq!( + api.messages().cancel("message/id").await.unwrap().status, + Some(types::MessageStatus::Canceled) + ); + let requests = transport.requests(); + assert_eq!(requests[0].method, "PATCH"); + assert_eq!( + requests[0].url, + "https://api.lettermint.co/v1/messages/message%2Fid" + ); + assert_eq!(requests[1].method, "POST"); + assert_eq!( + requests[1].url, + "https://api.lettermint.co/v1/messages/message%2Fid/cancel" + ); +} + #[tokio::test] async fn team_role_and_member_assignment_endpoints_map_requests() { let transport = MockTransport::new(vec![ @@ -327,6 +369,7 @@ fn generated_types_match_current_specs() { }; let message = types::MessageListData { spam_score: Some(2.5), + scheduled_at: Some("2026-08-27T09:00:00Z".into()), ..Default::default() }; @@ -334,16 +377,21 @@ fn generated_types_match_current_specs() { assert_eq!(domain.dkim_mode, types::DkimMode::ManagedCname); assert_eq!(recipient.source_message.unwrap().id, "msg_123"); assert_eq!(message.spam_score, Some(2.5)); + assert_eq!( + message.scheduled_at.as_deref(), + Some("2026-08-27T09:00:00Z") + ); } #[test] fn documented_operations_are_exposed() { - assert_eq!(lettermint::endpoints::OPERATION_IDS.len(), 50); + assert_eq!(lettermint::endpoints::OPERATION_IDS.len(), 52); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"v1.sendMail")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"v1.blockedFileTypes")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.roles")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.members.show")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"team.members.assignment.update")); + assert!(lettermint::endpoints::OPERATION_IDS.contains(&"rescheduleMessage")); assert!(lettermint::endpoints::OPERATION_IDS.contains(&"webhook.showDelivery")); }