feat: tell the user when a viewer action fails and when mail is sent

The viewer fired its store actions with `void` and went back to the
list, so a refused delete, archive, move, spam report, star, tag or
read change failed silently, and nothing confirmed a sent message.

- each viewer action now reports a failure as an error toast, and a
  refused star, tag or read change is taken back on screen
- the viewer mounts its own toast host, since the list's sits under it
- an immediate send from the composer or the quick reply box shows
  "Email sent"; a send held for the undo delay keeps its undo bar
  (webmail `ec67ab99`, `b03a0c1d`)
This commit is contained in:
Linus Rath
2026-09-24 19:06:55 +02:00
parent 33d26744b6
commit 9e95273c14
3 changed files with 49 additions and 11 deletions
+4 -1
View File
@@ -7,6 +7,7 @@ import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useEmailStore } from '../../stores/email-store';
import { toast } from '../../stores/toast-store';
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
import { jmapClient } from '../../api/jmap-client';
import { buildReplyRecipients } from '../../lib/reply-recipients';
@@ -78,7 +79,7 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
const quoted = original.split('\n').map((l) => `> ${l}`).join('\n');
const header = `${formatFullDateTime(emailDisplayDate(email), timeFormat, locale)}, ${from.name ? `${from.name} <${from.email}>` : from.email}:`;
const threading = computeReplyThreadingHeaders(email);
await sendEmail(
const result = await sendEmail(
{
from: [{ name: identity.name, email: identity.email }],
to: recipients.to.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })),
@@ -98,6 +99,8 @@ export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: P
} catch { /* the reply is out; the flag is cosmetic */ }
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
setText('');
// A reply held for the undo-send delay has not gone out yet (webmail b03a0c1d).
if (!result.scheduled) toast.success(t('notifications.email_sent', 'Email sent successfully'));
} catch (err) {
Alert.alert(t('email_composer.send_failed', 'Failed to send'), err instanceof Error ? err.message : String(err));
} finally {
+4
View File
@@ -33,6 +33,7 @@ import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { useAccountStore } from '../stores/account-store';
import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
import { type EmailTemplate } from '../stores/templates-store';
import { getIdentities } from '../api/identity';
import {
@@ -2032,6 +2033,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
delaySeconds: holdForSeconds,
createdAt: Date.now(),
});
} else if (!result.scheduled) {
// Held sends get the undo bar instead (webmail b03a0c1d).
toast.success(t('notifications.email_sent', 'Email sent successfully'));
}
allowLeaveRef.current = true;
navigation.goBack();
+41 -10
View File
@@ -19,7 +19,9 @@ import { MessageContent } from '../components/email/MessageContent';
import { ThreadMessageCard } from '../components/email/ThreadMessageCard';
import { QuickReplyBox } from '../components/email/QuickReplyBox';
import { AddressActionSheet } from '../components/email/AddressActionSheet';
import { ToastHost } from '../components/ToastHost';
import { useEmailStore } from '../stores/email-store';
import { toast } from '../stores/toast-store';
import {
useSettingsStore,
normalizeBottomQuickActions,
@@ -41,6 +43,14 @@ import type { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
// Actions finish after the viewer may already have gone back, so a failure is
// reported as a toast instead of vanishing with the promise (webmail
// `lib/email-action-toast.ts`).
function toastFailure(title: string, err: unknown): void {
console.warn('[viewer]', title, err);
toast.error(title, err instanceof Error ? err.message : undefined);
}
export default function EmailThreadScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -326,7 +336,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next[token]) delete next[token];
else next[token] = true;
updateLocalKeywords(email.id, next);
void patchKeywordsForEmails([email.id], { [token]: next[token] ?? null }, ownerAccountId);
patchKeywordsForEmails([email.id], { [token]: next[token] ?? null }, ownerAccountId).catch((err) => {
updateLocalKeywords(email.id, email.keywords);
toastFailure(t('notifications.tag_failed', 'Tagging failed'), err);
});
};
// Toggle the star on a specific message — used both by the toolbar (current
@@ -336,21 +349,28 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (next.$flagged) delete next.$flagged;
else next.$flagged = true;
updateLocalKeywords(target.id, next);
void patchKeywordsForEmails([target.id], { $flagged: next.$flagged ?? null }, ownerAccountId);
}, [updateLocalKeywords, ownerAccountId]);
patchKeywordsForEmails([target.id], { $flagged: next.$flagged ?? null }, ownerAccountId).catch((err) => {
updateLocalKeywords(target.id, target.keywords);
toastFailure(t('notifications.error_updating', 'Failed to update email'), err);
});
}, [updateLocalKeywords, ownerAccountId, t]);
const onToggleStar = () => { if (email) toggleStarFor(email); };
const onToggleUnread = () => {
if (!email) return;
const failed = (err: unknown) => {
updateLocalKeywords(email.id, email.keywords);
toastFailure(t('notifications.error_updating', 'Failed to update email'), err);
};
if (unread) {
void markSeen(email.id);
markSeen(email.id).catch(failed);
updateLocalKeywords(email.id, { ...email.keywords, $seen: true });
} else {
const next = { ...email.keywords };
delete next.$seen;
updateLocalKeywords(email.id, next);
void patchKeywordsForEmails([email.id], { $seen: null }, ownerAccountId);
patchKeywordsForEmails([email.id], { $seen: null }, ownerAccountId).catch(failed);
}
};
@@ -361,7 +381,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const performDelete = () => {
if (!email || !sourceMailbox || !trashMailbox) return;
void deleteEmail(email.id, trashMailbox.id, sourceMailbox.id, { email, accountId: ownerAccountId });
deleteEmail(email.id, trashMailbox.id, sourceMailbox.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_deleting', 'Failed to delete email'), err));
navigation.goBack();
};
@@ -394,7 +415,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const onArchive = () => {
if (!email || !canArchive) return;
void archiveEmailAction(email.id, { email, accountId: ownerAccountId });
archiveEmailAction(email.id, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.error_archiving', 'Failed to archive email'), err));
navigation.goBack();
};
@@ -405,8 +427,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!email || !canToggleSpam) return;
setMoreMenuOpen(false);
const viewed = { email, accountId: ownerAccountId };
if (isInJunk) void unmarkSpam([email.id], viewed);
else void markSpam([email.id], viewed);
if (isInJunk) {
unmarkSpam([email.id], viewed)
.catch((err) => toastFailure(t('email_viewer.spam.error_not_spam', 'Failed to restore email'), err));
} else {
markSpam([email.id], viewed)
.catch((err) => toastFailure(t('email_viewer.spam.error', 'Failed to report spam'), err));
}
navigation.goBack();
};
@@ -414,7 +441,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!email || !sourceMailbox || toId === sourceMailbox.id) return;
setMoveMenuOpen(false);
setMoreMenuOpen(false);
void moveToMailbox(email.id, sourceMailbox.id, toId, { email, accountId: ownerAccountId });
moveToMailbox(email.id, sourceMailbox.id, toId, { email, accountId: ownerAccountId })
.catch((err) => toastFailure(t('notifications.move_failed', 'Move failed'), err));
navigation.goBack();
};
@@ -786,6 +814,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
/>
<AddressActionSheet address={addressSheet} onClose={() => setAddressSheet(null)} />
{/* The list's host sits under this screen; failures in here need their own. */}
<ToastHost />
</SafeAreaView>
);
}