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:
@@ -75,3 +75,35 @@ export function hasRichFormatting(html: string): boolean {
|
||||
'h1, h2, h3, h4, h5, h6, ul, ol, blockquote'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse empty containers left behind when external images are blocked.
|
||||
* Walks up from each blocked img to find the nearest table cell or wrapper div
|
||||
* and hides it if it contains no meaningful visible content.
|
||||
*/
|
||||
export function collapseBlockedImageContainers(html: string): string {
|
||||
const doc = parseHtmlSafely(html);
|
||||
const blockedImages = doc.querySelectorAll('img[data-blocked-src]');
|
||||
|
||||
blockedImages.forEach((img) => {
|
||||
let el: HTMLElement | null = img.parentElement;
|
||||
while (el && el !== doc.body) {
|
||||
if (el.tagName === 'TD' || el.tagName === 'TH' || (el.tagName === 'DIV' && el.parentElement?.tagName === 'TD')) {
|
||||
const hasVisibleText = el.textContent?.replace(/[\s\u00A0]+/g, '').trim();
|
||||
const hasVisibleMedia = el.querySelector('img:not([data-blocked-src]), video, canvas');
|
||||
const hasLinks = el.querySelector('a[href]');
|
||||
if (!hasVisibleText && !hasVisibleMedia && !hasLinks) {
|
||||
el.style.display = 'none';
|
||||
el.style.height = '0';
|
||||
el.style.padding = '0';
|
||||
el.style.overflow = 'hidden';
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (el.tagName === 'TABLE' || el.tagName === 'TR') break;
|
||||
el = el.parentElement;
|
||||
}
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
+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