@social.io/client
A Node.js TypeScript client for the social.io platform and external mail accounts. SocialClient combines the complete published social.io RPC API, its REST endpoints, realtime events, and JMAP mail. MailClient provides the same mail interface for other JMAP servers or IMAP accounts with optional SMTP sending.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Runtime and installation
pnpm add @social.io/client
Requires Node.js 22 or newer. IMAP/SMTP need native sockets, so this package is intended for Node applications, services, CLIs, and desktop backends. It does not provide a browser bundle or a POP3 client.
Contributors build and verify a checkout with:
pnpm install
pnpm build
pnpm test
pnpm run check
Connecting to social.io
Use the base URL of your social.io application deployment. The client derives typedrequest, .well-known/jmap, api/, and ws endpoints from that URL; it does not assume that a public marketing hostname serves the application.
The base URL must be HTTPS, without credentials, query, or fragment. Plain http: is accepted only for loopback servers (localhost, 127.0.0.0/8, ::1) so that access tokens are never sent in cleartext; any other http: URL is rejected when the client is constructed.
import { SocialClient } from '@social.io/client';
const client = new SocialClient({
baseUrl: 'https://app.example.com/',
accessToken: process.env.SOCIAL_IO_ACCESS_TOKEN,
});
client.mail.on('error', (error) => console.error('Mail error:', error));
client.realtime.on('error', (error) => console.error('Realtime error:', error));
client.mail.on('message', (message) => {
console.log(message.from, message.subject, message.text);
});
try {
// HTTP is available independently of mail and realtime connections.
const { accounts } = await client.api.request('listMailAccounts', {});
console.log(accounts);
await client.connect(); // resolves after mail and realtime authentication
const messages = await client.mail.queryMessages({ subject: 'Invoice' }, 20);
if (messages[0]) await client.mail.markSeen(messages[0].ref);
} finally {
await client.close();
}
disconnect() closes mail and realtime while leaving HTTP available for login, refresh, and subsequent reconnection. close() also permanently closes HTTP and aborts its in-flight requests. A failed combined connection cleans up both mail and realtime.
Events and error handling
MailClient and SocialRealtimeClient emit connected, disconnected, and error; mail adds message and realtime adds frame. disconnected is emitted only after connected was emitted for the same connection, so a connection attempt that never authenticated ends with its rejected connect() promise and no lifecycle event.
Errors that belong to a call are thrown or rejected by that call. Errors observed outside a call — a provider dropping the connection, a failed cleanup after a failed connection attempt, or a consumer callback that throws — are emitted as error. Node throws an error event that nobody listens to, so this client never relies on that: when no error listener is registered, the error is reported through process.emitWarning() instead, and the client keeps running. Register an error listener to handle those failures; watching the process warnings is only a fallback.
Every consumer callback (onPush handlers and the event listeners themselves) runs isolated. A handler that throws, or an async handler whose promise rejects, is reported as an error; the remaining handlers, the frame event, and the connection continue. Handlers are not awaited, so an async handler does not delay dispatch, and its rejection never becomes an unhandled rejection.
A failed connect() rejects with the error that caused it. If cleaning up after that failure also fails, the cleanup failure is emitted as a separate error carrying the original cleanup rejection as its cause, and the client is released for another connect().
Full social.io API
SocialApiClient also works independently:
import { SocialApiClient } from '@social.io/client';
const api = new SocialApiClient({ baseUrl: 'https://app.example.com/' });
const login = await api.request('loginWithPassword', {
emailOrUsername: 'alice',
password: process.env.SOCIAL_IO_PASSWORD!,
deviceLabel: 'Node client',
});
if (login.status === 'success' && login.jwt) {
api.setAccessToken(login.jwt);
const { sendIdentities } = await api.request('listSendIdentities', {});
console.log(sendIdentities);
}
await api.close();
The method argument selects the exact request and response types from @social.io/interfaces; no generic type argument is required. request() injects the current JWT for authenticated methods. requestRaw() accepts the complete original contract payload, including an explicitly supplied JWT.
The initial API map covers all 130 client requests and 18 push contracts in @social.io/interfaces 3.0.0:
- Authentication, verification, password reset, MFA, sessions, and revocation.
- Mail accounts, threads, sending/replies, drafts, screener, and triage.
- Chats, reactions, memberships, and typing.
- Calendars, meetings, calls, voicemail, and letters.
- Organizations, workspaces, teams, spaces, and settings.
- Unified inbox/person timelines, notifications, and widgets.
Payload types remain owned by @social.io/interfaces. Exported socialRequestMethods and socialPushMethods enumerate the surface, and a regression test checks them against the installed contract package. The client does not invent endpoints such as contact CRUD that the server does not publish.
REST and binary resources
api.fetch() supports every server REST route, including routes without published TypeScript contracts. It returns the original Response; check response.ok and consume or cancel the response body. The request deadline remains active while the body is consumed.
const response = await api.fetch('/api/v1/attachments/attachment-id/download');
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
Pass method, headers, and body in the second argument for JSON or binary uploads, following the target route's contract. Requests must stay within the configured server's api/ path. Bearer authentication is injected, cookies are omitted, and redirects are rejected. Public endpoints can use api.fetch('/api/v1/health', {}, { authenticated: false }) before login. For a deployment under a path prefix, use relative paths such as api/v1/attachments/attachment-id/download.
Deadlines and authentication lifetime
RPC and REST default to a 30-second deadline. Per-call options accept { timeoutMs, signal }. RPC response caching and server-directed retries are disabled; the library does not replay a mutation after an ambiguous failure. Original typed errors and SMTP delivery errors propagate to callers.
accessToken may be a string or () => string | Promise<string>. HTTP resolves it on each authenticated call. Social mail and realtime resolve it when connecting; already-open connections do not replace their credentials. Credential callback waits are cancelled on shutdown. Mail connection preparation defaults to 30 seconds and accepts connectionTimeoutMs; an already-started SMTP verification is drained before the connection attempt settles.
SMTP verification is bounded by SmartSMTP's own timeouts rather than by connectionTimeoutMs: 30 seconds for the socket connect and 30 seconds per server response, with a 5-second close timeout. A slow SMTP server can therefore hold connect() past connectionTimeoutMs, up to the sum of those steps, because the underlying transport accepts no caller deadline.
Login and MFA outcomes are returned unchanged. For MFA, call verifyTotpChallenge with the challenge token and code, then set the returned JWT on the API client. Refresh is explicit through request('refreshSession', { refreshToken }); retain the returned replacement refresh token, update the access token, and disconnect/reconnect mail and realtime. Callers must serialize refresh operations because social.io rotates refresh tokens. The client does not store sessions on disk, refresh tokens automatically, or automatically reconnect.
External JMAP servers
import { MailClient } from '@social.io/client';
const mail = new MailClient({
protocol: 'jmap',
connection: {
sessionUrl: 'https://mail.example.com/.well-known/jmap',
auth: { accessToken: process.env.MAIL_ACCESS_TOKEN! },
mailbox: 'INBOX',
},
});
mail.on('error', (error) => console.error(error));
mail.on('message', (message) => console.log(message.subject));
try {
await mail.connect();
await mail.sendMail({
to: [{ email: 'recipient@example.com' }],
subject: 'Hello',
text: 'Plain text',
html: '<p>HTML text</p>',
attachments: [{
filename: 'note.txt',
contentType: 'text/plain',
content: new TextEncoder().encode('Attachment content'),
}],
});
} finally {
await mail.disconnect();
}
JMAP also accepts Basic authentication as { user, pass }. Sending uses the server's EmailSubmission/set capability and default identity when from is omitted. An optional SMTP configuration can provide an explicit alternative sending transport. The server must support the requested JMAP operation.
External IMAP and SMTP servers
import { MailClient } from '@social.io/client';
const mail = new MailClient({
protocol: 'imap',
connection: {
host: 'imap.example.com',
port: 993,
tlsMode: 'implicitTls',
auth: { user: 'alice@example.com', pass: process.env.MAIL_PASSWORD! },
mailbox: 'INBOX',
filter: { seen: false },
},
smtp: {
smtpServer: 'smtp.example.com',
smtpPort: 587,
smtpTlsMode: 'starttls',
smtpUser: 'alice@example.com',
smtpPassword: process.env.MAIL_PASSWORD!,
},
});
mail.on('error', (error) => console.error(error));
mail.on('message', (message) => console.log(message.ref, message.subject));
try {
await mail.connect(); // also verifies configured SMTP transport
await mail.sendMail({
from: { email: 'alice@example.com', name: 'Alice' },
to: [{ email: 'recipient@example.com' }],
subject: 'Hello from IMAP/SMTP',
text: 'Message body',
});
} finally {
await mail.disconnect();
}
IMAP OAuth uses { user, accessToken }; SMTP OAuth uses smtpAccessToken instead of smtpPassword. Supply either connection or smtp as an async function to resolve current credentials. IMAP configuration is resolved per connection; SMTP configuration is resolved at connection verification and for each send. SMTP TLS supports trusted custom CAs and an explicit certificate server name through the underlying SmartSMTP options.
Common mail operations and capabilities
| Operation | JMAP | IMAP with optional SMTP |
|---|---|---|
| Watch messages, list mailboxes | Yes | Yes |
queryMessages, getMessage |
Yes | Unavailable in the published SmartIMAP client |
markSeen, setFlagged, moveMessage, deleteMessage |
Yes | Yes |
| Download received attachments | Blob download | Parsed attachment bytes |
sendMail |
JMAP submission or configured SMTP | Requires SMTP |
sendRaw with explicit envelope and streaming bytes |
Requires SMTP | Requires SMTP |
appendRaw to a mailbox |
Unavailable | Yes |
mail.capabilities describes operations provided by the selected adapter and transport configuration; it is not a promise of server permissions or extensions. Unsupported operations throw UnsupportedMailOperationError.
Both protocols emit messages from an initial mailbox sweep and continue watching. JMAP prefers SSE and falls back to polling through SmartJMAP. IMAP defaults to unseen messages and emits messages as reported by SmartIMAP. This is not a durable synchronization cursor or a complete stream of remote flag/deletion changes; reconnects may emit messages again.
Messages share an IMailMessage shape with address arrays, text/HTML, attachment references, seen/flagged state, and a protocol-specific ref. JMAP references contain its email ID. IMAP references retain mailbox, UID, and UIDVALIDITY; the client checks the current UIDVALIDITY before mutations and rejects a stale reference. Do not persist a UID without its mailbox and validity epoch. A successful move can invalidate the old IMAP reference; obtain a reference from the destination before further operations.
deleteMessage() permanently deletes using the provider's deletion semantics. sendMail() returns a discriminated result with either transport: 'jmap' and email/submission IDs, or transport: 'smtp' and the original delivery result. SMTP errors retain the underlying disposition and phase; an uncertain outcome must not be retried blindly. Disconnect drains already-started SMTP sends because the transport does not expose cancellation of a delivery transaction.
Realtime events
const unsubscribe = client.realtime.onPush('pushMailThreadUpdate', (payload) => {
console.log(payload);
});
client.realtime.on('frame', (frame) => console.log(frame));
await client.realtime.connect();
client.realtime.subscribeChannel('channel-or-conversation-id');
client.realtime.setTyping('channel-or-conversation-id', true);
client.realtime.updatePresence('online');
unsubscribe();
await client.realtime.disconnect();
SocialRealtimeClient can also be constructed independently. Its auth option accepts { type: 'user', token }, { type: 'widget', siteKey, visitorToken }, or a function resolving one of those shapes. It implements social.io's custom JSON protocol at /ws, waits for authentication acknowledgement, and sends 30-second keepalives. frame exposes server acknowledgements, subscription errors, and legacy chat events. onPush() provides typed payloads for all published push methods. Reconnect and resubscribe explicitly after disconnection.
Session-revocation events are delivered unchanged. Compare the event's session ID with the application's current session before deciding whether to clear credentials.
Verification
Tests use published SmartJMAP/SmartIMAP servers, local SMTP and WebSocket fixtures, and an actual @api.global/typedrequest 3.3.2 router matching social.io's current dependency. The older router is a development-only alias used to verify interoperability with the current TypedRequest client. Tests never send mail to external recipients or require real credentials. They verify protocol operations, attachments/BCC/raw bytes, stale IMAP references, contract coverage, typed errors, cancellation, and reconnection with updated credentials, plus the loopback-only HTTP rule, unobserved error reporting, failed-cleanup context, callback isolation, and the connected/disconnected lifecycle.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the repository license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.