feat(compose): From override + catch-all auto-reply (fixes #246)

Adds an Override toggle in the composer's From row. When enabled, name
and address become free-text inputs. Mail is still submitted through the
selected identity, but the outgoing message's From: header — and the
SMTP envelope MAIL FROM when different — is set from the override.

The existing "Auto-select Reply Address" setting is extended: if the
incoming message was addressed to an alias on a domain that matches one
of your identities but isn't itself an identity (classic domain catch-
all), it now auto-enables Override and pre-fills the alias. Quick reply
honors the same resolution. The setting is relabeled to reflect the
broader behavior.

JMAP: client.sendEmail gains an optional envelopeMailFrom; when set, the
EmailSubmission includes an explicit envelope with that mailFrom and the
to/cc/bcc as rcptTo so header-From and envelope can diverge (JMAP §7.3).

S/MIME: override is incompatible with sign/encrypt and is refused with a
clear error — signing a different visible From from the identity's
certificate Subject would produce messages clients reject.

Tests: resolveReplyFrom covers exact match, sub-address stripping,
catch-all detection, identity preference, and foreign-domain null.
This commit is contained in:
Augustin Marcin
2026-05-11 12:13:47 +02:00
committed by Linus Rath
parent 2d7e24b513
commit b0640c9ecc
8 changed files with 297 additions and 40 deletions
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId } from '../reply-identity';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -49,4 +49,39 @@ describe('findReplyIdentityId', () => {
expect(selected).toBeNull();
});
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
.toEqual({ identityId: 'secondary' });
});
it('strips +tag before matching identities', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry+news@primary.com' }] }))
.toEqual({ identityId: 'primary' });
});
it('surfaces catch-all override when recipient is on an identity domain but not an identity', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'stripe@primary.com', name: 'Stripe' }],
});
expect(result).toEqual({
identityId: 'primary',
overrideEmail: 'stripe@primary.com',
overrideName: 'Stripe',
});
});
it('prefers identity match over catch-all override when both appear', () => {
const result = resolveReplyFrom(identities, {
to: [{ email: 'harry@primary.com' }, { email: 'stripe@primary.com' }],
});
expect(result).toEqual({ identityId: 'primary' });
});
it('returns null when recipients are on foreign domains', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'nobody@elsewhere.com' }] }))
.toBeNull();
});
});