diff --git a/package-lock.json b/package-lock.json index 5c4a2aed..88b3bef8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "date-fns": "^4.1.0", "expo": "~54.0.33", "expo-camera": "~17.0.10", - "expo-clipboard": "^8.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", @@ -5238,9 +5238,9 @@ } }, "node_modules/expo-clipboard": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", - "integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz", + "integrity": "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==", "license": "MIT", "peerDependencies": { "expo": "*", diff --git a/package.json b/package.json index f26e4e2b..87b87d73 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "date-fns": "^4.1.0", "expo": "~54.0.33", "expo-camera": "~17.0.10", - "expo-clipboard": "^8.0.7", + "expo-clipboard": "~8.0.8", "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", diff --git a/src/api/email.ts b/src/api/email.ts index 11e7470a..5681fd73 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -6,6 +6,7 @@ import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './typ import { toWildcardQuery } from '../lib/search-utils'; import { sanitizeDisplayName } from '../lib/rfc5322-mailbox'; import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading'; +import { buildMdnMessage, type MdnOptions } from '../lib/mdn'; export const EMAIL_LIST_PROPERTIES = [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', @@ -989,6 +990,12 @@ export interface OutgoingEmail { // Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent. messageId?: string; requestReadReceipt?: boolean; + /** + * SMTP MAIL FROM when it must differ from the header From - a catch-all + * alias on an owned domain is sent through the identity's envelope while + * the visible From keeps the alias (webmail #246). + */ + envelopeMailFrom?: string; } export interface SendEmailResult { @@ -1128,19 +1135,19 @@ export async function sendEmail( const submissionCreate: Record = { emailId: '#draft', identityId }; // For a deferred send the envelope must be set explicitly so the HOLDFOR // mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the - // server derive mailFrom from the Identity, dropping our parameter). - if (holdForSeconds && holdForSeconds > 0) { + // server derive mailFrom from the Identity, dropping our parameter). An + // explicit envelope sender (catch-all From override) needs it as well. + const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0; + if (holdFor > 0 || email.envelopeMailFrom) { const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] .map((r) => r.email.trim()) .filter(Boolean) .map((address) => ({ email: address })); - submissionCreate.envelope = { - mailFrom: { - email: email.from[0]?.email, - parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) }, - }, - rcptTo, + const mailFrom: Record = { + email: email.envelopeMailFrom || email.from[0]?.email, }; + if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) }; + submissionCreate.envelope = { mailFrom, rcptTo }; } const submissionArgs: Record = { @@ -1402,3 +1409,72 @@ export async function rescheduleScheduledSend( const created = body.created?.replacement as { id?: string; sendAt?: string } | undefined; return { emailSubmissionId: created?.id, sendAt: created?.sendAt }; } + +/** + * Move a message back into Drafts (out of Sent) and re-flag it `$draft` so + * it can be edited and re-sent - used after cancelling a scheduled send for + * editing (webmail `restoreEmailToDraft`). + */ +export async function restoreEmailToDraft( + emailId: string, + draftsMailboxId: string, + sentMailboxId?: string, + accountIdOverride?: string, +): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; + const patch: Record = { + [mailboxPointer(draftsMailboxId)]: true, + [keywordPointer('$draft')]: true, + [keywordPointer('$seen')]: true, + }; + if (sentMailboxId && sentMailboxId !== draftsMailboxId) patch[mailboxPointer(sentMailboxId)] = null; + await emailSetBatched(accountId, { update: { [emailId]: patch } }, 'draft'); +} + +export interface SendReadReceiptOptions extends MdnOptions { + /** Identity the receipt is submitted through (its address is the MDN From). */ + identityId: string; + /** Where the sent receipt is filed. */ + sentMailboxId: string; + /** Submitting account (shared/group account); defaults to the primary. */ + accountId?: string; +} + +/** + * Send an RFC 8098 read receipt (MDN). JMAP has no MDN primitive, so the + * multipart/report is built client-side (`lib/mdn.ts`), uploaded as a blob, + * imported into Sent and submitted with an explicit envelope - mirrors the + * webmail's `client.sendReadReceipt`. The caller flags the original + * `$mdnsent` afterwards. + */ +export async function sendReadReceipt(opts: SendReadReceiptOptions): Promise { + const accountId = opts.accountId ?? jmapClient.accountId; + const raw = buildMdnMessage(opts); + const bytes = new TextEncoder().encode(raw); + // Lazy: blob.ts pulls in expo-file-system, which this module otherwise + // never needs (and which the node test environment cannot load). + const { uploadBytes } = await import('./blob'); + const upload = await uploadBytes(bytes, 'message/rfc822', accountId); + const emailId = await importEmailBlob(upload.blobId, opts.sentMailboxId, { $seen: true }, accountId); + const res = await jmapClient.request( + [ + ['EmailSubmission/set', { + accountId, + create: { + mdn: { + emailId, + identityId: opts.identityId, + envelope: { + mailFrom: { email: opts.fromEmail }, + rcptTo: [{ email: opts.to }], + }, + }, + }, + }, '0'], + ], + SUBMISSION_USING, + ); + const body = requireMethodResult(res, '0', 'EmailSubmission/set'); + assertSetResult(body, ['mdn'], 'read receipt'); + return emailId; +} diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 11141bb8..e8b0a8f0 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -21,7 +21,9 @@ export type RichTextCommand = | 'justifyRight' | 'removeFormat' | 'undo' - | 'redo'; + | 'redo' + // Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'. + | `foreColor:${string}`; export interface RichTextSelectionState { bold: boolean; @@ -50,6 +52,8 @@ export interface RichTextEditorHandle { insertLink(url: string, label?: string): void; unsetLink(): void; insertImage(src: string, cid?: string, alt?: string): void; + /** Insert an HTML fragment at the caret (template bodies, tables). */ + insertHtml(html: string): void; setHtml(html: string): void; /** * Read the live editor DOM content. Resolves with the current innerHTML or @@ -97,7 +101,17 @@ const RichTextEditor = React.forwardRef(function Ri [], ); + // Scripts injected before the page has run its inline script are lost, and + // the composer talks to the editor as soon as async data arrives (signature + // after the identities load, hydrated inline images). Queue until the page + // posts its first message, then replay in order. + const readyRef = React.useRef(false); + const queuedRef = React.useRef([]); const call = React.useCallback((script: string) => { + if (!readyRef.current) { + queuedRef.current.push(script); + return; + } webViewRef.current?.injectJavaScript(`${script}; true;`); }, []); @@ -124,7 +138,9 @@ const RichTextEditor = React.forwardRef(function Ri React.useImperativeHandle(ref, () => ({ exec: (command) => { - const [name, value] = command.split(':'); + const sep = command.indexOf(':'); + const name = sep === -1 ? command : command.slice(0, sep); + const value = sep === -1 ? undefined : command.slice(sep + 1); if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`); else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`); }, @@ -137,6 +153,9 @@ const RichTextEditor = React.forwardRef(function Ri insertImage: (src, cid, alt) => { call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`); }, + insertHtml: (html) => { + call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`); + }, setHtml: (next) => { call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`); }, @@ -162,6 +181,12 @@ const RichTextEditor = React.forwardRef(function Ri } catch { return; } + if (!readyRef.current) { + readyRef.current = true; + const queued = queuedRef.current; + queuedRef.current = []; + for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`); + } switch (data.type) { case 'change': onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : ''); diff --git a/src/components/TemplateSheet.tsx b/src/components/TemplateSheet.tsx new file mode 100644 index 00000000..3737552b --- /dev/null +++ b/src/components/TemplateSheet.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, TextInput, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { X, Star, FileText } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useSheetDrag } from '../lib/use-sheet-drag'; +import { useLocaleStore } from '../stores/locale-store'; +import { useTemplatesStore, type EmailTemplate } from '../stores/templates-store'; +import { filterTemplates } from '../lib/template-utils'; + +interface TemplateSheetProps { + visible: boolean; + onClose: () => void; + onPick: (template: EmailTemplate) => void; +} + +/** + * Bottom sheet listing the user's templates for insertion into the composer + * (webmail template picker): favourites first, then the rest, with a search + * box over name/subject/category. + */ +export function TemplateSheet({ visible, onClose, onPick }: TemplateSheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); + const templates = useTemplatesStore((s) => s.templates); + const hydrated = useTemplatesStore((s) => s.hydrated); + const hydrate = useTemplatesStore((s) => s.hydrate); + const [query, setQuery] = React.useState(''); + const slideY = React.useRef(new Animated.Value(500)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); + + React.useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + + React.useEffect(() => { + if (visible) { + setQuery(''); + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + const visibleTemplates = React.useMemo(() => { + const list = query.trim() ? filterTemplates(templates, query.trim()) : templates; + return [...list].sort((a, b) => { + if (a.isFavorite !== b.isFavorite) return a.isFavorite ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [templates, query]); + + return ( + + + + + + + + + + + {t('templates.picker_title', 'Choose a Template')} + + + + + + {templates.length > 4 && ( + + )} + + {visibleTemplates.length === 0 ? ( + + {templates.length === 0 + ? t('templates.no_templates', 'No templates yet') + : t('templates.no_results', 'No templates found')} + + ) : visibleTemplates.map((template) => ( + { + onPick(template); + onClose(); + }} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + > + + {template.isFavorite + ? + : } + + + {template.name} + {!!(template.subject || template.category) && ( + + {[template.category, template.subject].filter(Boolean).join(' · ')} + + )} + + + ))} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + sheetOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, + sheetOverlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '70%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + sheetHandleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm }, + sheetHandle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + sheetTitle: { ...typography.bodySemibold, color: c.text }, + sheetClose: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs }, + search: { + ...typography.body, + color: c.text, + marginHorizontal: spacing.lg, + marginTop: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: 8, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + scrollList: { paddingVertical: spacing.xs }, + empty: { ...typography.body, color: c.textMuted, textAlign: 'center', padding: spacing.xl }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + minHeight: 52, + }, + rowPressed: { backgroundColor: c.surfaceHover }, + iconWrap: { + marginRight: spacing.md, + width: 28, + height: 28, + borderRadius: radius.xs, + backgroundColor: c.surface, + alignItems: 'center', + justifyContent: 'center', + }, + details: { flex: 1, justifyContent: 'center' }, + name: { ...typography.bodyMedium, color: c.text }, + subject: { ...typography.caption, color: c.textSecondary, marginTop: 2 }, + }); +} diff --git a/src/components/UndoSnackbar.tsx b/src/components/UndoSnackbar.tsx index faf02b7a..3ac35699 100644 --- a/src/components/UndoSnackbar.tsx +++ b/src/components/UndoSnackbar.tsx @@ -1,33 +1,72 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useEmailStore } from '../stores/email-store'; +import { useSendUndoStore, type PendingUndoSend } from '../stores/send-undo-store'; +import { useLocaleStore } from '../stores/locale-store'; +import { getFullEmail, restoreEmailToDraft } from '../api/email'; +import { ownMailboxes } from '../lib/mailbox-tree'; +import { draftContextFromEmail } from '../lib/draft-context'; +import type { RootStackParamList } from '../navigation/types'; const VISIBLE_MS = 5000; +interface Shown { + label: string; + createdAt: number; + visibleMs: number; + /** Undo-send entries carry a second "Send now" action. */ + send?: PendingUndoSend; +} + export function UndoSnackbar() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); + const t = useLocaleStore((s) => s.t); + const navigation = useNavigation>(); const entry = useEmailStore((s) => s.pendingUndo); const undoLast = useEmailStore((s) => s.undoLast); const clearUndo = useEmailStore((s) => s.clearUndo); + const pendingSend = useSendUndoStore((s) => s.pending); + const sendBusy = useSendUndoStore((s) => s.busy); const slideY = React.useRef(new Animated.Value(120)).current; const opacity = React.useRef(new Animated.Value(0)).current; + + // The undo-send entry (composer) and the list-action entry (store) share + // the bar; the newer one wins. + const current = React.useMemo(() => { + const list: Shown | null = entry + ? { label: entry.label, createdAt: entry.createdAt, visibleMs: VISIBLE_MS } + : null; + const send: Shown | null = pendingSend + ? { + label: t('email_composer.undo_send_label', 'Sending in {seconds}s…', { seconds: pendingSend.delaySeconds }), + createdAt: pendingSend.createdAt, + visibleMs: Math.max(1000, pendingSend.delaySeconds * 1000 - 1000), + send: pendingSend, + } + : null; + if (list && send) return list.createdAt >= send.createdAt ? list : send; + return list ?? send; + }, [entry, pendingSend, t]); + // Remember the last non-null entry so the bar's text/handlers stay valid - // while it animates out after pendingUndo flips to null. - const [shown, setShown] = React.useState(entry); + // while it animates out after the entry flips to null. + const [shown, setShown] = React.useState(current); React.useEffect(() => { - if (entry) setShown(entry); - }, [entry]); + if (current) setShown(current); + }, [current]); // Drive the auto-dismiss off the entry's createdAt so re-renders during // animation don't reset the timer, and so consecutive actions reset it. React.useEffect(() => { - if (!entry) { + if (!current) { Animated.parallel([ Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }), @@ -42,16 +81,44 @@ export function UndoSnackbar() { Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }), ]).start(); - const elapsed = Date.now() - entry.createdAt; - const remaining = Math.max(0, VISIBLE_MS - elapsed); + const elapsed = Date.now() - current.createdAt; + const remaining = Math.max(0, current.visibleMs - elapsed); const id = setTimeout(() => { - // Only clear if the entry is still the same one we scheduled for. - if (useEmailStore.getState().pendingUndo === entry) { + if (current.send) { + if (useSendUndoStore.getState().pending === current.send) useSendUndoStore.getState().clear(); + } else if (useEmailStore.getState().pendingUndo === entry) { + // Only clear if the entry is still the same one we scheduled for. clearUndo(); } }, remaining); return () => clearTimeout(id); - }, [entry, slideY, opacity, clearUndo]); + }, [current, entry, slideY, opacity, clearUndo]); + + const onUndo = async () => { + if (!shown) return; + if (!shown.send) { + void undoLast(); + return; + } + const send = shown.send; + const ok = await useSendUndoStore.getState().undo(); + if (!ok) return; + // Bring the message back as an editable draft and reopen the composer. + try { + const own = ownMailboxes(useEmailStore.getState().mailboxes); + const drafts = own.find((m) => m.role === 'drafts'); + const sent = own.find((m) => m.role === 'sent'); + if (drafts) await restoreEmailToDraft(send.emailId, drafts.id, sent?.id); + const email = await getFullEmail(send.emailId); + navigation.navigate('Compose', { draft: draftContextFromEmail(email) }); + } catch (err) { + console.warn('[send-undo] reopen failed', err); + } + }; + + const onSendNow = () => { + void useSendUndoStore.getState().sendNow(); + }; if (!shown) return null; @@ -65,12 +132,23 @@ export function UndoSnackbar() { > {shown.label} + {shown.send && ( + [styles.undoBtn, pressed && styles.undoBtnPressed]} + > + {t('email_composer.send_now', 'Send now').toUpperCase()} + + )} { void undoLast(); }} + onPress={() => { void onUndo(); }} hitSlop={8} + disabled={sendBusy} style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]} > - UNDO + {t('common.undo', 'Undo').toUpperCase()} @@ -117,5 +195,11 @@ function makeStyles(c: ThemePalette) { color: c.primary, letterSpacing: 0.5, }, + sendNowText: { + ...typography.bodySemibold, + color: c.background, + letterSpacing: 0.5, + opacity: 0.85, + }, }); } diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index a6214516..6e00881d 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -80,7 +80,7 @@ export function buildEditorHtml(opts: { -
+