Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@internxt/sdk",
"author": "Internxt <hello@internxt.com>",
"version": "1.17.3",
"version": "1.17.4",
"description": "An sdk for interacting with Internxt's services",
"repository": {
"type": "git",
Expand Down
70 changes: 69 additions & 1 deletion src/mail/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +22,9 @@ import {
HybridEncryptedEmail,
EmailPublicParameters,
PwdProtectedEmail,
UploadAttachmentResponse,
DownloadAttachmentResponse,
DownloadAttachmentPayload,
} from './types';

export class MailApi {
Expand Down Expand Up @@ -219,6 +222,55 @@ export class MailApi {
return this.client.getWithParams('/users/me/mail-account/keys', params, this.headers());
}

/**
* 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<UploadAttachmentResponse>;
requestCanceler: RequestCanceler;
} {
const formData = new FormData();
formData.append('attachments', file, file.name);

return this.client.postFormCancellable('/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<DownloadAttachmentResponse> {
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
Expand All @@ -233,3 +285,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;
}
129 changes: 129 additions & 0 deletions src/mail/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -471,6 +524,7 @@ export interface components {
textBody: string | null;
/** @example <p>Hi team, here are the notes…</p> */
htmlBody: string | null;
attachments: components['schemas']['EmailAttachmentDto'][];
};
LookupRecipientKeysRequestDto: {
/**
Expand Down Expand Up @@ -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'][];
Expand All @@ -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: {
/**
Expand All @@ -537,6 +606,21 @@ export interface components {
textBody?: string;
/** @example <p>Still working on this…</p> */
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: {
/**
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/mail/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
62 changes: 62 additions & 0 deletions src/shared/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }> {
return await this.execute(async () => {
try {
const response = await axios.get<ArrayBuffer>(url, {
baseURL: this.axios.defaults.baseURL,
params,
headers,
responseType: 'arraybuffer',
transformResponse: (raw) => raw,
});
return {
data: response.data,
headers: response.headers as unknown as Record<string, string>,
};
} catch (error) {
this.normalizeError(error as AxiosError);
throw error;
}
});
}

/**
* Requests a GET with option to cancel
* @param url
Expand Down Expand Up @@ -161,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<Response>(
url: URL,
params: Parameters,
headers: Headers,
): {
promise: Promise<Response>;
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<never, Response>(url, params, { headers, cancelToken: source.token });
});

return { promise, requestCanceler };
}

/**
* Requests a POST with option to cancel
* @param url
Expand Down
Loading
Loading