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:
Matthieu MALVACHE
2026-02-16 17:25:20 +01:00
committed by Matthieu MALVACHE
parent 5d60fe5186
commit 8a5bc9b88d
27 changed files with 2927 additions and 968 deletions
+211 -3
View File
@@ -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);
+82
View File
@@ -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;
};
};
}