From 46b3d26319551d53cccf2aa2af2090179197d230 Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:27:57 +0200 Subject: [PATCH 1/5] feat(mail): upload and download attachments --- src/mail/index.ts | 55 ++++++++++++++ src/mail/schema.ts | 129 ++++++++++++++++++++++++++++++++ src/mail/types.ts | 9 +++ src/shared/http/client.ts | 36 +++++++++ test/shared/http/client.test.ts | 89 ++++++++++++++++++++++ 5 files changed, 318 insertions(+) diff --git a/src/mail/index.ts b/src/mail/index.ts index 3f953b11..536682a6 100644 --- a/src/mail/index.ts +++ b/src/mail/index.ts @@ -22,6 +22,9 @@ import { HybridEncryptedEmail, EmailPublicParameters, PwdProtectedEmail, + UploadAttachmentResponse, + DownloadAttachmentResponse, + DownloadAttachmentPayload, } from './types'; export class MailApi { @@ -219,6 +222,42 @@ export class MailApi { return this.client.getWithParams('/users/me/mail-account/keys', params, this.headers()); } + uploadAttachment(file: File): Promise { + const formData = new FormData(); + formData.append('attachments', file, file.name); + + return this.client.postForm('/email/attachment', formData, this.headers()); + } + + /** + * Downloads an attachment of the given email as raw bytes, together with the + * metadata exposed by the response headers (filename, content type and + * content length). The caller decides how to consume the bytes (write them + * to disk, wrap them in a `Blob`, etc.). + * + * @param id - The id of the email that owns the attachment + * @param blobId - The blob id of the attachment + * @param query - Optional `name` and `type` overrides forwarded to the backend + * @returns The attachment bytes plus filename, content type and length + */ + async downloadAttachment( + id: string, + blobId: string, + query: DownloadAttachmentPayload = {}, + ): Promise { + const { data, headers } = await this.client.getBinary(`/email/${id}/attachment/${blobId}`, query, this.headers()); + + const contentLengthHeader = headers['content-length']; + const contentLength = contentLengthHeader ? Number(contentLengthHeader) : undefined; + + return { + data, + contentType: headers['content-type'] ?? 'application/octet-stream', + contentLength: Number.isFinite(contentLength) ? contentLength : undefined, + fileName: parseContentDispositionFilename(headers['content-disposition']), + }; + } + /** * Returns the needed headers for the module requests * @private @@ -233,3 +272,19 @@ export class MailApi { }); } } + +function parseContentDispositionFilename(header: string | undefined): string | undefined { + if (!header) return undefined; + + const utf8Match = /filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/i.exec(header); + if (utf8Match) { + try { + return decodeURIComponent(utf8Match[1].trim()); + } catch { + return utf8Match[1].trim(); + } + } + + const asciiMatch = /filename\s*=\s*"?([^";]+)"?/i.exec(header); + return asciiMatch ? asciiMatch[1].trim() : undefined; +} diff --git a/src/mail/schema.ts b/src/mail/schema.ts index f6ac077e..83a7c878 100644 --- a/src/mail/schema.ts +++ b/src/mail/schema.ts @@ -188,6 +188,46 @@ export interface paths { patch?: never; trace?: never; }; + '/email/attachment': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload an attachment + * @description Uploads an attachment and get the info to attach it to an user email. + */ + post: operations['EmailController_uploadAttachment']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/email/{id}/attachment/{blobId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Download an attachment + * @description Streams the bytes of an attachment from the given email. Optional `name` and `type` query params set the response filename and content-type. + */ + get: operations['EmailController_downloadAttachment']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/users/me/mail-account': { parameters: { query?: never; @@ -429,6 +469,19 @@ export interface components { /** @description Filter by attachment presence */ hasAttachment?: boolean; }; + EmailAttachmentDto: { + /** @example T1a2b3c… */ + blobId: string; + /** @example photo.jpg */ + name: string; + /** @example image/jpeg */ + type: string; + /** + * @description Size in bytes + * @example 4096 + */ + size: number; + }; EmailResponseDto: { /** @example Ma1f09b… */ id: string; @@ -471,6 +524,7 @@ export interface components { textBody: string | null; /** @example

Hi team, here are the notes…

*/ htmlBody: string | null; + attachments: components['schemas']['EmailAttachmentDto'][]; }; LookupRecipientKeysRequestDto: { /** @@ -501,6 +555,19 @@ export interface components { /** @description De-identified wrapped keys, one per recipient */ wrappedKeys: components['schemas']['EncryptedWrappedKeyDto'][]; }; + AttachmentRefDto: { + /** @example T1a2b3c… */ + blobId: string; + /** @example photo.jpg */ + name: string; + /** @example image/jpeg */ + type: string; + /** + * @description Size in bytes + * @example 4096 + */ + size: number; + }; SendEmailRequestDto: { /** @description Primary recipients (at least one required) */ to: components['schemas']['EmailAddressDto'][]; @@ -519,6 +586,8 @@ export interface components { */ htmlBody?: string; encryption?: components['schemas']['EncryptionBlockDto']; + /** @description Attachments to include, referenced by blobId previously obtained from POST /email/attachment */ + attachments?: components['schemas']['AttachmentRefDto'][]; }; EmailCreatedResponseDto: { /** @@ -537,6 +606,21 @@ export interface components { textBody?: string; /** @example

Still working on this…

*/ htmlBody?: string; + /** @description Attachments to include, referenced by blobId previously obtained from POST /email/attachment */ + attachments?: components['schemas']['AttachmentRefDto'][]; + }; + UploadAttachmentResponseDto: { + /** @example T1a2b3c… */ + blobId: string; + /** + * @description Size in bytes + * @example 4096 + */ + size: number; + /** @example image/jpeg */ + type: string; + /** @example photo.jpg */ + name: string; }; UpdateEmailRequestDto: { /** @@ -898,6 +982,51 @@ export interface operations { }; }; }; + EmailController_uploadAttachment: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Upload attachment successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UploadAttachmentResponseDto']; + }; + }; + }; + }; + EmailController_downloadAttachment: { + parameters: { + query?: { + type?: unknown; + name?: unknown; + }; + header?: never; + path: { + /** @description Email ID */ + id: string; + /** @description Attachment blob ID */ + blobId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; UserController_getMailAccount: { parameters: { query?: never; diff --git a/src/mail/types.ts b/src/mail/types.ts index 0e1e8687..2d3b5a8f 100644 --- a/src/mail/types.ts +++ b/src/mail/types.ts @@ -17,6 +17,15 @@ export type EmailAddress = components['schemas']['EmailAddressDto']; export type ListEmailsQuery = operations['EmailController_list']['parameters']['query']; export type SearchFiltersQuery = operations['EmailController_search']['requestBody']['content']['application/json']; export type EmailDomainsResponse = components['schemas']['MailDomainDto'][]; +export type UploadAttachmentResponse = components['schemas']['UploadAttachmentResponseDto']; +export type DownloadAttachmentPayload = operations['EmailController_downloadAttachment']['parameters']['query']; +export type AttachmentRef = components['schemas']['EmailAttachmentDto']; +export type DownloadAttachmentResponse = { + data: ArrayBuffer; + contentType: string; + contentLength?: number; + fileName?: string; +}; export type SetupMailAccountPayload = { address: string; domain: string; diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index 3d5153dc..724a1421 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -99,6 +99,42 @@ export class HttpClient { return await this.execute(() => this.axios.get(url, { params, headers })); } + /** + * Requests a GET that returns raw binary bytes together with the response + * headers. Useful for endpoints that stream files where the caller needs + * `Content-Type`, `Content-Length` or `Content-Disposition`. + * + * Bypasses the response interceptor so headers are preserved. + * + * @param url + * @param params + * @param headers + */ + public async getBinary( + url: URL, + params: Parameters, + headers: Headers, + ): Promise<{ data: ArrayBuffer; headers: Record }> { + return await this.execute(async () => { + try { + const response = await axios.get(url, { + baseURL: this.axios.defaults.baseURL, + params, + headers, + responseType: 'arraybuffer', + transformResponse: (raw) => raw, + }); + return { + data: response.data, + headers: response.headers as unknown as Record, + }; + } catch (error) { + this.normalizeError(error as AxiosError); + throw error; + } + }); + } + /** * Requests a GET with option to cancel * @param url diff --git a/test/shared/http/client.test.ts b/test/shared/http/client.test.ts index 0abd0535..bd4d4d6b 100644 --- a/test/shared/http/client.test.ts +++ b/test/shared/http/client.test.ts @@ -308,7 +308,96 @@ describe('HttpClient', () => { }, ); }); + }); + + describe('binary downloads', () => { + it('when downloading a binary resource then forwards the path, query and headers to the server', async () => { + const callStub = vi.spyOn(axios, 'get').mockResolvedValue({ + data: new ArrayBuffer(8), + headers: { 'content-type': 'image/png' }, + }); + const client = HttpClient.create('https://api.example.com'); + + await client.getBinary('/some-path', { name: 'photo.jpg', type: 'image/jpeg' }, { some: 'header' }); + + expect(callStub).toHaveBeenCalledWith('/some-path', { + baseURL: 'https://api.example.com', + params: { name: 'photo.jpg', type: 'image/jpeg' }, + headers: { some: 'header' }, + responseType: 'arraybuffer', + transformResponse: expect.any(Function), + }); + }); + + it('when the server responds then returns both the raw bytes and the response headers', async () => { + const buffer = new ArrayBuffer(16); + const responseHeaders = { + 'content-type': 'application/pdf', + 'content-length': '16', + 'content-disposition': 'attachment; filename="doc.pdf"', + }; + vi.spyOn(axios, 'get').mockResolvedValue({ data: buffer, headers: responseHeaders }); + const client = HttpClient.create(''); + + const result = await client.getBinary('/some-path', {}, {}); + + expect(result).toEqual({ data: buffer, headers: responseHeaders }); + }); + + it('when the server returns the body then the raw bytes are not parsed or transformed', async () => { + const callStub = vi.spyOn(axios, 'get').mockResolvedValue({ + data: new ArrayBuffer(0), + headers: {}, + }); + const client = HttpClient.create(''); + + await client.getBinary('/some-path', {}, {}); + + const config = callStub.mock.calls[0][1] as { transformResponse: (raw: unknown) => unknown }; + const raw = new ArrayBuffer(4); + expect(config.transformResponse(raw)).toBe(raw); + }); + + it('when the server returns a generic error then it is rethrown as a normalized error', async () => { + const unauthorizedSpy = vi.fn(); + const axiosError = getAxiosError(); + axiosError.message = 'boom'; + axiosError.response = { data: { error: 'nope' }, status: 500 }; + vi.spyOn(axios, 'get').mockRejectedValue(axiosError); + const client = HttpClient.create('', unauthorizedSpy); + + await expect(client.getBinary('/some-path', {}, {})).rejects.toMatchObject({ + message: 'boom', + status: 500, + }); + expect(unauthorizedSpy).not.toHaveBeenCalled(); + }); + + it('when the server responds with unauthorized then the unauthorized callback is invoked', async () => { + const unauthorizedSpy = vi.fn(); + const axiosError = getAxiosError(); + axiosError.message = 'unauthorized'; + axiosError.response = { data: { error: 'nope' }, status: 401 }; + vi.spyOn(axios, 'get').mockRejectedValue(axiosError); + const client = HttpClient.create('', unauthorizedSpy); + + await expect(client.getBinary('/some-path', {}, {})).rejects.toMatchObject({ status: 401 }); + expect(unauthorizedSpy).toHaveBeenCalledOnce(); + }); + + it('when retry options are configured then the binary download is retried through the same backoff path', async () => { + const retrySpy = vi.spyOn(retryModule, 'retryWithBackoff').mockImplementation((fn) => fn()); + vi.spyOn(axios, 'get').mockResolvedValue({ data: new ArrayBuffer(0), headers: {} }); + const instanceOptions = { maxRetries: 2 }; + const client = HttpClient.create('', undefined, instanceOptions); + + await client.getBinary('/some-path', {}, {}); + + expect(retrySpy).toHaveBeenCalledWith(expect.any(Function), instanceOptions); + }); + }); + describe('calls (continued)', () => { it('should execute DELETE request with correct parameters', async () => { // Arrange const callStub = vi.spyOn(axios.Axios.prototype, 'delete').mockResolvedValue({}); From 5c44fa7f3edea1cd02cd3a8240e5aa9f260d928a Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:53:26 +0200 Subject: [PATCH 2/5] chore: bump package version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index af86e02f..29d3ab8d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@internxt/sdk", "author": "Internxt ", - "version": "1.17.2", + "version": "1.17.3", "description": "An sdk for interacting with Internxt's services", "repository": { "type": "git", From 74ce6791bcb888f88cf2d341525d8db6d748e2ff Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:22:34 +0200 Subject: [PATCH 3/5] fix: use postCancellable when uploading attachments --- src/mail/index.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/mail/index.ts b/src/mail/index.ts index 536682a6..8d69e885 100644 --- a/src/mail/index.ts +++ b/src/mail/index.ts @@ -1,6 +1,6 @@ import { ApiSecurity, ApiUrl, AppDetails } from '../shared'; import { headersWithToken } from '../shared/headers'; -import { HttpClient } from '../shared/http/client'; +import { HttpClient, RequestCanceler } from '../shared/http/client'; import { MailboxResponse, EmailListResponse, @@ -222,11 +222,24 @@ export class MailApi { return this.client.getWithParams('/users/me/mail-account/keys', params, this.headers()); } - uploadAttachment(file: File): Promise { + /** + * Uploading an attachment to the S3 so we can attach it to an email + * @param file - File to upload + * @returns + * - `blobId` - The blob id of the attachment + * - `name` - The name of the attachment + * - `type` - The content type of the attachment + * - `size` - The size of the attachment + * + */ + uploadAttachment(file: File): { + promise: Promise; + requestCanceler: RequestCanceler; + } { const formData = new FormData(); formData.append('attachments', file, file.name); - return this.client.postForm('/email/attachment', formData, this.headers()); + return this.client.postCancellable('/email/attachment', formData, this.headers()); } /** From 7da848398256cc80c4254d8f09ffc71d8e3cf3db Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 3 Jun 2026 12:26:21 +0200 Subject: [PATCH 4/5] feat(mail): add postFormCancellable to upload attachments --- src/mail/index.ts | 2 +- src/shared/http/client.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/mail/index.ts b/src/mail/index.ts index 8d69e885..5f7dafcc 100644 --- a/src/mail/index.ts +++ b/src/mail/index.ts @@ -239,7 +239,7 @@ export class MailApi { const formData = new FormData(); formData.append('attachments', file, file.name); - return this.client.postCancellable('/email/attachment', formData, this.headers()); + return this.client.postFormCancellable('/email/attachment', formData, this.headers()); } /** diff --git a/src/shared/http/client.ts b/src/shared/http/client.ts index 724a1421..b9c1c5d1 100644 --- a/src/shared/http/client.ts +++ b/src/shared/http/client.ts @@ -197,6 +197,32 @@ export class HttpClient { return await this.execute(() => this.axios.postForm(url, params, { headers })); } + /** + * Requests a POST FORM with option to cancel + * @param url + * @param params + * @param headers + */ + public postFormCancellable( + url: URL, + params: Parameters, + headers: Headers, + ): { + promise: Promise; + requestCanceler: RequestCanceler; + } { + let currentCancel: RequestCanceler['cancel'] = () => {}; + const requestCanceler: RequestCanceler = { cancel: (message) => currentCancel(message) }; + + const promise = this.execute(() => { + const source = axios.CancelToken.source(); + currentCancel = source.cancel; + return this.axios.postForm(url, params, { headers, cancelToken: source.token }); + }); + + return { promise, requestCanceler }; + } + /** * Requests a POST with option to cancel * @param url From 055b5b2871c9ae7bfe154e4ea01a68cb8808f7af Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:58:25 +0200 Subject: [PATCH 5/5] chore: bump SDK version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 29d3ab8d..f266b72e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@internxt/sdk", "author": "Internxt ", - "version": "1.17.3", + "version": "1.17.4", "description": "An sdk for interacting with Internxt's services", "repository": { "type": "git",