feat(identities): Use JMAP identities for email sender address

- Add getIdentities() method to JMAP client
- Fetch and store identities in auth store on login
- Add From selector in composer (dropdown for multiple identities)
- Pass identity email/ID through send flow instead of using username
- Add translations for From field (en/fr)

This allows proper sender address handling for LDAP and other auth
backends where username differs from email address.

Closes #5
This commit is contained in:
Matthieu MALVACHE
2026-01-08 01:19:09 +01:00
parent 60aff695fe
commit e50404cf42
7 changed files with 102 additions and 24 deletions
+3 -1
View File
@@ -353,11 +353,13 @@ export default function Home() {
subject: string;
body: string;
draftId?: string;
fromEmail?: string;
identityId?: string;
}) => {
if (!client) return;
try {
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId);
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId, data.fromEmail, data.identityId);
setShowComposer(false);
} catch (error) {
console.error("Failed to send email:", error);
+36 -2
View File
@@ -16,6 +16,8 @@ interface EmailComposerProps {
subject: string;
body: string;
draftId?: string;
fromEmail?: string;
identityId?: string;
}) => void;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
@@ -97,8 +99,9 @@ export function EmailComposer({
const lastSavedDataRef = useRef<string>("");
const [attachments, setAttachments] = useState<Array<{ file: File; blobId?: string; uploading?: boolean; error?: boolean }>>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const { client } = useAuthStore();
const { client, identities, primaryIdentity } = useAuthStore();
// Handle file selection
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -255,6 +258,11 @@ export function EmailComposer({
}
}
// Get the selected identity or primary identity
const currentIdentity = selectedIdentityId
? identities.find(id => id.id === selectedIdentityId)
: primaryIdentity;
onSend?.({
to: toAddresses,
cc: ccAddresses,
@@ -262,6 +270,8 @@ export function EmailComposer({
subject,
body,
draftId: finalDraftId || undefined,
fromEmail: currentIdentity?.email,
identityId: currentIdentity?.id,
});
// Reset form
@@ -328,8 +338,32 @@ export function EmailComposer({
<div className="flex-1 flex flex-col">
<div className="space-y-2 px-4 py-3 border-b">
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">To:</span>
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
{identity.name ? `${identity.name} <${identity.email}>` : identity.email}
</option>
))}
</select>
) : (
<span className="text-sm text-foreground">
{primaryIdentity?.name
? `${primaryIdentity.name} <${primaryIdentity.email}>`
: primaryIdentity?.email || ''}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
<Input
type="email"
placeholder="Recipient email addresses (comma separated)"
+45 -18
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -900,6 +900,26 @@ export class JMAPClient {
}
}
async getIdentities(): Promise<Identity[]> {
try {
const response = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (response.methodResponses[0][1].list || []) as Identity[];
return identities;
}
return [];
} catch (error) {
console.error('Failed to get identities:', error);
return [];
}
}
async createDraft(
to: string[],
subject: string,
@@ -907,7 +927,8 @@ export class JMAPClient {
cc?: string[],
bcc?: string[],
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>,
fromEmail?: string
): Promise<string> {
// Find the drafts mailbox
const mailboxes = await this.getMailboxes();
@@ -933,7 +954,7 @@ export class JMAPClient {
attachments?: { blobId: string; type: string; name: string; disposition: string }[];
}
const emailData: EmailDraft = {
from: [{ email: this.username }],
from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
@@ -1025,7 +1046,9 @@ export class JMAPClient {
body: string,
cc?: string[],
bcc?: string[],
draftId?: string
draftId?: string,
fromEmail?: string,
selectedIdentityId?: string
): Promise<void> {
const emailId = draftId || `draft-${Date.now()}`;
@@ -1037,22 +1060,26 @@ export class JMAPClient {
throw new Error('No sent mailbox found');
}
// Get the identity ID - fetch identities from server
const identityResponse = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
// Use provided identity ID or fetch from server as fallback
let identityId = selectedIdentityId;
let identityId = this.accountId; // fallback
if (!identityId) {
const identityResponse = await this.request([
["Identity/get", {
accountId: this.accountId,
}, "0"]
]);
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
identityId = this.accountId; // fallback
if (identities.length > 0) {
// Use the first identity (or find one matching the username)
const matchingIdentity = identities.find((id) => id.email === this.username);
identityId = matchingIdentity?.id || identities[0].id;
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
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;
}
}
}
@@ -1085,7 +1112,7 @@ export class JMAPClient {
accountId: this.accountId,
create: {
[emailId]: {
from: [{ email: this.username }],
from: [{ email: fromEmail || this.username }],
to: to.map(email => ({ email })),
cc: cc?.map(email => ({ email })),
bcc: bcc?.map(email => ({ email })),
+1
View File
@@ -132,6 +132,7 @@
"reply_to": "Reply",
"reply_all_to": "Reply All",
"forward_message": "Forward",
"from": "From",
"to": "To",
"cc": "CC",
"bcc": "BCC",
+1
View File
@@ -132,6 +132,7 @@
"reply_to": "Répondre",
"reply_all_to": "Répondre à tous",
"forward_message": "Transférer",
"from": "De",
"to": "À",
"cc": "CC",
"bcc": "CCI",
+13
View File
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
isAuthenticated: boolean;
@@ -10,6 +11,8 @@ interface AuthState {
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
identities: Identity[];
primaryIdentity: Identity | null;
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
logout: () => void;
@@ -26,6 +29,8 @@ export const useAuthStore = create<AuthState>()(
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
login: async (serverUrl, username, password) => {
set({ isLoading: true, error: null });
@@ -37,6 +42,10 @@ export const useAuthStore = create<AuthState>()(
// Try to connect
await client.connect();
// Fetch identities from the server
const identities = await client.getIdentities();
const primaryIdentity = identities.length > 0 ? identities[0] : null;
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
@@ -44,6 +53,8 @@ export const useAuthStore = create<AuthState>()(
serverUrl,
username,
client,
identities,
primaryIdentity,
error: null,
});
@@ -87,6 +98,8 @@ export const useAuthStore = create<AuthState>()(
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
error: null,
});
+3 -3
View File
@@ -46,7 +46,7 @@ interface EmailStore {
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], draftId?: string, fromEmail?: string, identityId?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
@@ -282,10 +282,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
sendEmail: async (client, to, subject, body, cc, bcc, draftId) => {
sendEmail: async (client, to, subject, body, cc, bcc, draftId, fromEmail, identityId) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, draftId);
await client.sendEmail(to, subject, body, cc, bcc, draftId, fromEmail, identityId);
// Refresh emails after sending
await get().fetchEmails(client);
set({ isLoading: false });