mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-10-06 08:00:45 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 })),
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"reply_to": "Reply",
|
||||
"reply_all_to": "Reply All",
|
||||
"forward_message": "Forward",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user