feat: add address book, fix email layout, update dependencies
- Address book with JMAP sync and local fallback (contacts CRUD, search/filter, composer autocomplete, i18n for 8 languages) - Fix email layout: remove horizontal scroll, left-side clipping, and empty spaces from blocked external images in newsletters - Update all dependencies to latest compatible versions - Expand i18n from 3 to 8 languages (added ES, IT, DE, NL, PT) - Upgrade Next.js to 16.1.6 for security patches
This commit is contained in:
+211
-3
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
|
||||
|
||||
// JMAP protocol types - these are intentionally flexible due to server variations
|
||||
interface JMAPSession {
|
||||
@@ -198,13 +198,13 @@ export class JMAPClient {
|
||||
this.capabilities = {};
|
||||
}
|
||||
|
||||
private async request(methodCalls: JMAPMethodCall[]): Promise<JMAPResponse> {
|
||||
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
|
||||
if (!this.apiUrl) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
||||
methodCalls: methodCalls,
|
||||
};
|
||||
|
||||
@@ -1481,6 +1481,214 @@ export class JMAPClient {
|
||||
return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
|
||||
}
|
||||
|
||||
supportsContacts(): boolean {
|
||||
return this.hasCapability("urn:ietf:params:jmap:contacts");
|
||||
}
|
||||
|
||||
getContactsAccountId(): string {
|
||||
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
|
||||
return contactsAccount || this.accountId;
|
||||
}
|
||||
|
||||
private contactUsing(): string[] {
|
||||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||||
}
|
||||
|
||||
async getAddressBooks(): Promise<AddressBook[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["AddressBook/get", { accountId }, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||||
return (response.methodResponses[0][1].list || []) as AddressBook[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get address books:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
|
||||
if (addressBookId) {
|
||||
methodCalls.push(
|
||||
["ContactCard/query", {
|
||||
accountId,
|
||||
filter: { inAddressBook: addressBookId },
|
||||
limit: 1000,
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
);
|
||||
} else {
|
||||
methodCalls.push(
|
||||
["ContactCard/query", { accountId, limit: 1000 }, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls, this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get contacts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getContact(contactId: string): Promise<ContactCard | null> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
const response = await this.request([
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
ids: [contactId],
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||||
const list = response.methodResponses[0][1].list || [];
|
||||
return list[0] || null;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get contact:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
// If no addressBookIds provided, get default address book
|
||||
let addressBookIds = contact.addressBookIds;
|
||||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||||
const books = await this.getAddressBooks();
|
||||
const defaultBook = books.find(b => b.isDefault) || books[0];
|
||||
if (defaultBook) {
|
||||
addressBookIds = { [defaultBook.id]: true };
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
create: {
|
||||
"new-contact": {
|
||||
...contact,
|
||||
addressBookIds,
|
||||
}
|
||||
}
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notCreated?.["new-contact"]) {
|
||||
const error = result.notCreated["new-contact"];
|
||||
throw new Error(error.description || "Failed to create contact");
|
||||
}
|
||||
|
||||
const createdId = result.created?.["new-contact"]?.id;
|
||||
if (createdId) {
|
||||
const created = await this.getContact(createdId);
|
||||
if (created) return created;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to create contact");
|
||||
}
|
||||
|
||||
async updateContact(contactId: string, updates: Partial<ContactCard>): Promise<void> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
update: {
|
||||
[contactId]: updates
|
||||
}
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notUpdated?.[contactId]) {
|
||||
const error = result.notUpdated[contactId];
|
||||
throw new Error(error.description || "Failed to update contact");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to update contact");
|
||||
}
|
||||
|
||||
async deleteContact(contactId: string): Promise<void> {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/set", {
|
||||
accountId,
|
||||
destroy: [contactId]
|
||||
}, "0"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
|
||||
if (result.notDestroyed?.[contactId]) {
|
||||
const error = result.notDestroyed[contactId];
|
||||
throw new Error(error.description || "Failed to delete contact");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Failed to delete contact");
|
||||
}
|
||||
|
||||
async searchContacts(query: string): Promise<ContactCard[]> {
|
||||
try {
|
||||
const accountId = this.getContactsAccountId();
|
||||
|
||||
const response = await this.request([
|
||||
["ContactCard/query", {
|
||||
accountId,
|
||||
filter: { text: query },
|
||||
limit: 50,
|
||||
}, "0"],
|
||||
["ContactCard/get", {
|
||||
accountId,
|
||||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||||
}, "1"]
|
||||
], this.contactUsing());
|
||||
|
||||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||||
return (response.methodResponses[1][1].list || []) as ContactCard[];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to search contacts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
||||
|
||||
|
||||
@@ -153,6 +153,86 @@ export interface Identity {
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
// RFC 9553 JSContact / RFC 9610 JMAP for Contacts
|
||||
|
||||
export interface ContactCard {
|
||||
id: string;
|
||||
uid?: string;
|
||||
addressBookIds: Record<string, boolean>;
|
||||
kind?: 'individual' | 'group' | 'org';
|
||||
name?: ContactName;
|
||||
emails?: Record<string, ContactEmail>;
|
||||
phones?: Record<string, ContactPhone>;
|
||||
organizations?: Record<string, ContactOrganization>;
|
||||
addresses?: Record<string, ContactAddress>;
|
||||
nicknames?: Record<string, ContactNickname>;
|
||||
notes?: Record<string, ContactNote>;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
export interface ContactName {
|
||||
components: NameComponent[];
|
||||
isOrdered?: boolean;
|
||||
}
|
||||
|
||||
export interface NameComponent {
|
||||
kind: 'given' | 'surname' | 'prefix' | 'suffix' | 'additional';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ContactEmail {
|
||||
address: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactPhone {
|
||||
number: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactOrganization {
|
||||
name?: string;
|
||||
units?: Array<{ name: string }>;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
street?: string;
|
||||
locality?: string;
|
||||
region?: string;
|
||||
postcode?: string;
|
||||
country?: string;
|
||||
contexts?: Record<string, boolean>;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContactNickname {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ContactNote {
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface AddressBook {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
sortOrder?: number;
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
}
|
||||
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EmailSubmission {
|
||||
id: string;
|
||||
identityId: string;
|
||||
@@ -187,6 +267,8 @@ export interface StateChange {
|
||||
EmailDelivery?: string;
|
||||
EmailSubmission?: string;
|
||||
Identity?: string;
|
||||
ContactCard?: string;
|
||||
AddressBook?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user