feat: add S/MIME store for managing key records and public certificates

- Implemented Zustand store for S/MIME functionality, including state management for key records and public certificates.
- Added methods for importing PKCS#12 files and public certificates, binding identities to keys, and managing unlocked keys.
- Introduced session storage for remembering unlocked keys across sessions.
- Enhanced error handling and loading states during data operations.
This commit is contained in:
Linus Rath
2026-03-17 20:14:01 +01:00
parent 7c5785e9e8
commit de35d1d8e8
45 changed files with 24687 additions and 64 deletions
+118 -1
View File
@@ -616,7 +616,7 @@ export class JMAPClient {
"receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo",
"subject", "preview", "textBody", "htmlBody", "bodyValues",
"hasAttachment", "attachments", "messageId", "inReplyTo",
"references", "headers",
"references", "headers", "bodyStructure", "blobId",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
@@ -2927,4 +2927,121 @@ export class JMAPClient {
setLastStates(states: AccountStates): void {
this.lastStates = { ...states };
}
// ── S/MIME raw-email helpers ─────────────────────────────────────
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
}
return response.arrayBuffer();
}
/** Import a raw MIME message blob into the account. */
async importRawEmail(
blob: Blob,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
): Promise<string> {
// First upload the blob
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
// Then import via Email/import
const response = await this.request([
['Email/import', {
accountId: this.accountId,
emails: {
'smime-import': {
blobId,
mailboxIds,
keywords: keywords ?? { '$seen': true },
},
},
}, '0'],
]);
const importResult = response.methodResponses?.[0]?.[1];
if (importResult?.notCreated?.['smime-import']) {
const err = importResult.notCreated['smime-import'];
throw new Error(err.description || err.type || 'Failed to import email');
}
const emailId = importResult?.created?.['smime-import']?.id;
if (!emailId) {
throw new Error('Email import succeeded but no ID returned');
}
return emailId;
}
/** Submit an already-imported email for delivery. */
async submitEmail(emailId: string, identityId: string): Promise<void> {
const response = await this.request([
['EmailSubmission/set', {
accountId: this.accountId,
create: { 'smime-submit': { emailId, identityId } },
}, '0'],
]);
const result = response.methodResponses?.[0]?.[1];
if (result?.notCreated?.['smime-submit']) {
const err = result.notCreated['smime-submit'];
throw new Error(err.description || err.type || 'Failed to submit email');
}
}
/**
* Import a raw S/MIME message, move it to the Sent mailbox, and submit it.
* Encapsulates the full import → update → submit flow.
*/
async sendRawEmail(
blob: Blob,
identityId: string,
sentMailboxId: string,
draftMailboxId?: string,
): Promise<void> {
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
// Import into Sent, mark as seen, and submit — all in one request
const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', {
accountId: this.accountId,
emails: {
'raw-import': {
blobId,
mailboxIds: { [sentMailboxId]: true },
keywords: { '$seen': true },
},
},
}, '0'],
['EmailSubmission/set', {
accountId: this.accountId,
create: {
'raw-submit': {
emailId: '#raw-import',
identityId,
},
},
}, '1'],
];
const response = await this.request(methodCalls);
// Check for errors
for (const [methodName, result] of response.methodResponses ?? []) {
if (methodName.endsWith('/error')) {
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
}
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
if (r.notCreated) {
const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
}
}
}
}
+3
View File
@@ -36,6 +36,9 @@ export interface Email {
verdict: string;
explanation: string;
};
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
}
export interface AuthenticationResults {