mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge pull request #1213 from shukiv/fix/draft-body-from-body-parts
fix: take a re-opened draft's body from its body parts only
This commit is contained in:
@@ -85,6 +85,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { MvListToolbar } from "@/components/email/mv-list-toolbar";
|
||||
import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { getDraftTextBody } from '@/lib/email-body-selection';
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIdentitySync } from "@/hooks/use-identity-sync";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
@@ -2087,9 +2088,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
draft = await emailHooks.onBeforeEditDraft.transform(draft);
|
||||
|
||||
const bodyText = draft.bodyValues
|
||||
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
|
||||
: '';
|
||||
// Only the parts the server lists as the body: bodyValues also carries
|
||||
// every text/* ATTACHMENT, which used to be joined in as the message.
|
||||
const bodyText = getDraftTextBody(draft);
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = draft.htmlBody?.[0];
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { buildForwardAsAttachmentPayload } from "@/lib/forward-as-attachment";
|
||||
import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getDraftTextBody } from '@/lib/email-body-selection';
|
||||
|
||||
interface ProEmailTabBodyProps {
|
||||
tabId: string;
|
||||
@@ -319,9 +320,9 @@ export function ProEmailView({ emailId, client: clientOverride, accountId, onLoa
|
||||
const handleEditDraft = useCallback(() => {
|
||||
if (!email) return;
|
||||
|
||||
const bodyText = email.bodyValues
|
||||
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
|
||||
: '';
|
||||
// Only the parts the server lists as the body: bodyValues also carries
|
||||
// every text/* ATTACHMENT, which used to be joined in as the message.
|
||||
const bodyText = getDraftTextBody(email);
|
||||
// A plain-text-only draft lists its text/plain part under htmlBody
|
||||
// (RFC 8621 § 4.1.4 fallback) - only treat it as HTML when it really is.
|
||||
const draftHtmlPart = email.htmlBody?.[0];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getRenderableHtmlBody } from '@/lib/email-body-selection';
|
||||
import { getRenderableHtmlBody, getDraftTextBody } from '@/lib/email-body-selection';
|
||||
|
||||
type PartialEmail = Parameters<typeof getRenderableHtmlBody>[0];
|
||||
|
||||
@@ -105,3 +105,45 @@ describe('getRenderableHtmlBody', () => {
|
||||
expect(getRenderableHtmlBody(email)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-opening a draft used to join every value in bodyValues into the
|
||||
* message. getEmail asks for fetchAllBodyValues, which per RFC 8621
|
||||
* covers every text/* part including attachments, so a draft carrying
|
||||
* only a .csv re-opened with the contents of that .csv as its text - and
|
||||
* the next save made that the real message.
|
||||
*/
|
||||
describe('getDraftTextBody', () => {
|
||||
type DraftEmail = Parameters<typeof getDraftTextBody>[0];
|
||||
|
||||
const draft = (textBody: { partId: string }[], values: Record<string, string>): DraftEmail => ({
|
||||
textBody: textBody as DraftEmail['textBody'],
|
||||
bodyValues: Object.fromEntries(
|
||||
Object.entries(values).map(([id, value]) => [id, { value, isEncodingProblem: false, isTruncated: false }]),
|
||||
) as DraftEmail['bodyValues'],
|
||||
});
|
||||
|
||||
it('takes the body from the parts listed as the body', () => {
|
||||
expect(getDraftTextBody(draft([{ partId: '1' }], { '1': 'hello' }))).toBe('hello');
|
||||
});
|
||||
|
||||
it('ignores a text attachment that is not part of the body', () => {
|
||||
// Part 2 is an attached text/csv: fetchAllBodyValues returns its value
|
||||
// too, but it is not in textBody and is not the message.
|
||||
const d = draft([{ partId: '1' }], { '1': 'see attached', '2': 'Date,Amount\n2026-08-30,12.00' });
|
||||
expect(getDraftTextBody(d)).toBe('see attached');
|
||||
});
|
||||
|
||||
it('is empty for a draft that has attachments and no body at all', () => {
|
||||
expect(getDraftTextBody(draft([], { '2': 'Date,Amount\n2026-08-30,12.00' }))).toBe('');
|
||||
});
|
||||
|
||||
it('joins several body parts', () => {
|
||||
expect(getDraftTextBody(draft([{ partId: '1' }, { partId: '2' }], { '1': 'one', '2': 'two' })))
|
||||
.toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('survives a part whose value the server did not return', () => {
|
||||
expect(getDraftTextBody(draft([{ partId: '1' }, { partId: '9' }], { '1': 'one' }))).toBe('one');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,3 +33,24 @@ export function getRenderableHtmlBody(
|
||||
|
||||
return htmlContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plain-text body of a draft being re-opened, taken from the parts the
|
||||
* server lists as the body and from nothing else.
|
||||
*
|
||||
* `bodyValues` is keyed by part id, and `getEmail` asks for
|
||||
* `fetchAllBodyValues`, which per RFC 8621 § 4.1.4 covers every `text/*`
|
||||
* part of the message - attachments included. Joining all of them put the
|
||||
* contents of an attached .csv or .txt into the composer as the message
|
||||
* text: a draft carrying only attachments and no body re-opened showing an
|
||||
* attachment's contents as what you had written, and saving or sending it
|
||||
* then made that the actual message.
|
||||
*/
|
||||
export function getDraftTextBody(
|
||||
email: Pick<Email, 'textBody' | 'bodyValues'>
|
||||
): string {
|
||||
return (email.textBody ?? [])
|
||||
.map(part => (part.partId ? email.bodyValues?.[part.partId]?.value : undefined))
|
||||
.filter((value): value is string => !!value)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user