diff --git a/apps/docs/api-reference/domains/delete-domain.mdx b/apps/docs/api-reference/domains/delete-domain.mdx new file mode 100644 index 0000000..9f1b0de --- /dev/null +++ b/apps/docs/api-reference/domains/delete-domain.mdx @@ -0,0 +1,3 @@ +--- +openapi: delete /v1/domains/{id} +--- diff --git a/apps/docs/api-reference/openapi.json b/apps/docs/api-reference/openapi.json index 521b885..de775bf 100644 --- a/apps/docs/api-reference/openapi.json +++ b/apps/docs/api-reference/openapi.json @@ -620,6 +620,181 @@ } } } + }, + "delete": { + "parameters": [ + { + "schema": { + "type": "number", + "nullable": false, + "example": 1 + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Domain deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "number", + "description": "The ID of the domain", + "example": 1 + }, + "name": { + "type": "string", + "description": "The name of the domain", + "example": "example.com" + }, + "teamId": { + "type": "number", + "description": "The ID of the team", + "example": 1 + }, + "status": { + "type": "string", + "enum": [ + "NOT_STARTED", + "PENDING", + "SUCCESS", + "FAILED", + "TEMPORARY_FAILURE" + ] + }, + "region": { + "type": "string", + "default": "us-east-1" + }, + "clickTracking": { + "type": "boolean", + "default": false + }, + "openTracking": { + "type": "boolean", + "default": false + }, + "publicKey": { + "type": "string" + }, + "dkimStatus": { + "type": "string", + "nullable": true + }, + "spfDetails": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "dmarcAdded": { + "type": "boolean", + "default": false + }, + "isVerifying": { + "type": "boolean", + "default": false + }, + "errorMessage": { + "type": "string", + "nullable": true + }, + "subdomain": { + "type": "string", + "nullable": true + }, + "verificationError": { + "type": "string", + "nullable": true + }, + "lastCheckedTime": { + "type": "string", + "nullable": true + }, + "dnsRecords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "MX", + "TXT" + ], + "description": "DNS record type", + "example": "TXT" + }, + "name": { + "type": "string", + "description": "DNS record name", + "example": "mail" + }, + "value": { + "type": "string", + "description": "DNS record value", + "example": "v=spf1 include:amazonses.com ~all" + }, + "ttl": { + "type": "string", + "description": "DNS record TTL", + "example": "Auto" + }, + "priority": { + "type": "string", + "nullable": true, + "description": "DNS record priority", + "example": "10" + }, + "status": { + "type": "string", + "enum": [ + "NOT_STARTED", + "PENDING", + "SUCCESS", + "FAILED", + "TEMPORARY_FAILURE" + ] + }, + "recommended": { + "type": "boolean", + "description": "Whether the record is recommended" + } + }, + "required": [ + "type", + "name", + "value", + "ttl", + "status" + ] + } + } + }, + "required": [ + "id", + "name", + "teamId", + "status", + "publicKey", + "createdAt", + "updatedAt", + "dnsRecords" + ] + } + } + } + } + } } }, "/v1/emails/{emailId}": { diff --git a/apps/web/src/server/public-api/api/domains/delete-domain.ts b/apps/web/src/server/public-api/api/domains/delete-domain.ts new file mode 100644 index 0000000..10397f9 --- /dev/null +++ b/apps/web/src/server/public-api/api/domains/delete-domain.ts @@ -0,0 +1,87 @@ +import { createRoute, z } from "@hono/zod-openapi"; +import { PublicAPIApp } from "../../hono"; +import { db } from "~/server/db"; +import { UnsendApiError } from "../../api-error"; +import { deleteDomain as deleteDomainService } from "~/server/service/domain-service"; + +const route = createRoute({ + method: "delete", + path: "/v1/domains/{id}", + request: { + params: z.object({ + id: z.coerce.number().openapi({ + param: { + name: "id", + in: "path", + }, + example: 1, + }), + }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + message: z.string(), + }), + }, + }, + description: "Domain deleted successfully", + }, + 403: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + description: "Forbidden - API key doesn't have access", + }, + 404: { + content: { + "application/json": { + schema: z.object({ + error: z.string(), + }), + }, + }, + description: "Domain not found", + }, + } +}) + +function deleteDomain(app: PublicAPIApp) { + app.openapi(route, async (c) => { + const team = c.var.team; + const domainId = c.req.valid("param").id; + + // Enforce API key domain restriction + if (team.apiKey.domainId && team.apiKey.domainId !== domainId) { + throw new UnsendApiError({ + code: "FORBIDDEN", + message: "API key doesn't have access to this domain", + }); + } + + const domain = await db.domain.findFirst({ + where: { + id: domainId, + teamId: team.id + }, + }); + + if (!domain) { + throw new UnsendApiError({ + code: "NOT_FOUND", + message: "Domain not found", + }); + } + + const deletedDomain = await deleteDomainService(domainId); + + return c.json(deletedDomain); + }); +} + +export default deleteDomain; \ No newline at end of file diff --git a/apps/web/src/server/public-api/index.ts b/apps/web/src/server/public-api/index.ts index 3d8fd7d..6cfe1de 100644 --- a/apps/web/src/server/public-api/index.ts +++ b/apps/web/src/server/public-api/index.ts @@ -14,6 +14,7 @@ import createDomain from "./api/domains/create-domain"; import deleteContact from "./api/contacts/delete-contact"; import verifyDomain from "./api/domains/verify-domain"; import getDomain from "./api/domains/get-domain"; +import deleteDomain from "./api/domains/delete-domain"; import sendBatch from "./api/emails/batch-email"; export const app = getApp(); @@ -23,6 +24,7 @@ getDomains(app); createDomain(app); verifyDomain(app); getDomain(app); +deleteDomain(app); /**Email related APIs */ getEmail(app); diff --git a/packages/python-sdk/usesend/domains.py b/packages/python-sdk/usesend/domains.py index ec3fffe..e53b543 100644 --- a/packages/python-sdk/usesend/domains.py +++ b/packages/python-sdk/usesend/domains.py @@ -34,4 +34,8 @@ class Domains: data, err = self.usesend.get(f"/domains/{domain_id}") return (data, err) # type: ignore[return-value] + def delete(self, domain_id: int) -> Tuple[Optional[Domain], Optional[APIError]]: + data, err = self.usesend.delete(f"/domains/{domain_id}") + return (data, err) # type: ignore[return-value] + from .usesend import UseSend # noqa: E402 pylint: disable=wrong-import-position diff --git a/packages/sdk/src/domain.ts b/packages/sdk/src/domain.ts index 3e203c6..fcbb6ab 100644 --- a/packages/sdk/src/domain.ts +++ b/packages/sdk/src/domain.ts @@ -37,6 +37,14 @@ type GetDomainResponse = { type GetDomainResponseSuccess = paths["/v1/domains/{id}"]["get"]["responses"]["200"]["content"]["application/json"]; +type DeleteDomainResponse = { + data: DeleteDomainResponseSuccess | null; + error: ErrorResponse | null; +}; + +type DeleteDomainResponseSuccess = + paths["/v1/domains/{id}"]["delete"]["responses"]["200"]["content"]["application/json"]; + export class Domains { constructor(private readonly usesend: UseSend) { this.usesend = usesend; @@ -50,7 +58,7 @@ export class Domains { async create(payload: CreateDomainPayload): Promise { const data = await this.usesend.post( "/domains", - payload + payload, ); return data; } @@ -58,14 +66,22 @@ export class Domains { async verify(id: number): Promise { const data = await this.usesend.put( `/domains/${id}/verify`, - {} + {}, ); return data; } async get(id: number): Promise { const data = await this.usesend.get( - `/domains/${id}` + `/domains/${id}`, + ); + + return data; + } + + async delete(id: number): Promise { + const data = await this.usesend.delete( + `/domains/${id}`, ); return data; diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts index 629ba4f..2f3acf6 100644 --- a/packages/sdk/src/usesend.ts +++ b/packages/sdk/src/usesend.ts @@ -1,6 +1,7 @@ import { ErrorResponse } from "../types"; import { Contacts } from "./contact"; import { Emails } from "./email"; +import { Domains } from "./domain"; const defaultBaseUrl = "https://app.usesend.com"; // eslint-disable-next-line turbo/no-undeclared-env-vars @@ -15,12 +16,13 @@ export class UseSend { // readonly domains = new Domains(this); readonly emails = new Emails(this); + readonly domains = new Domains(this); readonly contacts = new Contacts(this); url = baseUrl; constructor( readonly key?: string, - url?: string + url?: string, ) { if (!key) { if (typeof process !== "undefined" && process.env) { @@ -29,7 +31,7 @@ export class UseSend { if (!this.key) { throw new Error( - 'Missing API key. Pass it to the constructor `new UseSend("us_123")`' + 'Missing API key. Pass it to the constructor `new UseSend("us_123")`', ); } } @@ -46,7 +48,7 @@ export class UseSend { async fetchRequest( path: string, - options = {} + options = {}, ): Promise<{ data: T | null; error: ErrorResponse | null }> { const response = await fetch(`${this.url}${path}`, options); const defaultError = { diff --git a/packages/sdk/types/schema.d.ts b/packages/sdk/types/schema.d.ts index 96ec038..cee724d 100644 --- a/packages/sdk/types/schema.d.ts +++ b/packages/sdk/types/schema.d.ts @@ -364,7 +364,119 @@ export interface paths { }; put?: never; post?: never; - delete?: never; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Domain deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** + * @description The ID of the domain + * @example 1 + */ + id: number; + /** + * @description The name of the domain + * @example example.com + */ + name: string; + /** + * @description The ID of the team + * @example 1 + */ + teamId: number; + /** @enum {string} */ + status: "NOT_STARTED" | "PENDING" | "SUCCESS" | "FAILED" | "TEMPORARY_FAILURE"; + /** @default us-east-1 */ + region: string; + /** @default false */ + clickTracking: boolean; + /** @default false */ + openTracking: boolean; + publicKey: string; + dkimStatus?: string | null; + spfDetails?: string | null; + createdAt: string; + updatedAt: string; + /** @default false */ + dmarcAdded: boolean; + /** @default false */ + isVerifying: boolean; + errorMessage?: string | null; + subdomain?: string | null; + verificationError?: string | null; + lastCheckedTime?: string | null; + dnsRecords: { + /** + * @description DNS record type + * @example TXT + * @enum {string} + */ + type: "MX" | "TXT"; + /** + * @description DNS record name + * @example mail + */ + name: string; + /** + * @description DNS record value + * @example v=spf1 include:amazonses.com ~all + */ + value: string; + /** + * @description DNS record TTL + * @example Auto + */ + ttl: string; + /** + * @description DNS record priority + * @example 10 + */ + priority?: string | null; + /** @enum {string} */ + status: "NOT_STARTED" | "PENDING" | "SUCCESS" | "FAILED" | "TEMPORARY_FAILURE"; + /** @description Whether the record is recommended */ + recommended?: boolean; + }[]; + }; + }; + }; + /** @description Forbidden - API key doesn't have access to this domain */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + /** @description Domain not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error: string; + }; + }; + }; + }; + }; options?: never; head?: never; patch?: never;