Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,14 @@ Choose your entry point based on your needs:
- **[SecureJoin Handshake](./docs/security.md#securejoin-handshake)** — QR-code / URI verification protocol.
- **[Multi-Device Sync](./docs/security.md#multi-device-synchronization)** — State consistency across devices.
- **[Media Processing](./docs/security.md#media-processing)** — Base64 handling, MIME parsing, and attachments.
- **[Core parity map](./docs/parity.md)** — madcore-web API vs Delta Chat core RPC.

### Level 4: Architecture & Protocol Internals
*Core contributors and protocol researchers.*
- **[WebSocket Protocol](./docs/architecture.md#the-websocket-protocol)** — JSON-RPC message structure and actions.
- **[UID System](./docs/architecture.md#the-uid-system)** — Message tracking and synchronization.
- **[Module Layout](./docs/architecture.md#package-layout)** — `lib/transport.ts`, `lib/crypto.ts`, `lib/mime.ts`, and more.
- **[Developing Extensions](./docs/architecture.md#developing-extensions)** — Webxdc support and custom storage.
- **[Module Layout](./docs/architecture.md#package-layout)** — `lib/transport.ts`, `lib/mime-build.ts`, `lib/webxdc.ts`, and more.
- **[Developing Extensions](./docs/architecture.md#developing-extensions)** — Webxdc, calls, location, custom storage.

---

Expand Down
187 changes: 187 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

28 changes: 21 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,23 @@ madcore-web/
├── index.ts # Internal module re-exports
├── transport.ts # WebSocket / REST network layer
├── crypto.ts # OpenPGP key management
├── mime.ts # RFC 2822 parsing & construction
├── messaging.ts # send() target resolution & payloads
├── securejoin.ts # QR / URI handshake state machine
├── group.ts # Groups & broadcast channels
├── mime.ts # RFC 2822 parsing
├── mime-build.ts # Shared PGP/MIME envelope builders
├── messaging.ts # 1:1 send helpers (text, media, MDN, …)
├── securejoin.ts # QR / URI handshake + checkQr
├── group.ts # Groups, broadcast, group actions
├── viewtype.ts # Viewtype ↔ store type mapping
├── webxdc.ts # Webxdc apps + status updates
├── backup.ts # Export/import encrypted backup
├── location.ts # Location streaming
├── calls.ts # WebRTC call signaling
├── profile.ts # Display name & avatar handling
├── context.ts # Per-account runtime context
└── logger.ts # Configurable log levels
```



## The WebSocket Protocol

The SDK communicates with the Delta Chat Relay using a **bidirectional JSON-RPC over WebSocket** protocol.
Expand Down Expand Up @@ -131,10 +139,16 @@ The SDK is store-agnostic. Any object implementing the `IDeltaChatStore` interfa
## Developing Extensions

### Webxdc Support
Webxdc ("Apps in Chat") allows developers to build full-blown web applications that live inside any Delta Chat conversation.
Webxdc ("Apps in Chat") is implemented in `lib/webxdc.ts`:

- `sendWebxdc` — send an `.xdc` attachment (`application/webxdc`, `Chat-Content: app`)
- `sendWebxdcStatusUpdate` / `getWebxdcStatusUpdates` — JSON status updates keyed by instance Message-ID
- Event: `DC_EVENT_WEBXDC_STATUS_UPDATE`

Realtime Webxdc channels are not yet implemented.

- **The SDK and Webxdc:** The SDK provides a low-level API for sending and receiving Webxdc-specific payloads.
- **Exposing the API:** Future versions of the SDK will include a dedicated `WebxdcManager` to simplify sending game/app state updates across a group chat.
### Shared MIME builders
`lib/mime-build.ts` is the single place that constructs PGP/MIME envelopes for 1:1 and group fan-out.

### Custom Transports
While Madcore Web defaults to the Delta Chat WebSocket Relay, it can be extended with custom `Transport` implementations (e.g., to bridge into other messaging protocols).
Expand Down
48 changes: 44 additions & 4 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,10 @@ await acc.sendMessage(bob, 'Hello!');

---

## Multi-Relay (Future)
## Multi-Relay

> **Note:** Multi-relay support is built into the SDK architecture but is not
> the primary workflow yet. Each account currently uses a single relay.
> When multi-relay is needed, the API is ready:
Accounts can hold multiple chatmail identities and keep several WebSocket
transports open. Relays are persisted via `saveToStore` / `loadFromStore`.

```ts
// Register on a second server
Expand All @@ -314,6 +313,47 @@ alice.getRelay(relay2.id); // → RelayInfo | undefined
alice.removeRelay(relay2.id); // disconnects + removes
```

## Drafts, ephemeral, avatars

```ts
await acc.setDraft(chatId, { text: 'unsent…' });
await acc.getDraft(chatId);
await acc.removeDraft(chatId);

await acc.setChatEphemeralTimer(chatId, 60); // seconds; 0 = off
await acc.sweepEphemeralMessages(); // call periodically

await acc.setChatProfileImage(groupId, { data: b64, mimeType: 'image/jpeg' });
await acc.removeChatProfileImage(groupId);
```

## Webxdc, location, calls

```ts
await acc.sendWebxdc(bob, { data: xdcBase64, name: 'Chess' });
await acc.sendWebxdcStatusUpdate(bob, instanceMsgId, { payload: { move: 'e2e4' } });

await acc.sendLocationsToChat(bob.email, { durationSec: 600 });
await acc.setLocation({ lat: 52.5, lon: 13.4 });
await acc.stopSendingLocations(bob.email);

const call = await acc.placeOutgoingCall(bob, { video: true, sdpOffer });
await acc.acceptIncomingCall(call.callId, { sdpAnswer });
await acc.endCall(call.callId);
acc.setIceServers([{ urls: 'stun:stun.l.google.com:19302' }]);
```

## Backup & config

```ts
const json = await acc.exportBackup({ passphrase: 'optional' });
await other.importBackup(json, { passphrase: 'optional' });

await acc.setConfig('watched_mailboxes', 'INBOX,Sent');
acc.setWatchedMailboxes(['INBOX', 'DeltaChat']);
await acc.backgroundFetch(0);
```

---

## Account Lifecycle
Expand Down
96 changes: 96 additions & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# madcore-web ↔ Delta Chat core parity

This document maps core JSON-RPC surface areas to **madcore-web** APIs.
madcore-web is a **web-native chatmail client**, not a wrapper around `@deltachat/jsonrpc-client`.

| Status | Meaning |
|--------|---------|
| ✅ | Implemented |
| 🟡 | Partial / web-shaped equivalent |
| ❌ | Out of scope or not yet implemented |
| 🌐 | Web-only approach (no native core equivalent needed) |

## Accounts & IO

| Core | madcore-web | Status |
|------|-------------|--------|
| `addAccount` / `removeAccount` | `DeltaChatSDK().register` / `addAccount` / `removeAccount` | ✅ |
| `configure` / IMAP-SMTP | `register` + `connect` (WS/REST) | 🟡 |
| `startIo` / `stopIo` | `connect` / `disconnect` | 🟡 |
| `getConnectivity` | `getConnectivity` / `getConnectivityHtml` | ✅ |
| `backgroundFetch` | `backgroundFetch` / `processPushPayload` | 🟡 |
| Multi-transport | `addRelay` / `listRelays` / multi-WS | ✅ |

## Messaging

| Core | madcore-web | Status |
|------|-------------|--------|
| `miscSendTextMessage` / `sendMsg` | `send` / `sendMessage` | ✅ |
| Image/Video/Audio/Voice/File | `send({ image\|video\|audio\|voice\|file })` | ✅ |
| Sticker / Gif | `send({ sticker\|gif })` | ✅ |
| Reactions | `send({ reaction })` | ✅ |
| Edit / delete for all | `send({ edit\|delete })` | ✅ |
| Forward | `send({ forward })` | ✅ |
| Drafts | `setDraft` / `getDraft` / `removeDraft` | ✅ |
| Read receipts | `markChatRead` + wire MDN | ✅ |
| Ephemeral timer | `setChatEphemeralTimer` / `sweepEphemeralMessages` | ✅ |
| HTML messages | — | ❌ |
| Download full / blob dir | base64 in store | 🟡 |

## Groups & channels

| Core | madcore-web | Status |
|------|-------------|--------|
| `createGroupChat` | `createGroup` | ✅ |
| Broadcast / channel | `createChannel` / `sendBroadcast` | ✅ |
| Add/remove member | `addGroupMember` / `removeGroupMember` | ✅ |
| Rename / description | `renameGroup` / `updateGroupDescription` | ✅ |
| Group media + actions | `send(group, …)` reaction/edit/delete/media | ✅ |
| Group avatar | `setChatProfileImage` | ✅ |
| Unencrypted groups | — | ❌ |

## Contacts & safety

| Core | madcore-web | Status |
|------|-------------|--------|
| Contacts CRUD | `createContact` / `getContacts` / … | ✅ |
| Block / unblock | `blockContact` / `unblockContact` | ✅ |
| SecureJoin | `secureJoin` / `generateSecureJoinURI` | ✅ |
| `checkQr` | `checkQr` | ✅ |
| QR SVG | `createQrSvg` (placeholder) | 🟡 |
| vCard | — | ❌ |

## Webxdc / location / calls

| Core | madcore-web | Status |
|------|-------------|--------|
| Webxdc send / status | `sendWebxdc` / `sendWebxdcStatusUpdate` | ✅ |
| Webxdc realtime | — | ❌ |
| Location stream | `sendLocationsToChat` / `setLocation` | ✅ |
| VoIP / ICE | `placeOutgoingCall` + signaling (`lib/calls.ts`) | 🟡 |
| Native media path | inject `RTCPeerConnection` in app | 🌐 |

## Backup & multi-device

| Core | madcore-web | Status |
|------|-------------|--------|
| `exportBackup` / `importBackup` | `exportBackup` / `importBackup` | ✅ |
| Backup QR second device | partial via `checkQr` backup kinds | 🟡 |
| IMAP folder watch | `setWatchedMailboxes` / multi-mailbox fetch | 🟡 |
| IMAP METADATA config | local `setConfig` / `getConfig` | 🟡 |

## Explicitly not ported

| Core area | Reason |
|-----------|--------|
| IMAP/SMTP engine lifecycle | Replaced by chatmail WS relay |
| Blob filesystem / sticker packs FS | Browser base64 / OPFS later |
| FCM/APNs push | Web Push via `setPushToken` + SW |
| Stock strings i18n engine | App-owned |

## Capability probe

```ts
const caps = account.capabilities();
// { calls: 'webrtc'|'signaling-only', webxdc: true, location: true, multiRelay: true }
```
149 changes: 149 additions & 0 deletions lib/backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* lib/backup.ts — Export / import account backup as encrypted JSON.
*
* Format (version 1):
* { v: 1, createdAt, account, contacts, chats, messages, config? }
* Optional passphrase: AES-GCM via Web Crypto (raw base64 envelope).
*/

import type { StoredAccount, StoredChat, StoredContact, StoredMessage } from '../store';

export const BACKUP_VERSION = 1;

export interface BackupPayload {
v: number;
createdAt: number;
account: StoredAccount;
contacts: StoredContact[];
chats: StoredChat[];
messages: StoredMessage[];
config?: Record<string, string>;
/** Known peer keys: email → armored public key */
knownKeys?: Record<string, string>;
}

export interface EncryptedBackupBlob {
v: number;
enc: true;
salt: string; // base64
iv: string; // base64
data: string; // base64 ciphertext
}

function b64encode(buf: ArrayBuffer | Uint8Array): string {
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
}

function b64decode(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}

async function deriveKey(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
'raw',
enc.encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt as BufferSource, iterations: 100_000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}

/** Serialize backup as plain JSON string */
export function serializeBackup(payload: BackupPayload): string {
return JSON.stringify({ ...payload, v: BACKUP_VERSION });
}

/** Parse plain JSON backup */
export function parseBackup(json: string): BackupPayload {
const obj = JSON.parse(json);
if (!obj || obj.v !== BACKUP_VERSION) {
throw new Error(`Unsupported backup version: ${obj?.v}`);
}
if (!obj.account?.email) {
throw new Error('Invalid backup: missing account');
}
return obj as BackupPayload;
}

/** Encrypt backup JSON with passphrase (AES-GCM) */
export async function encryptBackup(json: string, passphrase: string): Promise<EncryptedBackupBlob> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(passphrase, salt);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(json),
);
return {
v: BACKUP_VERSION,
enc: true,
salt: b64encode(salt),
iv: b64encode(iv),
data: b64encode(ct),
};
}

/** Decrypt passphrase-protected backup blob → JSON string */
export async function decryptBackup(blob: EncryptedBackupBlob, passphrase: string): Promise<string> {
if (!blob.enc) throw new Error('Not an encrypted backup');
const salt = b64decode(blob.salt);
const iv = b64decode(blob.iv);
const key = await deriveKey(passphrase, salt);
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
key,
b64decode(blob.data) as BufferSource,
);
return new TextDecoder().decode(pt);
}

/**
* Accept either plain JSON string, parsed BackupPayload, or EncryptedBackupBlob.
* Returns BackupPayload.
*/
export async function loadBackup(
input: string | BackupPayload | EncryptedBackupBlob,
passphrase?: string,
): Promise<BackupPayload> {
if (typeof input === 'object' && input !== null && 'account' in input && 'v' in input && !('enc' in input)) {
return input as BackupPayload;
}

let json: string;
if (typeof input === 'string') {
const trimmed = input.trim();
if (trimmed.startsWith('{')) {
const obj = JSON.parse(trimmed);
if (obj.enc === true) {
if (!passphrase) throw new Error('Passphrase required for encrypted backup');
json = await decryptBackup(obj as EncryptedBackupBlob, passphrase);
} else {
json = trimmed;
}
} else {
throw new Error('Backup must be JSON');
}
} else if (typeof input === 'object' && (input as EncryptedBackupBlob).enc) {
if (!passphrase) throw new Error('Passphrase required for encrypted backup');
json = await decryptBackup(input as EncryptedBackupBlob, passphrase);
} else {
throw new Error('Invalid backup input');
}

return parseBackup(json);
}
Loading