import Foundation // Shared Codable models mirroring the Go json tags exactly. // Convention: NO convertFromSnakeCase; every model declares explicit CodingKeys. // MARK: - Tokens struct AuthToken: Codable, Sendable { var accessToken: String var accessTokenExpiresAt: Date var refreshToken: String var refreshTokenExpiresAt: Date enum CodingKeys: String, CodingKey { case accessToken = "access_token" case accessTokenExpiresAt = "access_token_expires_at" case refreshToken = "refresh_token" case refreshTokenExpiresAt = "refresh_token_expires_at" } } /// `POST /auth/login/confirm` result: either the token pair or a 2FA challenge. struct LoginResult: Codable, Sendable { var accessToken: String? var accessTokenExpiresAt: Date? var refreshToken: String? var refreshTokenExpiresAt: Date? var twoFARequired: Bool? var pendingToken: String? var expiresIn: Int? enum CodingKeys: String, CodingKey { case accessToken = "access_token" case accessTokenExpiresAt = "access_token_expires_at" case refreshToken = "refresh_token" case refreshTokenExpiresAt = "refresh_token_expires_at" case twoFARequired = "two_fa_required" case pendingToken = "pending_token" case expiresIn = "expires_in" } var token: AuthToken? { guard let accessToken, let accessTokenExpiresAt, let refreshToken, let refreshTokenExpiresAt else { return nil } return AuthToken( accessToken: accessToken, accessTokenExpiresAt: accessTokenExpiresAt, refreshToken: refreshToken, refreshTokenExpiresAt: refreshTokenExpiresAt ) } } /// `POST /auth/login` and `/auth/register`. /// /// `session` plus `codeRequired == true` is the two-step flow: a code was /// emailed and the caller confirms it. When the deployment has the login code /// off, or email verification off, nothing is sent and the remaining fields /// carry the outcome instead, so `session` is optional. struct AuthSession: Codable, Sendable { var session: String? var codeRequired: Bool? var token: AuthToken? var twoFARequired: Bool? var pendingToken: String? var expiresIn: Int? enum CodingKeys: String, CodingKey { case session case codeRequired = "code_required" case token case twoFARequired = "two_fa_required" case pendingToken = "pending_token" case expiresIn = "expires_in" } /// Older backends omit the field entirely; they always sent a code. var needsCode: Bool { codeRequired ?? true } } /// `GET /auth/providers`: which native social sign-in options this host /// supports. One shipped binary adapts to hosted and self-hosted backends. struct AuthProvidersInfo: Codable, Sendable { struct Provider: Codable, Sendable { var enabled: Bool var clientID: String? enum CodingKeys: String, CodingKey { case enabled case clientID = "client_id" } } var apple: Provider? var google: Provider? var appleEnabled: Bool { apple?.enabled == true } var googleClientID: String? { guard google?.enabled == true, let id = google?.clientID, !id.isEmpty else { return nil } return id } } // MARK: - Errors struct APIErrorPayload: Codable, Sendable { var error: String? var message: String? var code: String? var requestId: String? var retryAfterMs: Int? enum CodingKeys: String, CodingKey { case error, message, code case requestId = "request_id" case retryAfterMs = "retry_after_ms" } } enum APIError: Error, LocalizedError { case notConfigured case unauthorized(APIErrorPayload?) case server(status: Int, payload: APIErrorPayload?) case rateLimited(retryAfterMs: Int?) case network(Error) case decoding(Error) var errorDescription: String? { switch self { case .notConfigured: return "Server address is not configured." case let .unauthorized(payload): return payload?.message ?? "Your session has expired. Please sign in again." case let .server(status, payload): return payload?.message ?? "The server returned an error (\(status))." case .rateLimited: return "Too many requests. Please wait a moment and try again." case .network: return "Couldn't reach the server. Check your connection." case .decoding: return "The server response couldn't be read." } } var code: String? { switch self { case let .unauthorized(payload): return payload?.code case let .server(_, payload): return payload?.code case .rateLimited: return "rate_limit_exceeded" default: return nil } } } // MARK: - Pagination struct Pagination: Codable, Sendable { var total: Int64? var nextCursor: String? var hasMore: Bool? enum CodingKeys: String, CodingKey { case total case nextCursor = "next_cursor" case hasMore = "has_more" } } struct ListResponse: Codable, Sendable { var data: [T] var pagination: Pagination? } struct MessageResponse: Codable, Sendable { var message: String? } struct EmptyBody: Codable, Sendable {} // MARK: - Permissions /// `uint16` bitmask serialized as a JSON number; owner = 65535. struct OrgPermissions: OptionSet, Sendable, Codable { let rawValue: UInt16 static let manageTeam = OrgPermissions(rawValue: 1) static let manageBilling = OrgPermissions(rawValue: 2) static let manageCampaigns = OrgPermissions(rawValue: 4) static let manageContacts = OrgPermissions(rawValue: 8) static let manageEmails = OrgPermissions(rawValue: 16) static let viewAnalytics = OrgPermissions(rawValue: 32) static let sendCampaigns = OrgPermissions(rawValue: 64) static let accessUnibox = OrgPermissions(rawValue: 128) static let manageSequences = OrgPermissions(rawValue: 256) static let manageSettings = OrgPermissions(rawValue: 512) static let viewCampaigns = OrgPermissions(rawValue: 1024) static let viewContacts = OrgPermissions(rawValue: 2048) static let transferOwnership = OrgPermissions(rawValue: 4096) static let manageAPIKeys = OrgPermissions(rawValue: 8192) static let useIntegrations = OrgPermissions(rawValue: 16384) static let useAI = OrgPermissions(rawValue: 32768) static let owner = OrgPermissions(rawValue: .max) } // MARK: - User /// Per-user label group (folders/tags/categories hydrated on GET /auth/me). /// Go `models.Group` puts the display name on the wire as `title`. struct UserGroup: Codable, Identifiable, Sendable, Hashable { var id: String var name: String? var color: String? var position: Int? enum CodingKeys: String, CodingKey { case id, color, position case name = "title" } } struct User: Codable, Identifiable, Sendable { var id: String var firstName: String? var lastName: String? var email: String var avatarURL: String? var onboardingCompletedAt: Date? var maxOrganizations: Int? var freeTrialUsed: Bool? var isAdmin: Bool? var deletionScheduledFor: Date? /// Undo window for instant sends; stale caches may omit it, default 30. var undoSendSeconds: Int var tags: [UserGroup]? var categories: [UserGroup]? var folders: [UserGroup]? enum CodingKeys: String, CodingKey { case id, email, tags, categories, folders case firstName = "first_name" case lastName = "last_name" case avatarURL = "avatar_url" case onboardingCompletedAt = "onboarding_completed_at" case maxOrganizations = "max_organizations" case freeTrialUsed = "free_trial_used" case isAdmin = "is_admin" case deletionScheduledFor = "deletion_scheduled_for" case undoSendSeconds = "undo_send_seconds" } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) email = try container.decode(String.self, forKey: .email) firstName = try container.decodeIfPresent(String.self, forKey: .firstName) lastName = try container.decodeIfPresent(String.self, forKey: .lastName) avatarURL = try container.decodeIfPresent(String.self, forKey: .avatarURL) onboardingCompletedAt = try container.decodeIfPresent(Date.self, forKey: .onboardingCompletedAt) maxOrganizations = try container.decodeIfPresent(Int.self, forKey: .maxOrganizations) freeTrialUsed = try container.decodeIfPresent(Bool.self, forKey: .freeTrialUsed) isAdmin = try container.decodeIfPresent(Bool.self, forKey: .isAdmin) deletionScheduledFor = try container.decodeIfPresent(Date.self, forKey: .deletionScheduledFor) undoSendSeconds = try container.decodeIfPresent(Int.self, forKey: .undoSendSeconds) ?? 30 tags = try container.decodeIfPresent([UserGroup].self, forKey: .tags) categories = try container.decodeIfPresent([UserGroup].self, forKey: .categories) folders = try container.decodeIfPresent([UserGroup].self, forKey: .folders) } var displayName: String { let name = [firstName, lastName].compactMap { $0 }.joined(separator: " ") return name.isEmpty ? email : name } var initials: String { let first = firstName?.first.map(String.init) ?? "" let last = lastName?.first.map(String.init) ?? "" let combined = first + last return combined.isEmpty ? String(email.prefix(1)).uppercased() : combined.uppercased() } } // MARK: - Organization struct Organization: Codable, Identifiable, Sendable, Hashable { var id: String var name: String var slug: String? var avatarURL: String? var ownerUserID: String? var createdAt: Date? var deletionScheduledFor: Date? enum CodingKeys: String, CodingKey { case id, name, slug case avatarURL = "avatar_url" case ownerUserID = "owner_user_id" case createdAt = "created_at" case deletionScheduledFor = "deletion_scheduled_for" } } struct OrganizationMember: Codable, Identifiable, Sendable { var id: String var organizationID: String var userID: String var role: String? var permissions: UInt16? var invitedAt: Date? var acceptedAt: Date? var organization: Organization? enum CodingKeys: String, CodingKey { case id, role, permissions, organization case organizationID = "organization_id" case userID = "user_id" case invitedAt = "invited_at" case acceptedAt = "accepted_at" } var orgPermissions: OrgPermissions { OrgPermissions(rawValue: permissions ?? 0) } } struct OrgLimits: Codable, Sendable { var maxCampaigns: Int? var maxActiveCampaigns: Int? var maxTeamMembers: Int? var maxEmailAccounts: Int? var maxContacts: Int? var dailyCampaignLimit: Int? enum CodingKeys: String, CodingKey { case maxCampaigns = "max_campaigns" case maxActiveCampaigns = "max_active_campaigns" case maxTeamMembers = "max_team_members" case maxEmailAccounts = "max_email_accounts" case maxContacts = "max_contacts" case dailyCampaignLimit = "daily_campaign_limit" } } struct OrgCounts: Codable, Sendable { var totalCampaigns: Int? var activeCampaigns: Int? var totalContacts: Int? var totalMembers: Int? var emailAccounts: Int? enum CodingKeys: String, CodingKey { case totalCampaigns = "total_campaigns" case activeCampaigns = "active_campaigns" case totalContacts = "total_contacts" case totalMembers = "total_members" case emailAccounts = "email_accounts" } } struct OrganizationCurrent: Codable, Sendable { var id: String var name: String var slug: String? var avatarURL: String? var ownerUserID: String? var presenceShowOnline: Bool? var presenceShowActivity: Bool? var limits: OrgLimits? var counts: OrgCounts? enum CodingKeys: String, CodingKey { case id, name, slug, limits, counts case avatarURL = "avatar_url" case ownerUserID = "owner_user_id" case presenceShowOnline = "presence_show_online" case presenceShowActivity = "presence_show_activity" } } struct SwitchOrgResponse: Codable, Sendable { var message: String? var organizationID: String? enum CodingKeys: String, CodingKey { case message case organizationID = "organization_id" } } // MARK: - Realtime bootstrap struct GetSocket: Codable, Sendable { var url: String /// Seconds; Go float64. var expiresIn: Double? enum CodingKeys: String, CodingKey { case url case expiresIn = "expires_in" } }