feat: expand internationalization and add identity management

This release significantly expands internationalization support and adds comprehensive identity management features.

Internationalization (i18n):
- Add 5 new languages: Spanish, Italian, German, Dutch, Portuguese
- Expand from 3 to 8 total supported languages
- Redesign language switcher for better scalability (dropdown UI)
- Complete translations for all features across all languages

Identity Management:
- Multiple sender identities with per-identity signatures
- Sub-addressing support (user+tag@domain.com)
- Context-aware tag suggestions for sub-addresses
- Identity badges in email viewer and list
- Full CRUD operations for managing identities

Newsletter Management:
- RFC 2369 List-Unsubscribe support (one-click unsubscribe)
- HTTP and mailto unsubscribe methods
- Security validation prevents XSS attacks
- Two-step confirmation with persistent dismissal

Security & Accessibility:
- Dark mode email readability (intelligent color transformation)
- WCAG 2.0 Level AA color contrast compliance
- Comprehensive XSS prevention with validation utilities
- Unit test coverage for security-critical code (57 validation tests)

Testing:
- Add unit tests for validation utilities
- Add unit tests for email sanitization
- Add unit tests for color transformation
- Full test coverage for XSS attack vectors
This commit is contained in:
Matthieu MALVACHE
2026-01-08 22:15:32 +01:00
committed by Matthieu MALVACHE
parent 0d68851b63
commit 5d273e2109
49 changed files with 11018 additions and 432 deletions
+182 -14
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -753,6 +753,58 @@ export class JMAPClient {
]);
}
/**
* Move email to Junk folder
*/
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes();
const junkMailbox = mailboxes.find(m => {
if (accountId) {
return m.role === 'junk' && m.accountId === accountId;
}
return m.role === 'junk' && !m.isShared;
});
if (!junkMailbox) {
throw new Error('Junk mailbox not found');
}
const mailboxId = accountId && junkMailbox.originalId
? junkMailbox.originalId
: junkMailbox.id;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [mailboxId]: true },
},
},
}, "0"],
]);
}
/**
* Undo spam - move email back from Junk to original mailbox
*/
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [originalMailboxId]: true },
},
},
}, "0"],
]);
}
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
// Use provided accountId or fallback to primary account
@@ -920,15 +972,134 @@ export class JMAPClient {
}
}
async createIdentity(
name: string,
email: string,
replyTo?: EmailAddress[],
bcc?: EmailAddress[],
textSignature?: string,
htmlSignature?: string
): Promise<Identity> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
create: {
"new-identity": {
name,
email,
replyTo,
bcc,
textSignature,
htmlSignature,
}
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notCreated?.["new-identity"]) {
const error = result.notCreated["new-identity"];
if (error.type === "forbidden") {
throw new Error("You are not authorized to send from this email address");
}
throw new Error(error.description || "Failed to create identity");
}
// Return created identity
const createdId = result.created?.["new-identity"]?.id;
if (createdId) {
// Fetch the full identity object
const identities = await this.getIdentities();
const identity = identities.find(i => i.id === createdId);
if (identity) return identity;
}
}
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
}
async updateIdentity(
identityId: string,
updates: {
name?: string;
replyTo?: EmailAddress[];
bcc?: EmailAddress[];
textSignature?: string;
htmlSignature?: string;
}
): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
update: {
[identityId]: updates
}
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notUpdated?.[identityId]) {
const error = result.notUpdated[identityId];
if (error.type === "notFound") {
throw new Error("Identity not found (may have been deleted)");
}
if (error.type === "forbidden") {
throw new Error("You are not authorized to modify this identity");
}
throw new Error(error.description || "Failed to update identity");
}
return;
}
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
}
async deleteIdentity(identityId: string): Promise<void> {
const response = await this.request([
["Identity/set", {
accountId: this.accountId,
destroy: [identityId]
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
const result = response.methodResponses[0][1];
// Check for errors
if (result.notDestroyed?.[identityId]) {
const error = result.notDestroyed[identityId];
if (error.type === "forbidden") {
throw new Error("This identity cannot be deleted");
}
if (error.type === "notFound") {
throw new Error("Identity not found (may already be deleted)");
}
throw new Error(error.description || "Failed to delete identity");
}
return;
}
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
}
async createDraft(
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
fromEmail?: string
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
): Promise<string> {
// Find the drafts mailbox
const mailboxes = await this.getMailboxes();
@@ -1013,8 +1184,6 @@ export class JMAPClient {
const response = await this.request(methodCalls);
console.log('Draft save response:', JSON.stringify(response, null, 2));
// If we're updating (destroy + create), check the second response
// Otherwise check the first response
const responseIndex = draftId ? 1 : 0;
@@ -1031,7 +1200,6 @@ export class JMAPClient {
}
if (result.created?.[emailId]) {
console.log('Draft created successfully:', result.created[emailId].id);
return result.created[emailId].id;
}
}
@@ -1046,9 +1214,9 @@ export class JMAPClient {
body: string,
cc?: string[],
bcc?: string[],
draftId?: string,
identityId?: string,
fromEmail?: string,
selectedIdentityId?: string
draftId?: string
): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`;
@@ -1061,16 +1229,16 @@ export class JMAPClient {
}
// Use provided identity ID or fetch from server as fallback
let identityId = selectedIdentityId;
let finalIdentityId = identityId;
if (!identityId) {
if (!finalIdentityId) {
const identityResponse = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
identityId = this.accountId; // fallback
finalIdentityId = this.accountId; // fallback
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
@@ -1078,7 +1246,7 @@ export class JMAPClient {
if (identities.length > 0) {
// Use the first identity (or find one matching the fromEmail/username)
const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username));
identityId = matchingIdentity?.id || identities[0].id;
finalIdentityId = matchingIdentity?.id || identities[0].id;
}
}
}
@@ -1103,7 +1271,7 @@ export class JMAPClient {
create: {
"1": {
emailId: draftId,
identityId: identityId,
identityId: finalIdentityId,
},
},
}, "1"]);
@@ -1137,7 +1305,7 @@ export class JMAPClient {
create: {
"1": {
emailId: `#${emailId}`,
identityId: identityId,
identityId: finalIdentityId,
},
},
}, "1"]);