mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: start a new message in a shared folder from that mailbox's identity
A new message composed while a shared or group folder was open always defaulted to the user's own identity: `findComposeIdentityId` was ported from the webmail but never called. The composer now reads the folder that was open when it was started and, for a shared folder whose account name is an address the user has an identity for, preselects that identity (webmail 129f545a, a971d5a1). Own folders resolve to nothing and keep the preferred identity. Unlike the webmail this isn't gated on "Reply from received address": the webmail gates it only because it falls back to the login address, which would override a chosen default identity (#507), and this path has no such fallback.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findReplyIdentityId, findDraftIdentityId, resolveReplyFrom, findComposeIdentityId, resolveReplyIdentity,
|
||||
resolveComposeAccountEmail,
|
||||
} from '../reply-identity';
|
||||
import type { Identity } from '../../api/types';
|
||||
|
||||
@@ -136,3 +137,32 @@ describe('resolveReplyIdentity', () => {
|
||||
expect(resolveReplyIdentity(identities, { to: [{ email: 'x@other.com' }] }, { ownEmails, catchAll: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveComposeAccountEmail', () => {
|
||||
const mailboxes = [
|
||||
{ id: 'a-inbox' },
|
||||
{ id: 'owner-x:x-inbox', isShared: true, accountName: 'team@shared.example' },
|
||||
{ id: 'owner-y:y-inbox', isShared: true, accountName: 'Support Team' },
|
||||
];
|
||||
|
||||
it('uses the shared folder owner address when a shared folder is open', () => {
|
||||
expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox', 'me@primary.example')).toBe('team@shared.example');
|
||||
expect(resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox')).toBe('team@shared.example');
|
||||
});
|
||||
|
||||
it('keeps the fallback for an own folder, a non-address account name or no selection', () => {
|
||||
expect(resolveComposeAccountEmail(mailboxes, 'a-inbox', 'me@primary.example')).toBe('me@primary.example');
|
||||
expect(resolveComposeAccountEmail(mailboxes, 'owner-y:y-inbox', 'me@primary.example')).toBe('me@primary.example');
|
||||
expect(resolveComposeAccountEmail(mailboxes, null, 'me@primary.example')).toBe('me@primary.example');
|
||||
expect(resolveComposeAccountEmail(mailboxes, 'a-inbox')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preselects the shared identity end-to-end', () => {
|
||||
const ids: Identity[] = [
|
||||
{ id: 'primary', name: 'D R', email: 'me@primary.example', mayDelete: false },
|
||||
{ id: 'shared', name: 'D R', email: 'team@shared.example', mayDelete: false },
|
||||
];
|
||||
expect(findComposeIdentityId(ids, resolveComposeAccountEmail(mailboxes, 'owner-x:x-inbox'))).toBe('shared');
|
||||
expect(findComposeIdentityId(ids, resolveComposeAccountEmail(mailboxes, 'a-inbox'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,6 +130,33 @@ export function findComposeIdentityId(
|
||||
return baseIdentity?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Address whose identity `findComposeIdentityId` should preselect, based on the
|
||||
* mailbox currently open.
|
||||
*
|
||||
* A shared/group folder has no login of its own - it is reached through the
|
||||
* user's - so the owner of the open folder is the right default sender there.
|
||||
* `accountName` is the JMAP `Account.name`, which RFC 8620 describes as "e.g.,
|
||||
* the email address of the account"; a server that puts a human label there
|
||||
* produces no match (the `@` test keeps that explicit) and the caller keeps
|
||||
* its default identity.
|
||||
*/
|
||||
export function resolveComposeAccountEmail(
|
||||
mailboxes: Array<{ id: string; isShared?: boolean; accountName?: string }>,
|
||||
selectedMailbox?: string | null,
|
||||
activeAccountEmail?: string | null,
|
||||
): string | undefined {
|
||||
const current = selectedMailbox
|
||||
? mailboxes.find((mailbox) => mailbox.id === selectedMailbox)
|
||||
: undefined;
|
||||
|
||||
if (current?.isShared && current.accountName?.includes('@')) {
|
||||
return current.accountName;
|
||||
}
|
||||
|
||||
return activeAccountEmail ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the identity a draft was composed with from its saved From. A draft
|
||||
* stores only the From address+name, not an identityId, so when two identities
|
||||
|
||||
@@ -54,7 +54,9 @@ import { buildQuoteHeader, formatQuoteDate, type QuoteHeaderLabels } from '../li
|
||||
import {
|
||||
isValidEmail, splitPastedRecipients, expandRecipients, parseRecipient, type Recipient as ParsedRecipient,
|
||||
} from '../lib/recipients';
|
||||
import { findDraftIdentityId, resolveReplyIdentity } from '../lib/reply-identity';
|
||||
import {
|
||||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||||
} from '../lib/reply-identity';
|
||||
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||||
import {
|
||||
hasSignature, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
|
||||
@@ -834,12 +836,22 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// A new message started while a shared folder is open sends as that
|
||||
// mailbox's owner when the user has an identity for it (webmail 129f545a).
|
||||
// Read once: it is the folder the message was started from. Own folders
|
||||
// resolve to nothing, so they keep the preferred identity below.
|
||||
const composeFromAccountEmail = React.useMemo(() => {
|
||||
const { mailboxes: all, currentMailboxId } = useEmailStore.getState();
|
||||
return resolveComposeAccountEmail(all, currentMailboxId);
|
||||
}, []);
|
||||
|
||||
// Choose the identity once we know both the loaded identities and the
|
||||
// compose context: a re-opened draft keeps the identity it was written
|
||||
// with; a reply or forward sends from the own identity the original was
|
||||
// delivered to (a reply to our own message, from the one that sent it);
|
||||
// with auto-select on, a reply to a catch-all alias on an owned domain
|
||||
// takes that address as a From override; otherwise the preferred ("Use as
|
||||
// with; a new message in a shared folder uses that folder's identity; a
|
||||
// reply or forward sends from the own identity the original was delivered
|
||||
// to (a reply to our own message, from the one that sent it); with
|
||||
// auto-select on, a reply to a catch-all alias on an owned domain takes
|
||||
// that address as a From override; otherwise the preferred ("Use as
|
||||
// default") identity, else the one matching the active account.
|
||||
React.useEffect(() => {
|
||||
if (selectedIdentityId || identities.length === 0) return;
|
||||
@@ -856,6 +868,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
setSelectedIdentityId(matched ?? defaultIdentity.id);
|
||||
return;
|
||||
}
|
||||
if (!replyTo) {
|
||||
const composeId = findComposeIdentityId(identities, composeFromAccountEmail);
|
||||
if (composeId) {
|
||||
setSelectedIdentityId(composeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (replyTo) {
|
||||
// The catch-all From rewrite is opt-in and never used on a forward.
|
||||
const resolved = resolveReplyIdentity(identities, replyTo, {
|
||||
|
||||
Reference in New Issue
Block a user