mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
feat: conversation view with collapsible cards, header details, auth chips, unsubscribe and read-receipt banners, attachment preview and zip, embedded-message and TNEF unwrap in the reader (#480, #830, #891)
This commit is contained in:
@@ -12,7 +12,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: the list collapses same-thread rows to the newest message with a count badge (`src/screens/EmailListScreen.tsx:224-238`, default `disableThreading: false` at `src/stores/settings-store.ts:266`), the row opens `EmailThreadScreen` with a single `emailId` (`App.tsx:145-151`), and the screen only ever shows that one message (`src/screens/EmailThreadScreen.tsx:113-120`, `685-840`). `getThread` exists in `src/api/email.ts:448` but is only used by a test. Older messages of a thread cannot be opened at all unless the user turns on "Disable Thread Grouping".
|
||||
- Fix hint: on open, call `getThread(threadId, ownerAccountId)` + `getFullEmails(ids)` and render a vertical list of collapsible message cards inside the pane (newest + unread expanded), reusing `EmailBodyView` per card; keep the horizontal pager for thread-to-thread navigation.
|
||||
|
||||
- [ ] **Pager shows the wrong message (or nothing) when the opened email is not in the store list** — `P1` — `rn-only-bug`
|
||||
- [x] **Pager shows the wrong message (or nothing) when the opened email is not in the store list — fixed in c8be383** — `P1` — `rn-only-bug`
|
||||
- What WEB does: the viewer renders `selectedEmail` directly; prev/next are derived from the current list (`components/email/email-viewer.tsx:5220-5279`).
|
||||
- What RN does: the pager is a `FlatList` over `useEmailStore(s => s.emails)` and the initial index is `Math.max(0, emails.findIndex(id))` (`src/screens/EmailThreadScreen.tsx:128-133`, `535-572`). `UnifiedInboxScreen` keeps its own local list and never writes the store (`src/screens/UnifiedInboxScreen.tsx:35-50`, `61-84`), `ContactActivity` navigates with ids from a contact search (`src/components/contacts/ContactActivity.tsx:170-177`). In both cases the opened id is absent from `emails`, so the visible pane is `emails[0]` (a different mailbox's first message, or blank when the list is empty) while the toolbar/mark-read/delete act on `activeEmailId`.
|
||||
- Fix hint: when `route.params.emailId` is not in `emails`, render a single-item pager (`data=[{id: emailId}]`) and disable prev/next; or have the unified inbox / contact activity pass their own id list through route params.
|
||||
@@ -26,12 +26,12 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
|
||||
### Body rendering & sanitisation
|
||||
|
||||
- [ ] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css"** — `P1` — `bug`
|
||||
- [x] **HTML-only messages are rendered (and quoted) as raw HTML source — native issue #46 "email preview shows css" — fixed in 06742ef** — `P1` — `bug`
|
||||
- What WEB does: per RFC 8621 §4.1.4 an HTML-only message exposes the same part in `textBody` and `htmlBody`; WEB checks `hasDistinctTextBody` (different partIds) before preferring the text alternative, and routes by the part's `type` (`components/email/email-viewer.tsx:1727-1747`, `components/email/thread-conversation-view.tsx:332-346`; changelog 1.6.3 "Render HTML-only emails", "Render plain-text-only emails as text, not HTML").
|
||||
- What RN does: `extractTextBody` returns the html part's value for HTML-only mail, then `rawHtml = html && (!text || hasMeaningfulHtmlBody(html)) ? html : null` (`src/components/EmailBodyView.tsx:43-57`, `504-511`). For any HTML-only message whose markup fails `MEANINGFUL_HTML_RE` (`src/lib/email-html.ts:353-362`, e.g. a `<div>`/`<p>` body without `style=`), `rawHtml` is null and the raw source — tags, `<head>`, CSS — is shown as plain text. The same value feeds the reply quote: `plainTextBody()` (`src/screens/EmailThreadScreen.tsx:59-65`, `377`) so replies to HTML-only mail quote escaped HTML source.
|
||||
- Fix hint: compare `textBody[0].partId !== htmlBody[0].partId` (and `part.type === 'text/html'`) before treating `text` as an alternative; in `navigateCompose` pass `htmlBody` (and let `buildInitialHtml` use it) or convert with `htmlToPlainText` (`src/lib/compose-html.ts:108`) when the only body is HTML.
|
||||
|
||||
- [ ] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails"** — `P2` — `bugfix-parity`
|
||||
- [x] **`<style>` blocks are stripped from every HTML email — layout/colour loss, likely native issue #49 "Unreadable text in dark-mode emails" — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: iframe sanitiser keeps `<style>` (`lib/email-sanitization.ts:60-78`), and when external content is blocked it neutralises `url()`/`@import`/`@font-face` inside the sheet (`stripExternalStyleSheetCss`, `lib/email-sanitization.ts:474-509`, changelog 1.7.8 #457) while the strict iframe CSP is the network backstop (`components/email/email-viewer.tsx:2265-2267`).
|
||||
- What RN does: `FORBID_TAGS` includes `'style'` (and `svg`) so every stylesheet is removed before render (`src/lib/email-html.ts:16-28`, `50-68`). Class-based colours vanish: a newsletter with white text via `.hero { color:#fff }` on a `bgcolor` cell renders white-on-white (light) or, after the invert filter, black-on-black. Also `hasNativeDarkMode` is evaluated on the *stripped* HTML inside `wrapEmailHtml` (`src/lib/email-html.ts:226-229`) but on the raw HTML in the component (`src/components/EmailBodyView.tsx:612-613`), so for emails with `@media (prefers-color-scheme: dark)` the CSS inversion is applied but the DOM re-invert pass is skipped.
|
||||
- Fix hint: keep `<style>` (the WebView is an isolated document; CSP already has `style-src 'unsafe-inline'`), port `stripExternalStyleSheetCss` + `decodeCssEscapes` for the blocked case, and evaluate `hasNativeDarkMode` once on the same input.
|
||||
@@ -41,50 +41,50 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: `DARK_INVERSION` applies the `:not(:has(...))` guard to background-image containers too and includes them in the nested `filter:none` rule (`src/lib/email-html.ts:167-185`), the DOM pass only re-inverts containers without media children (`src/components/EmailBodyView.tsx:72-88`); no Word/Outlook padding, no per-message light/dark toggle (WEB toolbar Sun/Moon, `email-viewer.tsx:3199-3212`, changelog 1.6.0), no `messageSpacing`.
|
||||
- Fix hint: copy the current `darkModeCSS` block and the DOM re-invert loop verbatim into `email-html.ts` / `DARK_REINVERT_SCRIPT`; add a "View in light/dark mode" entry to the More sheet that overrides `renderAsDark` for the current message.
|
||||
|
||||
- [ ] **External-content detection misses most tracking vectors (privacy leak)** — `P2` — `bugfix-parity`
|
||||
- [x] **External-content detection misses most tracking vectors (privacy leak) — fixed in 06742ef** — `P2` — `bugfix-parity`
|
||||
- What WEB does: `isExternalResourceUrl` strips C0/space so `"\nhttps://x"` and `h\ttps://` count; blocks `<img srcset>`, `<picture><source>`, `<video poster>`, media `src`, `background=`, inline `url()` incl. CSS escapes, `<style>` url()/@import, and forbids `<link>` in blocking mode; the banner is driven by "something was actually blocked" (`lib/email-sanitization.ts:399-406`, `511-615`, `108-150`; changelog 1.7.8).
|
||||
- What RN does: `hasRemoteContent` only matches `<img src="http…">`, `background="http…"` and `style="...url(http…)"` (`src/lib/email-html.ts:364-369`); when it returns false `shouldBlock` is false and the CSP becomes `img-src data: cid: https: http:` (`src/lib/email-html.ts:235`), so a pixel expressed as `srcset=`, `<source>`, `poster=`, `src=" https://…"` (leading whitespace/newline), or a CSS-escaped `\68ttps://` loads unconditionally under policy `ask`/`block`. `blockRemoteImageSrcs` (`316-347`) is likewise src/srcset/background/style only, and has no `data-blocked-*` restore.
|
||||
- Fix hint: make the gate policy-driven rather than detection-driven (block = strict CSP `img-src data: cid:` + `media-src`/`font-src` none whenever policy is `block`/`ask`-not-allowed and sender untrusted, like WEB's `externalBlocked`), port `isExternalResourceUrl` normalisation, add `source/video/audio/poster/link` handling, and show the banner when the strict CSP is in effect and the HTML contains any `http(s)://` resource reference.
|
||||
|
||||
- [ ] **Blocked images leave broken-image icons / alt text; empty containers not collapsed** — `P3` — `bugfix-parity`
|
||||
- [x] **Blocked images leave broken-image icons / alt text; empty containers not collapsed — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: swaps `src` for a transparent 1x1 GIF, empties `alt`, `display:none`, and collapses empty `<td>/<div>` wrappers (`lib/email-sanitization.ts:319-320`, `537-546`, `678-704`); hides images that fail to load (`components/email/email-viewer.tsx:2393-2411`, changelog 1.7.7).
|
||||
- What RN does: sets `src=""` and `alt="[remote image blocked]"` (`src/lib/email-html.ts:319-323`), so blocked newsletters show rows of broken-image glyphs with English alt text; no failed-image hiding.
|
||||
- Fix hint: use the same transparent-GIF + `display:none` swap and port `collapseBlockedImageContainers` as a regex/DOM pass in the injected script; add an `img` `error` listener that hides the element.
|
||||
|
||||
- [ ] **mailto: links inside the body open the OS mail handler instead of the app composer** — `P2` — `missing`
|
||||
- [x] **mailto: links inside the body open the OS mail handler instead of the app composer — fixed in 06742ef** — `P2` — `missing`
|
||||
- What WEB does: `mailto:` opens the built-in composer with parsed to/subject/body (changelog 1.8.1 "Open mailto: links in the built-in composer"; iframe click handler leaves `mailto:` to the app's protocol handling, `components/email/email-viewer.tsx:2420-2436`).
|
||||
- What RN does: `onShouldStartLoadWithRequest` hands `mailto:` to `Linking.openURL` (`src/components/EmailBodyView.tsx:715-724`), so tapping an address in an email opens Gmail/Apple Mail instead of Bulwark.
|
||||
- Fix hint: parse `mailto:` (address, `subject`, `body`, `cc`) and `navigation.navigate('Compose', { prefillTo, ... })`; the `prefillTo` param already exists (`src/screens/ComposeScreen.tsx:292-298`).
|
||||
|
||||
- [ ] **`data:` URL navigations replace the email body** — `P3` — `rn-only-bug`
|
||||
- [x] **`data:` URL navigations replace the email body — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What RN does: `onShouldStartLoadWithRequest` returns `true` for any `data:` URL (`src/components/EmailBodyView.tsx:717-719`), and `safeUri` keeps `href="data:image/…"` (`src/lib/email-html.ts:109-113`); tapping such a link navigates the WebView to the image with no way back. Only the initial `about:blank` load should be allowed.
|
||||
- Fix hint: allow `data:` only when `request.navigationType === 'other' && !request.isTopFrame`-style initial loads, otherwise return false (optionally open images externally).
|
||||
|
||||
- [ ] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB** — `P3` — `partial`
|
||||
- [x] **Regex sanitiser + `script-src 'unsafe-inline'`: keep the data-URI/SVG allowlist in sync with WEB — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: allows only raster `data:image/(png|jpe?g|gif|webp|bmp|avif|x-icon|vnd.microsoft.icon)` on media tags, re-checks `srcset` candidates individually, forbids SVG data URIs and `<svg>` (`lib/email-sanitization.ts:24`, `330-388`), and the iframe has no scripting at all.
|
||||
- What RN does: `safeUri` allows any `data:image/*` including `image/svg+xml` (`src/lib/email-html.ts:109-113`), `srcset` values are never scheme-checked except when blocking (`326-329`), and the page CSP must allow `'unsafe-inline'` scripts for the injected bridge (`236-245`), so a parser-differential bypass of the regex stripper would execute script in the WebView (limited blast radius: the bridge only accepts height/swipe/zoom strings).
|
||||
- Fix hint: restrict `data:` to the raster list, scheme-check every `srcset` candidate, and consider parsing with a real HTML parser (e.g. `htmlparser2`/`parse5` on the JS side) instead of regexes; document the residual risk.
|
||||
|
||||
- [ ] **Quoted reply text is not collapsed (#480)** — `P3` — `missing`
|
||||
- [x] **Quoted reply text is not collapsed (#480) — fixed in 06742ef** — `P3` — `missing`
|
||||
- What WEB does: hides the trailing quote (Gmail/Outlook/Apple/Thunderbird/Bulwark markers, attributed `<blockquote>`, `>`-lines in plain text) behind a "•••" toggle (`lib/quote-collapse.ts:143-197`, `219-259`; changelog 1.7.8).
|
||||
- What RN does: nothing; long threads show every quoted original inline.
|
||||
- Fix hint: port `setupQuoteCollapse` into the injected WebView script (runs on the parsed DOM, uses inline styles only) and `collapsePlainTextQuotes` into `wrapPlainTextEmail`.
|
||||
|
||||
- [ ] **Plain-text bodies are always monospace (#830)** — `P3` — `bugfix-parity`
|
||||
- [x] **Plain-text bodies are always monospace (#830) — fixed in 06742ef** — `P3` — `bugfix-parity`
|
||||
- What WEB does: renders text/plain in the app font by default with a `plainTextFont: 'sans' | 'mono'` setting (`stores/settings-store.ts:329`, `565`; `components/email/email-viewer.tsx:5050-5060`; changelog 1.9.0).
|
||||
- What RN does: `wrapPlainTextEmail` hard-codes `ui-monospace, Menlo, …` (`src/lib/email-html.ts:290`); no setting (`src/stores/settings-store.ts` has no `plainTextFont`).
|
||||
- Fix hint: add the setting (Reading settings) and switch the font-family in `wrapPlainTextEmail`.
|
||||
|
||||
- [ ] **Plain-text linkifier only catches http(s)** — `P3` — `verified` (same as WEB `plainTextToSafeHtml`); no action.
|
||||
|
||||
- [ ] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified** — `P3` — `partial`
|
||||
- [x] **cid images larger than 2 MB and non-image cid parts silently show a 1x1 placeholder; octet-stream cid parts (#543) unverified — fixed in 06742ef** — `P3` — `partial`
|
||||
- What WEB does: fetches every cid part as an authenticated `blob:` URL regardless of size/type; the browser sniffs `application/octet-stream` image bytes (`components/email/email-viewer.tsx:1509-1575`; changelog 1.8.1 #543).
|
||||
- What RN does: skips parts over `MAX_INLINE_IMAGE_BYTES` (`src/components/EmailBodyView.tsx:15`, `556-559`) and builds `data:${att.type || 'application/octet-stream'};base64,…` (`573`), which Chromium/WebKit may refuse to decode as an image for `application/octet-stream`.
|
||||
- Fix hint: sniff magic bytes (PNG/JPEG/GIF/WebP) to pick the MIME for the data URI; for >2 MB parts encode off the JS thread (e.g. `File.base64()` from expo-file-system after `downloadInto`) instead of dropping them.
|
||||
|
||||
### Header / meta
|
||||
|
||||
- [ ] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched** — `P2` — `missing` (root cause for the next five items)
|
||||
- [x] **`headers`, `messageId`, `inReplyTo`, `references` are never fetched — fixed in ccbe67c** — `P2` — `missing` (root cause for the next five items)
|
||||
- What WEB does: `Email/get` requests `messageId, inReplyTo, references, headers, bodyStructure` and parses `Authentication-Results`, `X-Spam-*`, `X-Spam-LLM` into `email.authenticationResults/spamScore/spamLLM` (`lib/jmap/client.ts:1775-1870`).
|
||||
- What RN does: `EMAIL_FULL_PROPERTIES` stops at `bodyStructure, textBody, htmlBody, bodyValues, attachments, blobId, bcc, replyTo, sentAt` (`src/api/email.ts:11-15`); the `Email` type has no `headers`/`messageId` (`src/api/types.ts:65-87`).
|
||||
- Fix hint: add the four properties to `EMAIL_FULL_PROPERTIES`, extend the type, and port `parseAuthenticationResults`/`parseSpamScore`/`extractListHeaders` (`lib/email-headers.ts`) to `src/lib/email-headers.ts`.
|
||||
@@ -109,7 +109,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: none; no `readReceiptResponse` setting.
|
||||
- Fix hint: port `lib/mdn.ts` verbatim (pure string builder), reuse `uploadBytes` (`src/api/blob.ts:67`) + `importEmailBlob` (`src/api/email.ts:423`) + an `EmailSubmission/set` helper; add the setting.
|
||||
|
||||
- [ ] **Reply-To shown / used** — folded into the reply-recipients finding below.
|
||||
- [x] **Reply-To shown / used — fixed in 7f956d6** — folded into the reply-recipients finding below.
|
||||
|
||||
- [ ] **Sender / recipient tap actions (popover, view/add contact, copy address)** — `P3` — `missing`
|
||||
- What WEB does: every address is a `RecipientPopover` (contact lookup, extra emails/phones, copy, email, view/add contact); tapping the avatar opens the contact sidebar or, on mobile, the contact page (`components/email/recipient-popover.tsx`, `components/email/email-viewer.tsx:1140-1170`, `3841-3866`).
|
||||
@@ -128,7 +128,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
|
||||
### Calendar invitation banner
|
||||
|
||||
- [ ] **Invitation parsing is not routed to the owning account (#847, #867)** — `P2` — `bugfix-parity`
|
||||
- [x] **Invitation parsing is not routed to the owning account (#847, #867) — fixed in 8206893** — `P2` — `bugfix-parity`
|
||||
- What WEB does: parses/fetches the ICS through the message's source client and owner account, including directly viewed shared folders (`components/email/calendar-invitation-banner.tsx:373-388`, `412-432`; changelog 1.9.0).
|
||||
- What RN does: `parseCalendarBlob(attachment.blobId)` uses `jmapClient.accountId` (`src/components/email/CalendarInvitationBanner.tsx:54`, `src/api/calendar.ts:355-360`) even though `EmailPane` knows `jmapAccountId`; Stalwart answers `CalendarEvent/parse` with an error for a blob in another account, so invitations in group/shared mailboxes show nothing (state `error` → banner hidden).
|
||||
- Fix hint: pass `jmapAccountId` from `EmailPane` into the banner and through to `parseCalendarBlob(blobId, accountId)`.
|
||||
@@ -150,7 +150,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
|
||||
### Attachments
|
||||
|
||||
- [ ] **Forwarding drops every attachment** — `P1` — `bug`
|
||||
- [x] **Forwarding drops every attachment — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: forward pre-populates the composer with the original's attachments by blobId (minus embedded cid images) (`components/email/email-composer.tsx:598-616`, #543).
|
||||
- What RN does: `navigateCompose('forward')` passes only from/to/cc/subject/plain body (`src/screens/EmailThreadScreen.tsx:366-382`); the `Compose.replyTo` param has no `attachments` field (`src/navigation/types.ts:7-19`) and `ComposeScreen` never seeds `attachments` from the original (`src/screens/ComposeScreen.tsx:354`). A forwarded invoice arrives without the PDF.
|
||||
- Fix hint: add `attachments?: Attachment[]` to the route param, and in `ComposeScreen` initialise `attachments` with `{blobId,name,type,size}` entries (blobs are account-scoped so no re-upload is needed; for shared-folder messages the blob belongs to the owner account — either re-upload via `getDownloadUrl(..., ownerAccountId)` + `uploadBytes`, or block forward there).
|
||||
@@ -187,17 +187,17 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
|
||||
### Actions from the viewer
|
||||
|
||||
- [ ] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity)** — `P1` — `bug`
|
||||
- [x] **`In-Reply-To`/`References` are written with the JMAP email id (#234 bugfix-parity) — fixed in 7f956d6** — `P1` — `bug`
|
||||
- What WEB does: passes the original's RFC `messageId` and `references`, stripped of brackets, as JMAP `inReplyTo`/`references` arrays (`lib/jmap/client.ts:3124-3194`; changelog 1.5.4 #234).
|
||||
- What RN does: `navigateCompose` sets `inReplyTo: email.id` (`src/screens/EmailThreadScreen.tsx:379`), and `sendEmail` writes it verbatim into `header:In-Reply-To:asText` / `header:References:asText` (`src/api/email.ts:823-826`). The outgoing header is a bare Stalwart object id, not a msg-id, so recipients' clients cannot thread the reply and `References` is never accumulated. `messageId`/`references` are not even fetched (see headers finding).
|
||||
- Fix hint: fetch `messageId`/`references`, pass `inReplyTo: email.messageId?.[0]` and `references: [...(email.references ?? []), email.messageId[0]]`, and send them as JMAP `inReplyTo`/`references` arrays (or `<…>`-wrapped `header:…:asMessageIds`).
|
||||
|
||||
- [ ] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0)** — `P1` — `bugfix-parity`
|
||||
- [x] **Reply addressing: Reply-To ignored, own addresses kept on reply-all, self-sent thread replies (#703), external Reply-To on self-sent (1.9.0) — fixed in 7f956d6** — `P1` — `bugfix-parity`
|
||||
- What WEB does: `buildReplyRecipients` — reply goes to `Reply-To` if present else `From`; reply-all adds original To/CC minus the user's own identities (exact and `+tag`-stripped); replying to your own message continues to its original recipients; an external Reply-To on a self-sent message still wins (`lib/reply-recipients.ts:74-111`, `components/mail/mail-app.tsx:920`, `2940-2944`).
|
||||
- What RN does: `initialTo` = From (+ all original To on reply-all, including the user's own address), `initialCc` = all CC; `email.replyTo` is fetched but never passed (`src/screens/ComposeScreen.tsx:299-319`, `src/screens/EmailThreadScreen.tsx:366-382`). Replying to a newsletter with `Reply-To: support@…` mails the no-reply sender; reply-all sends the user a copy; replying to your own sent message addresses yourself.
|
||||
- Fix hint: copy `lib/reply-recipients.ts` (pure), pass `replyToAddresses: email.replyTo` in the route param and the identities' emails as `ownEmails`.
|
||||
|
||||
- [ ] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543)** — `P2` — `bugfix-parity`
|
||||
- [x] **Reply/forward quote is plain text only — HTML formatting and inline images lost (#163, #543) — fixed in 7f956d6** — `P2` — `bugfix-parity`
|
||||
- What WEB does: quotes the sanitized HTML body (with cid images re-attached inline) inside an editable quote island, localized "On … wrote:" header with `Name <email>` (`components/email/email-composer.tsx:462-500`, `lib/quote-header.ts:54-91`; changelog 1.7.1, 1.7.3, 1.8.1).
|
||||
- What RN does: `buildInitialHtml` supports `htmlBody` (`src/lib/compose-html.ts:79-83`) but the viewer only passes `body: plainTextBody(email)` (`src/screens/EmailThreadScreen.tsx:377`), and the header uses display name only (`compose-html.ts:31-35`) rather than WEB's `Name <email>` (#482).
|
||||
- Fix hint: pass `htmlBody` (sanitized via `stripDangerousTags`, cid refs rewritten to fetched data URIs or re-attached with the same `cid`) and switch `senderName` to the `Name <email>` form.
|
||||
@@ -207,7 +207,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
- What RN does: none.
|
||||
- Fix hint: add a More-sheet entry that opens `Compose` with `attachments: [{blobId: email.blobId, name, type: 'message/rfc822', size: email.size}]` (depends on the forward-attachments fix).
|
||||
|
||||
- [ ] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246)** — `P3` — `partial`
|
||||
- [x] **Reply identity selection: exact match only, no `+tag` or catch-all domain override (#246) — fixed in 5b72c4d** — `P3` — `partial`
|
||||
- What WEB does: `resolveReplyFrom` — exact, then `+tag`-stripped, then same-domain catch-all with a From override (`lib/reply-identity.ts:197-256`).
|
||||
- What RN does: `identities.find(i => i.email === candidate.email)` (`src/screens/ComposeScreen.tsx:431-437`).
|
||||
- Fix hint: port `resolveReplyFrom`; the override needs a From override in `sendEmail`.
|
||||
@@ -229,7 +229,7 @@ RN has a solid single-message reader (WebView body with CSP, shrink-to-fit + pin
|
||||
|
||||
### Trusted senders / settings
|
||||
|
||||
- [ ] **"Trust sender" writes to the Trusted Senders address book even when the setting is off** — `P3` — `rn-only-bug`
|
||||
- [x] **"Trust sender" writes to the Trusted Senders address book even when the setting is off — fixed in 06742ef** — `P3` — `rn-only-bug`
|
||||
- What WEB does: writes to the book only when `trustedSendersAddressBook` is on, else to the local list (`components/email/email-viewer.tsx:4646-4655`, `components/trusted-senders-modal.tsx:136-140`).
|
||||
- What RN does: always does both (`src/components/EmailBodyView.tsx:644-653`), and `addToTrustedSendersBook` creates the book on first add (`src/stores/contacts-store.ts:308-321`), so a user who left the setting off (default `false`, `src/stores/settings-store.ts:239`) still gets a server-side "Trusted Senders" address book; the settings modal then lists only the local entries (`src/components/settings/ContentSendersSettings.tsx:19-21`, `123-144`), so book-trusted senders are invisible/unremovable there.
|
||||
- Fix hint: gate the book write on the setting (or, matching WEB 1.9.0, default the setting to on for contacts-capable accounts) and show `trustedSenderEmails` in the settings modal with remove support.
|
||||
|
||||
Generated
+534
@@ -27,6 +27,7 @@
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "^0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
@@ -2390,6 +2391,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@ide/backoff": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz",
|
||||
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -3935,6 +3942,19 @@
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assert": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz",
|
||||
"integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"is-nan": "^1.3.2",
|
||||
"object-is": "^1.1.5",
|
||||
"object.assign": "^4.1.4",
|
||||
"util": "^0.12.5"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
@@ -3951,6 +3971,21 @@
|
||||
"integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/available-typed-arrays": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"possible-typed-array-names": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -4211,6 +4246,12 @@
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/badgin": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -4416,6 +4457,53 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"get-intrinsic": "^1.3.0",
|
||||
"set-function-length": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
@@ -4907,6 +4995,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-lazy-prop": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
|
||||
@@ -4916,6 +5021,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -5026,6 +5148,20 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -5083,6 +5219,24 @@
|
||||
"stackframe": "^1.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@@ -5090,6 +5244,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -5217,6 +5383,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz",
|
||||
@@ -5456,6 +5631,26 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "0.32.17",
|
||||
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.32.17.tgz",
|
||||
"integrity": "sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.8",
|
||||
"@ide/backoff": "^1.0.0",
|
||||
"abort-controller": "^3.0.0",
|
||||
"assert": "^2.0.0",
|
||||
"badgin": "^1.1.5",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-secure-store": {
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
|
||||
@@ -5997,6 +6192,21 @@
|
||||
"integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-callable": "^1.2.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/freeport-async": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
|
||||
@@ -6044,6 +6254,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
|
||||
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -6062,6 +6281,30 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
@@ -6071,6 +6314,19 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/getenv": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz",
|
||||
@@ -6133,6 +6389,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -6148,6 +6416,45 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -6326,12 +6633,40 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-callable": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
|
||||
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -6371,6 +6706,41 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.4",
|
||||
"generator-function": "^2.0.0",
|
||||
"get-proto": "^1.0.1",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"safe-regex-test": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-nan": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz",
|
||||
"integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.0",
|
||||
"define-properties": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -6389,6 +6759,39 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-regex": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-typed-array": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
|
||||
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"which-typed-array": "^1.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-wsl": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||
@@ -7329,6 +7732,15 @@
|
||||
"integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mdn-data": {
|
||||
"version": "2.0.14",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
|
||||
@@ -7958,6 +8370,51 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/object.assign": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz",
|
||||
"integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.8",
|
||||
"call-bound": "^1.0.3",
|
||||
"define-properties": "^1.2.1",
|
||||
"es-object-atoms": "^1.0.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -8258,6 +8715,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
|
||||
@@ -9009,6 +9475,23 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"is-regex": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
@@ -9138,6 +9621,23 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.1.4",
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-intrinsic": "^1.2.4",
|
||||
"gopd": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
@@ -9932,6 +10432,19 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
|
||||
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"is-arguments": "^1.0.4",
|
||||
"is-generator-function": "^1.0.7",
|
||||
"is-typed-array": "^1.1.3",
|
||||
"which-typed-array": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -10263,6 +10776,27 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.22",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz",
|
||||
"integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"available-typed-arrays": "^1.0.7",
|
||||
"call-bind": "^1.0.9",
|
||||
"call-bound": "^1.0.4",
|
||||
"for-each": "^0.3.5",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
"expo-localization": "~17.0.8",
|
||||
"expo-notifications": "^0.32.17",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Modal, Animated, Easing } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { X } 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';
|
||||
|
||||
export interface ActionSheetItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
interface ActionSheetProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: ActionSheetItem[];
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom sheet with a list of actions - the same slide-up/drag-to-dismiss
|
||||
* pattern as the reader's More sheet, reused for address and attachment
|
||||
* actions.
|
||||
*/
|
||||
export function ActionSheet({ visible, title, subtitle, items, onClose, children }: ActionSheetProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
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 (visible) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
|
||||
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
||||
<Pressable style={styles.overlayPress} onPress={onClose} />
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.sheet,
|
||||
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
|
||||
]}
|
||||
>
|
||||
<View {...dragHandlers}>
|
||||
<View style={styles.handleHit}>
|
||||
<View style={styles.handle} />
|
||||
</View>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerText}>
|
||||
<Text style={styles.title} numberOfLines={1}>{title}</Text>
|
||||
{subtitle ? <Text style={styles.subtitle} numberOfLines={1}>{subtitle}</Text> : null}
|
||||
</View>
|
||||
<Pressable onPress={onClose} hitSlop={8} style={styles.close}>
|
||||
<X size={18} color={c.textSecondary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{children}
|
||||
{items.map((item) => (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
onPress={item.disabled ? undefined : item.onPress}
|
||||
style={({ pressed }) => [styles.item, pressed && styles.itemPressed, item.disabled && styles.itemDisabled]}
|
||||
>
|
||||
<View style={styles.itemIcon}>{item.icon}</View>
|
||||
<Text style={[styles.itemLabel, item.destructive && styles.itemLabelDestructive]}>{item.label}</Text>
|
||||
{item.trailing}
|
||||
</Pressable>
|
||||
))}
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' },
|
||||
overlayPress: { flex: 1 },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: c.popover,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
borderTopWidth: 1,
|
||||
borderColor: c.border,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
handleHit: { alignItems: 'center', paddingTop: spacing.xs, paddingBottom: spacing.sm },
|
||||
handle: { width: 36, height: 4, borderRadius: 2, backgroundColor: c.border },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerText: { flex: 1, minWidth: 0 },
|
||||
title: { ...typography.bodySemibold, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
close: { width: 28, height: 28, alignItems: 'center', justifyContent: 'center', borderRadius: radius.xs },
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 48,
|
||||
},
|
||||
itemPressed: { backgroundColor: c.surfaceHover },
|
||||
itemDisabled: { opacity: 0.4 },
|
||||
itemIcon: { width: 20, alignItems: 'center', justifyContent: 'center' },
|
||||
itemLabel: { ...typography.body, color: c.text, flex: 1 },
|
||||
itemLabelDestructive: { color: c.error },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { Copy, PenSquare, UserRound, UserRoundPlus } from 'lucide-react-native';
|
||||
import type { EmailAddress } from '../../api/types';
|
||||
import type { RootStackParamList } from '../../navigation/types';
|
||||
import { useContactsStore } from '../../stores/contacts-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
interface Props {
|
||||
address: EmailAddress | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap sheet for a sender/recipient: copy the address, start a message to it,
|
||||
* open the matching contact or add one (the webmail's RecipientPopover).
|
||||
*/
|
||||
export function AddressActionSheet({ address, onClose }: Props) {
|
||||
const c = useColors();
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<Nav>();
|
||||
const findContactByEmail = useContactsStore((s) => s.findContactByEmail);
|
||||
|
||||
const email = address?.email ?? '';
|
||||
const contact = React.useMemo(() => (email ? findContactByEmail(email) : undefined), [email, findContactByEmail]);
|
||||
|
||||
const items: ActionSheetItem[] = [
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('email_viewer.address_actions.copy', 'Copy address'),
|
||||
icon: <Copy size={18} color={c.textSecondary} />,
|
||||
onPress: () => {
|
||||
onClose();
|
||||
void Clipboard.setStringAsync(email).catch(() => {
|
||||
Alert.alert(t('common.error', 'Error'), t('email_viewer.address_actions.copy_failed', 'Could not copy the address.'));
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'compose',
|
||||
label: t('email_viewer.address_actions.compose', 'New message'),
|
||||
icon: <PenSquare size={18} color={c.textSecondary} />,
|
||||
onPress: () => {
|
||||
onClose();
|
||||
navigation.navigate('Compose', { prefillTo: [{ email, name: address?.name }] });
|
||||
},
|
||||
},
|
||||
contact
|
||||
? {
|
||||
key: 'contact',
|
||||
label: t('email_viewer.view_contact', 'View contact'),
|
||||
icon: <UserRound size={18} color={c.textSecondary} />,
|
||||
onPress: () => {
|
||||
onClose();
|
||||
navigation.navigate('ContactDetail', { contactId: contact.id });
|
||||
},
|
||||
}
|
||||
: {
|
||||
key: 'add',
|
||||
label: t('email_viewer.address_actions.add_contact', 'Add to contacts'),
|
||||
icon: <UserRoundPlus size={18} color={c.textSecondary} />,
|
||||
onPress: () => {
|
||||
onClose();
|
||||
navigation.navigate('ContactForm', { prefill: { email, name: address?.name } });
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ActionSheet
|
||||
visible={!!address}
|
||||
title={address?.name || email}
|
||||
subtitle={address?.name ? email : undefined}
|
||||
items={items}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from 'lucide-react-native';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import {
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, cacheBlobFile, fetchBlobBytes,
|
||||
shareBytes, writeTempFile, shareLocalFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
import { emailExportFilename } from '../../lib/download-filename';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { unwrapEmbeddedMessage, type ExtractedAttachment } from './use-body-override';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
jmapAccountId?: string;
|
||||
calendarBannerShown: boolean;
|
||||
tnefUnpacked?: boolean;
|
||||
/** Parts extracted client-side (TNEF / embedded message). */
|
||||
extracted?: ExtractedAttachment[];
|
||||
}
|
||||
|
||||
type Item =
|
||||
| { key: string; kind: 'blob'; att: Attachment; name: string; type: string; size?: number }
|
||||
| { key: string; kind: 'bytes'; part: ExtractedAttachment; name: string; type: string; size?: number };
|
||||
|
||||
const MAX_BUNDLE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Attachment chips: tap follows the "attachment click action" setting
|
||||
* (in-app preview when the type supports it, else the platform viewer /
|
||||
* save), long-press opens Preview / Open / Save / Share, and "Download all"
|
||||
* bundles every part into a zip named by the export template.
|
||||
*/
|
||||
export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tnefUnpacked, extracted }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
|
||||
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
|
||||
const exportOpts = useSettingsStore((s) => ({
|
||||
spaceReplacement: s.exportSpaceReplacement,
|
||||
lowercase: s.exportLowercase,
|
||||
stripDiacritics: s.exportStripDiacritics,
|
||||
}));
|
||||
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const previewSource = React.useRef<Item | null>(null);
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
|
||||
.map<Item>((att, i) => ({
|
||||
key: att.blobId || `att-${i}`,
|
||||
kind: 'blob',
|
||||
att,
|
||||
name: getAttachmentDisplayName(att.name, att.type),
|
||||
type: att.type || 'application/octet-stream',
|
||||
size: att.size,
|
||||
}));
|
||||
const extra = (extracted ?? []).map<Item>((part, i) => ({
|
||||
key: `x-${i}-${part.name}`,
|
||||
kind: 'bytes',
|
||||
part,
|
||||
name: getAttachmentDisplayName(part.name, part.type),
|
||||
type: part.type,
|
||||
size: part.size,
|
||||
}));
|
||||
return [...blobs, ...extra];
|
||||
}, [email, hideInlineImageAttachments, calendarBannerShown, tnefUnpacked, extracted]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>, failTitle: string) => {
|
||||
if (busyKey) return;
|
||||
setBusyKey(key);
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
Alert.alert(failTitle, e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const open = (item: Item) => run(item.key, async () => {
|
||||
if (item.kind === 'blob') await shareAttachment(item.att.blobId, item.att.name, item.att.type, email, jmapAccountId);
|
||||
else await shareBytes(item.part.bytes, item.name, item.type);
|
||||
}, t('email_viewer.attachment_failed', 'Could not open attachment'));
|
||||
|
||||
const save = (item: Item) => run(item.key, async () => {
|
||||
if (item.kind === 'blob') await downloadAttachment(item.att.blobId, item.att.name, item.att.type, email, jmapAccountId);
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not save attachment'));
|
||||
|
||||
const share = (item: Item) => run(item.key, async () => {
|
||||
if (item.kind === 'blob') await shareAttachmentViaSheet(item.att.blobId, item.att.name, item.att.type, email, jmapAccountId);
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
|
||||
|
||||
const showPreview = (item: Item) => {
|
||||
const kind = previewKindFor({ name: item.name, type: item.type });
|
||||
if (kind === 'none') { void open(item); return; }
|
||||
previewSource.current = item;
|
||||
setPreviewLoading(true);
|
||||
setPreview(null);
|
||||
void (async () => {
|
||||
try {
|
||||
if (kind === 'text' || kind === 'eml') {
|
||||
const bytes = item.kind === 'blob'
|
||||
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
|
||||
: item.part.bytes;
|
||||
if (kind === 'eml') {
|
||||
const res = await unwrapEmbeddedMessage(bytes);
|
||||
const { default: PostalMime } = await import('postal-mime');
|
||||
const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
|
||||
const from = parsed.from && 'address' in parsed.from
|
||||
? `${parsed.from.name ? `${parsed.from.name} ` : ''}<${parsed.from.address}>`
|
||||
: parsed.from?.name;
|
||||
setPreview({
|
||||
kind, name: item.name, mimeType: item.type,
|
||||
eml: { subject: parsed.subject, from, date: parsed.date, html: res.html, text: res.text },
|
||||
});
|
||||
} else {
|
||||
setPreview({ kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const file = item.kind === 'blob'
|
||||
? await cacheBlobFile(item.att.blobId, item.name, item.type, jmapAccountId)
|
||||
: writeTempFile(item.part.bytes, item.name, item.type);
|
||||
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
|
||||
} catch (e) {
|
||||
setPreview(null);
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const onTap = (item: Item) => {
|
||||
if (mailAttachmentAction === 'download') void save(item);
|
||||
else showPreview(item);
|
||||
};
|
||||
|
||||
const downloadAll = () => run('__all__', async () => {
|
||||
const total = items.reduce((n, i) => n + (i.size ?? 0), 0);
|
||||
if (total > MAX_BUNDLE_BYTES) throw new Error(t('email_viewer.bundle_too_large', 'The attachments are too large to bundle.'));
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = new JSZip();
|
||||
const used = new Set<string>();
|
||||
for (const item of items) {
|
||||
let name = item.name;
|
||||
if (used.has(name)) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
name = dot > 0 ? `${name.slice(0, dot)} (${used.size})${name.slice(dot)}` : `${name} (${used.size})`;
|
||||
}
|
||||
used.add(name);
|
||||
const bytes = item.kind === 'blob'
|
||||
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
|
||||
: item.part.bytes;
|
||||
zip.file(name, bytes);
|
||||
}
|
||||
const out = await zip.generateAsync({ type: 'uint8array' });
|
||||
const stem = emailExportFilename(email, { ...exportOpts, template: '{date} {subject}' }).replace(/\.eml$/i, '');
|
||||
await shareBytes(out, `${stem}.zip`, 'application/zip', { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not bundle attachments'));
|
||||
|
||||
const visible = expanded ? items : items.slice(0, 3);
|
||||
const sheetActions: ActionSheetItem[] = sheetItem ? [
|
||||
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
|
||||
key: 'preview',
|
||||
label: t('email_viewer.attachment_actions.preview', 'Preview'),
|
||||
icon: <Eye size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
|
||||
}] : []),
|
||||
{
|
||||
key: 'open',
|
||||
label: t('email_viewer.attachment_actions.open', 'Open'),
|
||||
icon: <ExternalLink size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void open(it); },
|
||||
},
|
||||
{
|
||||
key: 'save',
|
||||
label: t('email_viewer.attachment_actions.save', 'Save'),
|
||||
icon: <Save size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void save(it); },
|
||||
},
|
||||
{
|
||||
key: 'share',
|
||||
label: t('email_viewer.attachment_actions.share', 'Share'),
|
||||
icon: <Share2 size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void share(it); },
|
||||
},
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<View style={styles.row}>
|
||||
{visible.map((item) => {
|
||||
const busy = busyKey === item.key;
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
|
||||
onPress={() => onTap(item)}
|
||||
onLongPress={() => setSheetItem(item)}
|
||||
disabled={!!busyKey}
|
||||
accessibilityLabel={item.name}
|
||||
>
|
||||
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Paperclip size={14} color={c.textMuted} />}
|
||||
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.size}>{formatSize(item.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.footer}>
|
||||
{items.length > 3 && (
|
||||
<Pressable onPress={() => setExpanded((v) => !v)} style={styles.link} hitSlop={6}>
|
||||
<Text style={styles.linkText}>
|
||||
{expanded
|
||||
? t('email_viewer.show_less', 'Show less')
|
||||
: t('email_viewer.show_all_count', 'Show all ({count})', { count: items.length })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{items.length > 1 && (
|
||||
<Pressable onPress={downloadAll} style={styles.link} hitSlop={6} disabled={!!busyKey}>
|
||||
{busyKey === '__all__' ? <ActivityIndicator size="small" color={c.primary} /> : <FolderArchive size={14} color={c.primary} />}
|
||||
<Text style={styles.linkText}>{t('email_viewer.download_all', 'Download all')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ActionSheet
|
||||
visible={!!sheetItem}
|
||||
title={sheetItem?.name ?? ''}
|
||||
subtitle={sheetItem ? `${sheetItem.type}${sheetItem.size ? ` · ${formatSize(sheetItem.size)}` : ''}` : undefined}
|
||||
items={sheetActions}
|
||||
onClose={() => setSheetItem(null)}
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={previewLoading}
|
||||
onClose={() => { setPreview(null); setPreviewLoading(false); }}
|
||||
onOpenExternal={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
if (preview?.fileUri && it.kind === 'blob') {
|
||||
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
|
||||
} else {
|
||||
void open(it);
|
||||
}
|
||||
}}
|
||||
onShare={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
if (preview?.fileUri) {
|
||||
const { File } = require('expo-file-system') as typeof import('expo-file-system');
|
||||
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
|
||||
} else {
|
||||
void share(it);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
block: {
|
||||
backgroundColor: c.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
chipPressed: { backgroundColor: c.surfaceHover, opacity: 0.85 },
|
||||
name: { ...typography.caption, color: c.text, maxWidth: 180 },
|
||||
size: { ...typography.small, color: c.textMuted },
|
||||
footer: { flexDirection: 'row', gap: spacing.lg, alignItems: 'center' },
|
||||
link: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 4 },
|
||||
linkText: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, Modal, Pressable, ScrollView, Image, ActivityIndicator, Platform, useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import { X, ExternalLink, Share2 } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import type { PreviewKind } from '../../lib/attachment-display';
|
||||
|
||||
export interface PreviewItem {
|
||||
kind: PreviewKind;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Local file:// URI for images and PDFs. */
|
||||
fileUri?: string;
|
||||
/** Decoded text for text-like parts. */
|
||||
text?: string;
|
||||
/** Parsed embedded message for .eml previews. */
|
||||
eml?: { subject?: string; from?: string; date?: string; html: string | null; text: string | null };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
item: PreviewItem | null;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
/** Hand the file to an external app (PDF on Android, unsupported types). */
|
||||
onOpenExternal: () => void;
|
||||
onShare: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS renders them in the WebView; Android
|
||||
* has no built-in PDF renderer, so it offers the external viewer), text and
|
||||
* embedded messages. Anything else goes to the external viewer / share sheet.
|
||||
*/
|
||||
export function AttachmentPreviewModal({ item, loading, onClose, onOpenExternal, onShare }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const emlEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item?.eml) return null;
|
||||
return {
|
||||
id: `eml-preview-${item.name}`,
|
||||
threadId: '',
|
||||
mailboxIds: {},
|
||||
keywords: {},
|
||||
size: 0,
|
||||
receivedAt: item.eml.date ?? new Date().toISOString(),
|
||||
hasAttachment: false,
|
||||
subject: item.eml.subject,
|
||||
};
|
||||
}, [item]);
|
||||
|
||||
const renderBody = () => {
|
||||
if (loading || !item) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator color={c.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
switch (item.kind) {
|
||||
case 'image':
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
contentContainerStyle={styles.imageScroll}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
bouncesZoom
|
||||
>
|
||||
<Image source={{ uri: item.fileUri }} style={{ width: width - spacing.lg * 2, height: width * 1.2 }} resizeMode="contain" />
|
||||
</ScrollView>
|
||||
);
|
||||
case 'pdf':
|
||||
if (Platform.OS === 'ios' && item.fileUri) {
|
||||
return (
|
||||
<WebView
|
||||
source={{ uri: item.fileUri }}
|
||||
originWhitelist={['file://*']}
|
||||
allowFileAccess
|
||||
allowingReadAccessToURL={item.fileUri}
|
||||
style={styles.flex}
|
||||
javaScriptEnabled={false}
|
||||
setSupportMultipleWindows={false}
|
||||
onShouldStartLoadWithRequest={(req) => req.url === item.fileUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{t('email_viewer.preview.pdf_external', 'PDFs open in your PDF viewer on this device.')}</Text>
|
||||
<Pressable style={styles.button} onPress={onOpenExternal}>
|
||||
<ExternalLink size={16} color={c.primaryForeground} />
|
||||
<Text style={styles.buttonText}>{t('email_viewer.preview.open_external', 'Open in app')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<ScrollView style={styles.flex} contentContainerStyle={styles.textScroll}>
|
||||
<Text selectable style={styles.text}>{item.text ?? ''}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'eml':
|
||||
return (
|
||||
<ScrollView style={styles.flex}>
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
|
||||
</View>
|
||||
{emlEmail && (
|
||||
<EmailBodyView
|
||||
email={emlEmail}
|
||||
bodyOverride={{ html: item.eml?.html, text: item.eml?.text }}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.hint}>{t('email_viewer.preview.unsupported', 'No preview for this file type.')}</Text>
|
||||
<Pressable style={styles.button} onPress={onOpenExternal}>
|
||||
<ExternalLink size={16} color={c.primaryForeground} />
|
||||
<Text style={styles.buttonText}>{t('email_viewer.preview.open_external', 'Open in app')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={!!item || loading} animationType="slide" onRequestClose={onClose}>
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={onClose} style={styles.headerBtn} hitSlop={8}>
|
||||
<X size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.title} numberOfLines={1}>{item?.name ?? ''}</Text>
|
||||
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!item}>
|
||||
<Share2 size={20} color={item ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{renderBody()}
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
flex: { flex: 1 },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: componentSizes.headerHeight,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerBtn: { width: 40, height: 40, alignItems: 'center', justifyContent: 'center', borderRadius: radius.md },
|
||||
title: { ...typography.h3, color: c.text, flex: 1 },
|
||||
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg, gap: spacing.md },
|
||||
hint: { ...typography.body, color: c.textMuted, textAlign: 'center' },
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: c.primary,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
buttonText: { ...typography.bodySemibold, color: c.primaryForeground },
|
||||
imageScroll: { alignItems: 'center', justifyContent: 'center', padding: spacing.lg, flexGrow: 1 },
|
||||
textScroll: { padding: spacing.lg },
|
||||
text: { fontFamily: 'monospace', fontSize: 12, lineHeight: 18, color: c.text },
|
||||
emlHeader: { padding: spacing.lg, borderBottomWidth: 1, borderBottomColor: c.border, gap: 2 },
|
||||
emlSubject: { ...typography.bodySemibold, color: c.text },
|
||||
emlMeta: { ...typography.caption, color: c.textSecondary },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import type { Email, EmailAddress, Identity } from '../../api/types';
|
||||
import { spacing, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import { CalendarInvitationBanner } from './CalendarInvitationBanner';
|
||||
import { MessageHeader } from './MessageHeader';
|
||||
import { AttachmentChips } from './AttachmentChips';
|
||||
import { UnsubscribeBanner } from './UnsubscribeBanner';
|
||||
import { ReadReceiptBanner } from './ReadReceiptBanner';
|
||||
import { useBodyOverride } from './use-body-override';
|
||||
import { deriveHeaderInfo } from '../../lib/email-headers';
|
||||
import { calendarBannerShownFor } from '../../lib/attachment-display';
|
||||
|
||||
export interface MessageContentProps {
|
||||
email: Email;
|
||||
jmapAccountId?: string;
|
||||
identities: Identity[];
|
||||
currentMailboxRole?: string | null;
|
||||
themeOverride?: 'light' | 'dark' | null;
|
||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onAddressPress: (address: EmailAddress) => void;
|
||||
/** Reflect a local keyword change (e.g. `$mdnsent`) in the caller's cache. */
|
||||
onEmailPatched: (email: Email) => void;
|
||||
/** Compact header (thread cards). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything below the subject for one message: header block with details,
|
||||
* receipt / unsubscribe banners, attachment chips, calendar invitation and
|
||||
* the body. Used both by the single-message pane and by expanded thread
|
||||
* cards.
|
||||
*/
|
||||
export function MessageContent({
|
||||
email, jmapAccountId, identities, currentMailboxRole, themeOverride, onSwipe, onZoomChange,
|
||||
onToggleStar, onAddressPress, onEmailPatched, compact,
|
||||
}: MessageContentProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
|
||||
const calendarParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||
const headerInfo = React.useMemo(() => deriveHeaderInfo(email), [email]);
|
||||
const unwrap = useBodyOverride(email, jmapAccountId);
|
||||
const calendarBannerShown = calendarBannerShownFor(email, calendarParsing);
|
||||
const from = email.from?.[0];
|
||||
|
||||
const chips = (
|
||||
<AttachmentChips
|
||||
email={email}
|
||||
jmapAccountId={jmapAccountId}
|
||||
calendarBannerShown={calendarBannerShown}
|
||||
tnefUnpacked={unwrap.tnefUnpacked}
|
||||
extracted={unwrap.extracted}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.headerBlock}>
|
||||
<MessageHeader
|
||||
email={email}
|
||||
identities={identities}
|
||||
headerInfo={headerInfo}
|
||||
onToggleStar={onToggleStar}
|
||||
onAddressPress={onAddressPress}
|
||||
compact={compact}
|
||||
/>
|
||||
{attachmentPosition === 'beside-sender' && chips}
|
||||
</View>
|
||||
{attachmentPosition === 'below-header' && <View style={styles.chipsBlock}>{chips}</View>}
|
||||
|
||||
{headerInfo.readReceiptRequestedBy && (
|
||||
<ReadReceiptBanner
|
||||
email={email}
|
||||
requestedBy={headerInfo.readReceiptRequestedBy}
|
||||
jmapAccountId={jmapAccountId}
|
||||
currentMailboxRole={currentMailboxRole}
|
||||
onHandled={onEmailPatched}
|
||||
/>
|
||||
)}
|
||||
{headerInfo.list.listUnsubscribe?.preferred && (
|
||||
<UnsubscribeBanner
|
||||
email={email}
|
||||
list={headerInfo.list}
|
||||
messageKey={headerInfo.messageId ?? email.id}
|
||||
jmapAccountId={jmapAccountId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
|
||||
|
||||
<View style={styles.body}>
|
||||
{unwrap.loading ? (
|
||||
<View style={styles.loading}><ActivityIndicator color={c.primary} /></View>
|
||||
) : (
|
||||
<EmailBodyView
|
||||
email={email}
|
||||
senderEmail={from?.email}
|
||||
jmapAccountId={jmapAccountId}
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onZoomChange}
|
||||
themeOverride={themeOverride}
|
||||
bodyOverride={unwrap.override}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
headerBlock: {
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
chipsBlock: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
body: { backgroundColor: c.background },
|
||||
loading: { padding: spacing.xl, alignItems: 'center' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import {
|
||||
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
|
||||
} from 'lucide-react-native';
|
||||
import type { Email, EmailAddress, Identity } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useKeywordsStore, keywordToken } from '../../stores/keywords-store';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||
import {
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||
type AuthenticationResults, type EmailHeaderInfo,
|
||||
} from '../../lib/email-headers';
|
||||
import { formatSize } from '../../lib/attachment-display';
|
||||
import { isSmimeEmail } from '../../lib/smime';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
identities: Identity[];
|
||||
headerInfo?: EmailHeaderInfo;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
onAddressPress: (address: EmailAddress) => void;
|
||||
/** Compact variant for collapsed thread cards. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function addressLabel(a: EmailAddress): string {
|
||||
return a.name || a.email;
|
||||
}
|
||||
|
||||
function AddressList({
|
||||
label, addresses, onPress, styles,
|
||||
}: { label: string; addresses: EmailAddress[] | undefined; onPress: (a: EmailAddress) => void; styles: ReturnType<typeof makeStyles> }) {
|
||||
if (!addresses || addresses.length === 0) return null;
|
||||
return (
|
||||
<Text style={styles.recipients} numberOfLines={2}>
|
||||
<Text style={styles.recipientsLabel}>{label} </Text>
|
||||
{addresses.map((a, i) => (
|
||||
<Text key={`${a.email}-${i}`} onPress={() => onPress(a)} style={styles.recipientLink}>
|
||||
{addressLabel(a)}{i < addresses.length - 1 ? ', ' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function authTone(result?: string): 'ok' | 'warn' | 'bad' | 'none' {
|
||||
switch (result) {
|
||||
case 'pass': return 'ok';
|
||||
case 'fail':
|
||||
case 'permerror': return 'bad';
|
||||
case 'softfail':
|
||||
case 'neutral':
|
||||
case 'temperror': return 'warn';
|
||||
default: return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function AuthChip({ label, result, styles, c }: { label: string; result?: string; styles: ReturnType<typeof makeStyles>; c: ThemePalette }) {
|
||||
const tone = authTone(result);
|
||||
const color = tone === 'ok' ? c.success : tone === 'bad' ? c.error : tone === 'warn' ? c.warning : c.textMuted;
|
||||
const Icon = tone === 'ok' ? ShieldCheck : tone === 'bad' ? ShieldAlert : ShieldQuestion;
|
||||
return (
|
||||
<View style={[styles.chip, { borderColor: color }]}>
|
||||
<Icon size={12} color={color} />
|
||||
<Text style={[styles.chipText, { color }]}>{label} {result ?? '-'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, styles, mono }: { label: string; value?: string | null; styles: ReturnType<typeof makeStyles>; mono?: boolean }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<View style={styles.detailRow}>
|
||||
<Text style={styles.detailLabel}>{label}</Text>
|
||||
<Text style={[styles.detailValue, mono && styles.detailMono]} selectable>{value}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender / recipient / date block of a message, with the collapsible
|
||||
* "Show details" panel (recipients & routing, authentication, identifiers,
|
||||
* mailing list, properties) the webmail viewer shows.
|
||||
*/
|
||||
export function MessageHeader({ email, identities, headerInfo, onToggleStar, onAddressPress, compact }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
React.useEffect(() => { setShowDetails(false); }, [email.id]);
|
||||
|
||||
const info = React.useMemo(() => headerInfo ?? deriveHeaderInfo(email), [headerInfo, email]);
|
||||
const from = email.from?.[0];
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const displayDate = emailDisplayDate(email);
|
||||
const spoofed = isAuthenticationSpoofed(info.auth);
|
||||
|
||||
// "via <identity>": the message was sent by one of the user's identities or
|
||||
// received at one of them (incl. +tag); hidden when the From can't be
|
||||
// trusted so a forged From doesn't get a legitimacy badge.
|
||||
const viaIdentity = React.useMemo(() => {
|
||||
if (spoofed || identities.length === 0) return null;
|
||||
const fromEmail = from?.email?.toLowerCase();
|
||||
const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined;
|
||||
if (sentAs) return { identity: sentAs, direction: 'from' as const };
|
||||
const received = findReceivingIdentity(identities, email);
|
||||
if (received && identities.length > 1 && received.id !== identities[0].id) {
|
||||
return { identity: received, direction: 'to' as const };
|
||||
}
|
||||
return null;
|
||||
}, [spoofed, identities, from?.email, email]);
|
||||
|
||||
const tags = React.useMemo(
|
||||
() => keywordDefs.filter((kw) => !!email.keywords?.[keywordToken(kw.id)]),
|
||||
[keywordDefs, email.keywords],
|
||||
);
|
||||
const important = !!email.keywords?.$important;
|
||||
const answered = !!email.keywords?.$answered;
|
||||
const forwarded = !!email.keywords?.$forwarded;
|
||||
const smime = React.useMemo(() => isSmimeEmail(email), [email]);
|
||||
const delta = deliveryDeltaMs(email);
|
||||
|
||||
const auth: AuthenticationResults | undefined = info.auth;
|
||||
const hasAuth = !!(auth && (auth.spf || auth.dkim || auth.dmarc || auth.iprev));
|
||||
const attachmentsSummary = email.attachments?.length
|
||||
? `${email.attachments.length} · ${formatSize(email.attachments.reduce((n, a) => n + (a.size ?? 0), 0))}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.block}>
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined} hitSlop={4}>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
||||
</Pressable>
|
||||
<View style={styles.info}>
|
||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||
</Text>
|
||||
{from?.name && from?.email ? (
|
||||
<Text style={styles.email} numberOfLines={1}>{from.email}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{!compact && (
|
||||
<>
|
||||
<AddressList label={t('email_viewer.to', 'To').toLowerCase()} addresses={email.to} onPress={onAddressPress} styles={styles} />
|
||||
<AddressList label={t('email_viewer.cc', 'CC').toLowerCase()} addresses={email.cc} onPress={onAddressPress} styles={styles} />
|
||||
<AddressList label={t('email_viewer.bcc', 'BCC').toLowerCase()} addresses={email.bcc} onPress={onAddressPress} styles={styles} />
|
||||
<AddressList label={t('email_viewer.reply_to_label', 'Reply-To:').replace(/:$/, '').toLowerCase()} addresses={email.replyTo} onPress={onAddressPress} styles={styles} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.date}>{formatHeaderDate(displayDate, locale)}</Text>
|
||||
<Text style={styles.time}>
|
||||
{formatHeaderTime(displayDate, timeFormat, locale)}
|
||||
{!compact && email.size > 0 ? ` · ${formatSize(email.size)}` : ''}
|
||||
</Text>
|
||||
{onToggleStar && (
|
||||
<Pressable onPress={() => onToggleStar(email)} hitSlop={8} style={styles.star}>
|
||||
<Star size={16} color={starred ? c.starred : c.textMuted} fill={starred ? c.starred : 'transparent'} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{(tags.length > 0 || important || answered || forwarded || viaIdentity || smime) && (
|
||||
<View style={styles.badges}>
|
||||
{important && (
|
||||
<View style={[styles.badge, { backgroundColor: c.warningBg }]}>
|
||||
<Text style={[styles.badgeText, { color: c.warning }]}>{t('email_viewer.important', 'Important')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{tags.map((kw) => {
|
||||
const palette = c.tags[kw.color];
|
||||
return (
|
||||
<View key={kw.id} style={[styles.badge, { backgroundColor: palette.bg }]}>
|
||||
<View style={[styles.badgeDot, { backgroundColor: palette.dot }]} />
|
||||
<Text style={[styles.badgeText, { color: palette.text }]}>{kw.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{answered && (
|
||||
<View style={styles.badge}>
|
||||
<Reply size={11} color={c.textMuted} />
|
||||
<Text style={styles.badgeText}>{t('email_viewer.replied', 'Replied')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{forwarded && (
|
||||
<View style={styles.badge}>
|
||||
<Forward size={11} color={c.textMuted} />
|
||||
<Text style={styles.badgeText}>{t('email_viewer.forwarded', 'Forwarded')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{viaIdentity && (
|
||||
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
|
||||
<Text style={[styles.badgeText, { color: c.primary }]}>
|
||||
{viaIdentity.direction === 'from'
|
||||
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.email })
|
||||
: t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{smime && (
|
||||
<View style={styles.badge}>
|
||||
<Lock size={11} color={c.textMuted} />
|
||||
<Text style={styles.badgeText}>{t('email_viewer.smime_unsupported', 'Signed/encrypted with S/MIME - not supported on mobile')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!compact && (
|
||||
<Pressable onPress={() => setShowDetails((v) => !v)} style={styles.detailsToggle} hitSlop={6}>
|
||||
{showDetails ? <ChevronUp size={14} color={c.primary} /> : <ChevronDown size={14} color={c.primary} />}
|
||||
<Text style={styles.detailsToggleText}>
|
||||
{showDetails ? t('email_viewer.hide_details', 'Hide details') : t('email_viewer.show_details', 'Show details')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{showDetails && (
|
||||
<View style={styles.details}>
|
||||
<Text style={styles.detailsSection}>{t('email_viewer.details.recipients_routing', 'Recipients & routing')}</Text>
|
||||
<DetailRow label={t('email_viewer.from', 'From')} value={from ? `${from.name ? `${from.name} ` : ''}<${from.email}>` : undefined} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.to', 'To')} value={email.to?.map((a) => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.cc', 'CC')} value={email.cc?.map((a) => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.bcc', 'BCC')} value={email.bcc?.map((a) => a.name ? `${a.name} <${a.email}>` : a.email).join(', ')} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.details.sent', 'Sent')} value={formatFullDateTime(email.sentAt, timeFormat, locale)} styles={styles} />
|
||||
<DetailRow
|
||||
label={t('email_viewer.details.received', 'Received')}
|
||||
value={`${formatFullDateTime(email.receivedAt, timeFormat, locale)}${delta !== null && delta > 60000 ? ` · ${formatDelta(delta)} ${t('email_viewer.details.delivery_time', 'Delivery time').toLowerCase()}` : ''}`}
|
||||
styles={styles}
|
||||
/>
|
||||
|
||||
{(hasAuth || info.spamScore || info.spamLLM) && (
|
||||
<>
|
||||
<Text style={styles.detailsSection}>{t('email_viewer.details.authentication_security', 'Authentication & security')}</Text>
|
||||
<View style={styles.chips}>
|
||||
{auth?.spf && <AuthChip label="SPF" result={auth.spf.result} styles={styles} c={c} />}
|
||||
{auth?.dkim && <AuthChip label="DKIM" result={auth.dkim.result} styles={styles} c={c} />}
|
||||
{auth?.dmarc && <AuthChip label="DMARC" result={auth.dmarc.result} styles={styles} c={c} />}
|
||||
{auth?.iprev && <AuthChip label={t('email_viewer.details.iprev', 'Reverse DNS')} result={auth.iprev.result} styles={styles} c={c} />}
|
||||
</View>
|
||||
{spoofed && (
|
||||
<Text style={[styles.detailValue, { color: c.error }]}>
|
||||
{t('email_viewer.authentication.spoof_warning', 'The sender address could not be verified - this message may be spoofed.')}
|
||||
</Text>
|
||||
)}
|
||||
<DetailRow label={t('email_viewer.authentication.policy', 'Policy')} value={auth?.dmarc?.policy} styles={styles} />
|
||||
<DetailRow
|
||||
label={t('email_viewer.authentication.spam_score', 'Spam Score')}
|
||||
value={info.spamScore ? `${info.spamScore.score} (${info.spamScore.status})` : undefined}
|
||||
styles={styles}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('email_viewer.details.ai_verdict', 'AI verdict')}
|
||||
value={info.spamLLM ? `${info.spamLLM.verdict} - ${info.spamLLM.explanation}` : undefined}
|
||||
styles={styles}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.detailsSection}>{t('email_viewer.details.identifiers_threading', 'Identifiers & threading')}</Text>
|
||||
<DetailRow label={t('email_viewer.headers.message_id', 'Message ID')} value={info.messageId} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.details.in_reply_to', 'In-Reply-To')} value={email.inReplyTo?.join(', ')} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.details.references', 'References')} value={email.references?.length ? `${email.references.length}` : undefined} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.details.thread_id', 'Thread ID')} value={email.threadId} styles={styles} mono />
|
||||
|
||||
{(info.list.listId || info.list.listHelp || info.list.listPost || info.list.listUnsubscribe) && (
|
||||
<>
|
||||
<Text style={styles.detailsSection}>{t('email_viewer.details.mailing_list', 'Mailing list')}</Text>
|
||||
<DetailRow label={t('email_viewer.details.list_id', 'List ID')} value={info.list.listId} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.details.list_help', 'List help')} value={info.list.listHelp} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.details.list_post', 'List post')} value={info.list.listPost} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.details.list_unsubscribe', 'Unsubscribe')} value={info.list.listUnsubscribe?.http ?? info.list.listUnsubscribe?.mailto} styles={styles} mono />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.detailsSection}>{t('email_viewer.details.message_properties', 'Message properties')}</Text>
|
||||
<DetailRow label={t('email_viewer.details.size', 'Size')} value={formatSize(email.size)} styles={styles} />
|
||||
<DetailRow label={t('email_viewer.details.mime_type', 'MIME type')} value={email.bodyStructure?.type} styles={styles} mono />
|
||||
<DetailRow label={t('email_viewer.attachments', 'Attachments')} value={attachmentsSummary} styles={styles} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
block: {
|
||||
backgroundColor: c.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md },
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodySemibold, color: c.text },
|
||||
email: { ...typography.caption, color: c.textSecondary, marginTop: 2 },
|
||||
recipients: { ...typography.caption, color: c.textSecondary, marginTop: 4 },
|
||||
recipientsLabel: { color: c.textMuted },
|
||||
recipientLink: { color: c.textSecondary },
|
||||
meta: { alignItems: 'flex-end', paddingTop: 1, gap: 2 },
|
||||
date: { ...typography.caption, color: c.textSecondary },
|
||||
time: { ...typography.caption, color: c.textMuted },
|
||||
star: { paddingTop: 4 },
|
||||
badges: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs },
|
||||
badge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: c.surfaceHover,
|
||||
},
|
||||
badgeDot: { width: 8, height: 8, borderRadius: 4 },
|
||||
badgeText: { ...typography.small, color: c.textSecondary },
|
||||
detailsToggle: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-start' },
|
||||
detailsToggleText: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
details: {
|
||||
backgroundColor: c.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
detailsSection: { ...typography.small, color: c.textMuted, textTransform: 'uppercase', marginTop: spacing.xs, letterSpacing: 0.5 },
|
||||
detailRow: { flexDirection: 'row', gap: spacing.sm },
|
||||
detailLabel: { ...typography.caption, color: c.textMuted, width: 92 },
|
||||
detailValue: { ...typography.caption, color: c.text, flex: 1 },
|
||||
detailMono: { fontFamily: 'monospace', fontSize: 11 },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, marginBottom: spacing.xs },
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
chipText: { ...typography.small },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Send, Maximize2 } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { sendEmail, patchKeywordsForEmails } from '../../api/email';
|
||||
import { buildReplyRecipients } from '../../lib/reply-recipients';
|
||||
import { buildReplySubject } from '../../lib/subject-prefix';
|
||||
import { computeReplyThreadingHeaders } from '../../lib/email-threading';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { plainTextBody } from '../../lib/email-body';
|
||||
import { htmlToPlainText } from '../../lib/compose-html';
|
||||
import { pickEmailBody } from '../../lib/email-body';
|
||||
import { mailboxesForSiblingOf } from '../../lib/mailbox-tree';
|
||||
import { emailDisplayDate, formatFullDateTime } from '../../lib/email-date';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
jmapAccountId?: string;
|
||||
/** Open the full composer with the draft text carried over. */
|
||||
onMoreOptions: (draft: string) => void;
|
||||
/** Reflect `$answered` in the caller's cache. */
|
||||
onSent?: (email: Email) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline reply box under the message (webmail quick reply): plain-text reply
|
||||
* to the sender with the original quoted, sent through the identity that
|
||||
* received the message. "More options" hands the text to the full composer.
|
||||
*/
|
||||
export function QuickReplyBox({ email, jmapAccountId, onMoreOptions, onSent }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const [text, setText] = React.useState('');
|
||||
const [sending, setSending] = React.useState(false);
|
||||
|
||||
React.useEffect(() => { setText(''); }, [email.id]);
|
||||
|
||||
const from = email.from?.[0];
|
||||
if (!from?.email || email.keywords?.$draft) return null;
|
||||
|
||||
const send = async () => {
|
||||
const body = text.trim();
|
||||
if (!body || sending) return;
|
||||
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||
if (!identity) {
|
||||
Alert.alert(t('common.error', 'Error'), t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
||||
return;
|
||||
}
|
||||
const scoped = mailboxesForSiblingOf(mailboxes, currentMailboxId);
|
||||
const sent = scoped.find((m) => m.role === 'sent');
|
||||
const drafts = scoped.find((m) => m.role === 'drafts');
|
||||
if (!sent) {
|
||||
Alert.alert(t('common.error', 'Error'), t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const ownEmails = identities.map((i) => i.email).filter(Boolean);
|
||||
const recipients = buildReplyRecipients(
|
||||
{ from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc },
|
||||
'reply',
|
||||
ownEmails,
|
||||
);
|
||||
const picked = pickEmailBody(email);
|
||||
const original = picked.text ?? (picked.html ? htmlToPlainText(picked.html) : plainTextBody(email));
|
||||
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(
|
||||
{
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
to: recipients.to.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })),
|
||||
cc: recipients.cc.filter((r) => !!r.email).map((r) => ({ email: r.email!, name: r.name })),
|
||||
subject: buildReplySubject(email.subject, t('email_composer.prefix.reply', 'Re:')),
|
||||
textBody: `${body}\n\n${header}\n${quoted}`,
|
||||
inReplyTo: threading?.inReplyTo,
|
||||
references: threading?.references,
|
||||
},
|
||||
identity.id,
|
||||
sent.originalId ?? sent.id,
|
||||
sendDelaySeconds > 0 ? sendDelaySeconds : undefined,
|
||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
||||
);
|
||||
try {
|
||||
await patchKeywordsForEmails([email.id], { $answered: true }, jmapAccountId);
|
||||
} catch { /* the reply is out; the flag is cosmetic */ }
|
||||
onSent?.({ ...email, keywords: { ...email.keywords, $answered: true } });
|
||||
setText('');
|
||||
} catch (err) {
|
||||
Alert.alert(t('email_composer.send_failed', 'Failed to send'), err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.box}>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChangeText={setText}
|
||||
placeholder={t('email_viewer.quick_reply_placeholder', 'Write a quick reply...')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
style={styles.input}
|
||||
multiline
|
||||
editable={!sending}
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.more} onPress={() => onMoreOptions(text)} hitSlop={6}>
|
||||
<Maximize2 size={14} color={c.textSecondary} />
|
||||
<Text style={styles.moreText}>{t('email_viewer.more_options', 'More options')}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.send, (!text.trim() || sending) && styles.sendDisabled]}
|
||||
onPress={send}
|
||||
disabled={!text.trim() || sending}
|
||||
>
|
||||
{sending ? <ActivityIndicator size="small" color={c.primaryForeground} /> : <Send size={14} color={c.primaryForeground} />}
|
||||
<Text style={styles.sendText}>{sending ? t('email_viewer.sending', 'Sending...') : t('email_viewer.send', 'Send')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
box: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.surface,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
minHeight: 44,
|
||||
maxHeight: 160,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
actions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
more: { flexDirection: 'row', alignItems: 'center', gap: 4, paddingVertical: 4 },
|
||||
moreText: { ...typography.caption, color: c.textSecondary },
|
||||
send: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: c.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
sendDisabled: { opacity: 0.5 },
|
||||
sendText: { ...typography.caption, color: c.primaryForeground, fontWeight: '600' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { MailCheck } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { sendReadReceipt, patchKeywordsForEmails } from '../../api/email';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { mailboxesForSiblingOf } from '../../lib/mailbox-tree';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
/** Bare address from Disposition-Notification-To. */
|
||||
requestedBy: string;
|
||||
jmapAccountId?: string;
|
||||
/** Role of the folder the message was opened from (receipts only in received folders). */
|
||||
currentMailboxRole?: string | null;
|
||||
/** Reflect `$mdnsent` in the caller's cache. */
|
||||
onHandled: (email: Email) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-receipt (MDN, RFC 8098) request banner: Send / Ignore, or auto-send
|
||||
* in "always" mode. Either answer flags the message `$mdnsent` (RFC 3503) so
|
||||
* the request is suppressed in every client, not just here. Never offered
|
||||
* for the user's own copies, trash or spam.
|
||||
*/
|
||||
export function ReadReceiptBanner({ email, requestedBy, jmapAccountId, currentMailboxRole, onHandled }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
|
||||
const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [handledLocally, setHandledLocally] = React.useState(false);
|
||||
const autoRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => { setHandledLocally(false); }, [email.id]);
|
||||
React.useEffect(() => { if (identities.length === 0) void fetchIdentities(); }, [identities.length, fetchIdentities]);
|
||||
|
||||
const eligibleFolder = !['sent', 'drafts', 'trash', 'junk', 'spam'].includes(currentMailboxRole || '');
|
||||
const identity = React.useMemo(() => findReceivingIdentity(identities, email), [identities, email]);
|
||||
const shouldOffer =
|
||||
!!requestedBy
|
||||
&& !email.keywords?.$mdnsent
|
||||
&& !email.keywords?.$draft
|
||||
&& !handledLocally
|
||||
&& readReceiptResponse !== 'never'
|
||||
&& eligibleFolder
|
||||
&& !!identity;
|
||||
|
||||
const flagSent = React.useCallback(async () => {
|
||||
try {
|
||||
await patchKeywordsForEmails([email.id], { $mdnsent: true }, jmapAccountId);
|
||||
} catch { /* best effort - the local flag still hides the banner */ }
|
||||
onHandled({ ...email, keywords: { ...email.keywords, $mdnsent: true } });
|
||||
}, [email, jmapAccountId, onHandled]);
|
||||
|
||||
const send = React.useCallback(async (automatic: boolean) => {
|
||||
if (!identity) return;
|
||||
const scoped = mailboxesForSiblingOf(mailboxes, currentMailboxId);
|
||||
const sent = scoped.find((m) => m.role === 'sent');
|
||||
if (!sent) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||
await sendReadReceipt({
|
||||
to: requestedBy,
|
||||
fromEmail: identity.email,
|
||||
fromName: identity.name,
|
||||
identityId: identity.id,
|
||||
sentMailboxId: sent.originalId ?? sent.id,
|
||||
accountId: jmapAccountId,
|
||||
originalMessageId: email.messageId,
|
||||
originalSubject: email.subject,
|
||||
originalRecipient: identity.email,
|
||||
automatic,
|
||||
subject: t('email_viewer.read_receipt.mdn_subject', 'Read: {subject}', { subject: email.subject || '' }),
|
||||
humanText: t(
|
||||
'email_viewer.read_receipt.mdn_body',
|
||||
"This is a return receipt for the message you sent to {recipient}.\n\nNote: This receipt only acknowledges that the message was displayed on the recipient's device. There is no guarantee that the recipient has read or understood the message contents.",
|
||||
{ recipient: identity.email },
|
||||
),
|
||||
});
|
||||
await flagSent();
|
||||
}, [identity, mailboxes, currentMailboxId, requestedBy, jmapAccountId, email.messageId, email.subject, t, flagSent]);
|
||||
|
||||
// "always" mode: auto-send once when the message is opened.
|
||||
React.useEffect(() => {
|
||||
if (readReceiptResponse !== 'always' || !shouldOffer) return;
|
||||
if (autoRef.current === email.id) return;
|
||||
autoRef.current = email.id;
|
||||
setHandledLocally(true);
|
||||
send(true).catch((err) => {
|
||||
console.warn('[mdn] auto-send failed', err);
|
||||
autoRef.current = null;
|
||||
});
|
||||
}, [readReceiptResponse, shouldOffer, email.id, send]);
|
||||
|
||||
if (!shouldOffer || readReceiptResponse === 'always') return null;
|
||||
|
||||
const onSend = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await send(false);
|
||||
setHandledLocally(true);
|
||||
} catch (err) {
|
||||
Alert.alert(
|
||||
t('email_viewer.read_receipt.send_failed', 'Read receipt could not be sent'),
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onIgnore = async () => {
|
||||
setHandledLocally(true);
|
||||
await flagSent();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<MailCheck size={16} color={c.textSecondary} />
|
||||
<Text style={styles.text}>{t('email_viewer.read_receipt.prompt', 'The sender asked to be notified when you open this message.')}</Text>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : (
|
||||
<View style={styles.actions}>
|
||||
<Pressable onPress={onSend} hitSlop={8}>
|
||||
<Text style={styles.primary}>{t('email_viewer.read_receipt.send', 'Send receipt')}</Text>
|
||||
</Pressable>
|
||||
<Pressable onPress={onIgnore} hitSlop={8}>
|
||||
<Text style={styles.secondary}>{t('email_viewer.read_receipt.ignore', 'Ignore')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
text: { ...typography.caption, color: c.textSecondary, flex: 1, minWidth: 160 },
|
||||
actions: { flexDirection: 'row', gap: spacing.md },
|
||||
primary: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
secondary: { ...typography.caption, color: c.textMuted, fontWeight: '600' },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { Paperclip, Reply, ReplyAll, Forward, Star } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import SenderAvatar from '../SenderAvatar';
|
||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||
|
||||
interface Props extends MessageContentProps {
|
||||
expanded: boolean;
|
||||
onToggleExpanded: () => void;
|
||||
onReply: (mode: 'reply' | 'replyAll' | 'forward', email: Email) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One message of a conversation: a collapsed summary row (sender, date,
|
||||
* preview) that expands into the full message with its own reply / forward
|
||||
* actions - the webmail's thread-conversation-view cards.
|
||||
*/
|
||||
export function ThreadMessageCard({ expanded, onToggleExpanded, onReply, ...content }: Props) {
|
||||
const { email, onToggleStar } = content;
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const from = email.from?.[0];
|
||||
const unread = !email.keywords?.$seen;
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const date = emailDisplayDate(email);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
|
||||
<View style={styles.collapsedInfo}>
|
||||
<View style={styles.collapsedTop}>
|
||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||
{from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||
</Text>
|
||||
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
||||
</View>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{email.preview || ''}</Text>
|
||||
</View>
|
||||
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.expanded}>
|
||||
<Pressable onPress={onToggleExpanded} style={styles.collapseHandle} hitSlop={6} accessibilityLabel={t('threads.collapse', 'Collapse conversation')} />
|
||||
<MessageContent {...content} compact={false} onToggleStar={onToggleStar} />
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={styles.actionBtn} onPress={() => onReply('reply', email)} hitSlop={4}>
|
||||
<Reply size={16} color={c.textSecondary} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply', 'Reply')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={() => onReply('replyAll', email)} hitSlop={4}>
|
||||
<ReplyAll size={16} color={c.textSecondary} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.reply_all', 'Reply All')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.actionBtn} onPress={() => onReply('forward', email)} hitSlop={4}>
|
||||
<Forward size={16} color={c.textSecondary} />
|
||||
<Text style={styles.actionLabel}>{t('email_viewer.forward', 'Forward')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
collapsed: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: c.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
pressed: { backgroundColor: c.surfaceHover },
|
||||
collapsedInfo: { flex: 1, minWidth: 0 },
|
||||
collapsedTop: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
|
||||
collapsedName: { ...typography.body, color: c.text, flex: 1 },
|
||||
unread: { fontWeight: '700' },
|
||||
collapsedDate: { ...typography.small, color: c.textMuted },
|
||||
collapsedPreview: { ...typography.caption, color: c.textMuted, marginTop: 2 },
|
||||
expanded: {
|
||||
backgroundColor: c.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
},
|
||||
collapseHandle: {
|
||||
height: 6,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
actionLabel: { ...typography.caption, color: c.textSecondary },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { MailMinus, CheckCircle2, AlertCircle, X } from 'lucide-react-native';
|
||||
import type { Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { sendEmail } from '../../api/email';
|
||||
import { isValidUnsubscribeUrl, parseMailtoUrl, isOneClickUnsubscribe } from '../../lib/unsubscribe';
|
||||
import type { ListHeaders } from '../../lib/email-headers';
|
||||
import { findReceivingIdentity } from '../../lib/email-headers';
|
||||
import { mailboxesForSiblingOf } from '../../lib/mailbox-tree';
|
||||
|
||||
const DISMISSED_KEY = 'webmail:unsubscribe-dismissed:v1';
|
||||
const dismissed = new Set<string>();
|
||||
let dismissedLoaded: Promise<void> | null = null;
|
||||
|
||||
function loadDismissed(): Promise<void> {
|
||||
if (!dismissedLoaded) {
|
||||
dismissedLoaded = AsyncStorage.getItem(DISMISSED_KEY)
|
||||
.then((raw) => {
|
||||
if (!raw) return;
|
||||
for (const id of JSON.parse(raw) as string[]) dismissed.add(id);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return dismissedLoaded;
|
||||
}
|
||||
|
||||
function persistDismissed(): void {
|
||||
// Keep the list bounded - the newest few hundred Message-IDs are plenty.
|
||||
const list = Array.from(dismissed).slice(-500);
|
||||
void AsyncStorage.setItem(DISMISSED_KEY, JSON.stringify(list)).catch(() => undefined);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
list: ListHeaders;
|
||||
/** Dedupe key for dismissals (bare Message-ID, else the JMAP id). */
|
||||
messageKey: string;
|
||||
jmapAccountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* List-Unsubscribe (RFC 2369 / RFC 8058) banner. An https one-click URL is
|
||||
* POSTed directly; a plain http(s) URL opens in the in-app browser; a
|
||||
* mailto: URL is sent through the account itself (handing it to the OS mail
|
||||
* app goes nowhere for most users). Dismissals are remembered per Message-ID.
|
||||
*/
|
||||
export function UnsubscribeBanner({ email, list, messageKey, jmapAccountId }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const identities = useSettingsStore((s) => s.identities);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
||||
const [hidden, setHidden] = React.useState(true);
|
||||
const [state, setState] = React.useState<'idle' | 'busy' | 'done' | 'error'>('idle');
|
||||
|
||||
const unsub = list.listUnsubscribe;
|
||||
const method = unsub?.preferred;
|
||||
const url = method === 'http' ? unsub?.http : unsub?.mailto;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setState('idle');
|
||||
void loadDismissed().then(() => {
|
||||
if (!cancelled) setHidden(dismissed.has(messageKey));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [messageKey]);
|
||||
|
||||
if (hidden || !url || !method) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
dismissed.add(messageKey);
|
||||
persistDismissed();
|
||||
setHidden(true);
|
||||
};
|
||||
|
||||
const perform = async () => {
|
||||
if (!isValidUnsubscribeUrl(url)) { setState('error'); return; }
|
||||
setState('busy');
|
||||
try {
|
||||
if (method === 'http') {
|
||||
if (isOneClickUnsubscribe(list.listUnsubscribePost, url)) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'List-Unsubscribe=One-Click',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} else {
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
}
|
||||
} else {
|
||||
const fields = parseMailtoUrl(url);
|
||||
if (!fields) throw new Error('invalid mailto');
|
||||
const identity = findReceivingIdentity(identities, email) ?? identities[0];
|
||||
if (!identity) throw new Error(t('email_viewer.unsubscribe_banner.no_identity', 'No sending identity available'));
|
||||
const scoped = mailboxesForSiblingOf(mailboxes, currentMailboxId);
|
||||
const sent = scoped.find((m) => m.role === 'sent');
|
||||
const drafts = scoped.find((m) => m.role === 'drafts');
|
||||
if (!sent) throw new Error(t('email_composer.no_sent_folder', 'No Sent folder found'));
|
||||
await sendEmail(
|
||||
{
|
||||
from: [{ name: identity.name, email: identity.email }],
|
||||
to: fields.to.map((address) => ({ email: address })),
|
||||
cc: fields.cc?.map((address) => ({ email: address })),
|
||||
subject: fields.subject ?? '',
|
||||
textBody: fields.body ?? '',
|
||||
},
|
||||
identity.id,
|
||||
sent.originalId ?? sent.id,
|
||||
undefined,
|
||||
{ draftsMailboxId: drafts ? (drafts.originalId ?? drafts.id) : undefined, accountId: jmapAccountId },
|
||||
);
|
||||
}
|
||||
setState('done');
|
||||
setTimeout(dismiss, 3000);
|
||||
} catch (err) {
|
||||
console.warn('[unsubscribe] failed', err);
|
||||
setState('error');
|
||||
}
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
Alert.alert(
|
||||
t('email_viewer.unsubscribe_banner.confirm_title', 'Unsubscribe from this sender?'),
|
||||
method === 'http'
|
||||
? t('email_viewer.unsubscribe_banner.confirm_message_http', 'The unsubscribe page will open in a new tab.')
|
||||
: t('email_viewer.unsubscribe_banner.confirm_message_mailto', 'An unsubscribe email will be sent to the sender.'),
|
||||
[
|
||||
{ text: t('email_viewer.unsubscribe_banner.cancel', 'Cancel'), style: 'cancel' },
|
||||
{ text: t('email_viewer.unsubscribe_banner.confirm_button', 'Confirm'), onPress: () => { void perform(); } },
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<MailMinus size={14} color={c.textSecondary} />
|
||||
<Text style={styles.label}>{t('email_viewer.unsubscribe_banner.label', 'Newsletter')}</Text>
|
||||
{state === 'busy' ? (
|
||||
<ActivityIndicator size="small" color={c.primary} />
|
||||
) : state === 'done' ? (
|
||||
<View style={styles.status}>
|
||||
<CheckCircle2 size={14} color={c.success} />
|
||||
<Text style={[styles.statusText, { color: c.success }]}>
|
||||
{t(method === 'http' ? 'email_viewer.unsubscribe_banner.success_http' : 'email_viewer.unsubscribe_banner.success_mailto',
|
||||
method === 'http' ? 'Unsubscribe page opened' : 'Unsubscribe email sent')}
|
||||
</Text>
|
||||
</View>
|
||||
) : state === 'error' ? (
|
||||
<View style={styles.status}>
|
||||
<AlertCircle size={14} color={c.error} />
|
||||
<Text style={[styles.statusText, { color: c.error }]}>{t('email_viewer.unsubscribe_banner.error', 'Unable to unsubscribe')}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={confirm} hitSlop={8}>
|
||||
<Text style={styles.action}>{t('email_viewer.unsubscribe_banner.button', 'Unsubscribe')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<View style={styles.spacer} />
|
||||
<Pressable onPress={dismiss} hitSlop={8} accessibilityLabel={t('email_viewer.unsubscribe_banner.dismiss', 'Dismiss')}>
|
||||
<X size={16} color={c.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surfaceHover,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.border,
|
||||
borderRadius: radius.xs,
|
||||
},
|
||||
label: { ...typography.caption, color: c.textSecondary },
|
||||
action: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
status: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
statusText: { ...typography.caption },
|
||||
spacer: { flex: 1 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { pickEmailBody } from '../../lib/email-body';
|
||||
import { fetchBlobBytes, bytesToBase64 } from '../../lib/email-export';
|
||||
import { isRfc822Attachment, isTnefAttachment } from '../../lib/attachment-display';
|
||||
import { parseTnef } from '../../lib/tnef';
|
||||
import { sniffImageMime } from '../../lib/email-html';
|
||||
|
||||
/** A part extracted client-side (TNEF / embedded message) - bytes, not a blob. */
|
||||
export interface ExtractedAttachment {
|
||||
name: string;
|
||||
type: string;
|
||||
size: number;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface BodyOverrideState {
|
||||
/** Body to render instead of the message's own, when one was extracted. */
|
||||
override: { html?: string | null; text?: string | null } | null;
|
||||
/** Extra chips for extracted parts. */
|
||||
extracted: ExtractedAttachment[];
|
||||
/** winmail.dat was unpacked (its chip is hidden). */
|
||||
tnefUnpacked: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: BodyOverrideState = { override: null, extracted: [], tnefUnpacked: false, loading: false };
|
||||
const MAX_UNWRAP_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
function toBytes(content: ArrayBuffer | Uint8Array | string): Uint8Array {
|
||||
if (typeof content === 'string') return new TextEncoder().encode(content);
|
||||
return content instanceof Uint8Array ? content : new Uint8Array(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an embedded message/rfc822 part with postal-mime and inline its cid
|
||||
* images as data: URIs (they are not JMAP blobs, so the body view can't
|
||||
* fetch them).
|
||||
*/
|
||||
export async function unwrapEmbeddedMessage(bytes: Uint8Array): Promise<{
|
||||
html: string | null;
|
||||
text: string | null;
|
||||
attachments: ExtractedAttachment[];
|
||||
}> {
|
||||
const { default: PostalMime } = await import('postal-mime');
|
||||
const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' });
|
||||
let html = parsed.html ?? null;
|
||||
const text = parsed.text ?? null;
|
||||
const attachments: ExtractedAttachment[] = [];
|
||||
for (const att of parsed.attachments ?? []) {
|
||||
const data = toBytes(att.content);
|
||||
const cid = att.contentId?.replace(/^<|>$/g, '');
|
||||
if (cid && html && html.includes(`cid:${cid}`)) {
|
||||
const mime = sniffImageMime(data, att.mimeType);
|
||||
html = html.split(`cid:${cid}`).join(`data:${mime};base64,${bytesToBase64(data)}`);
|
||||
if (att.disposition === 'inline') continue;
|
||||
}
|
||||
attachments.push({
|
||||
name: att.filename || 'attachment',
|
||||
type: att.mimeType || 'application/octet-stream',
|
||||
size: data.byteLength,
|
||||
bytes: data,
|
||||
});
|
||||
}
|
||||
return { html, text, attachments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Two client-side unwraps the webmail does before rendering:
|
||||
* - Outlook "forward as attachment": the outer message is an empty envelope
|
||||
* around a message/rfc822 part - render the embedded message's body and
|
||||
* list its attachments (the .eml chip stays, it is a real attachment);
|
||||
* - TNEF winmail.dat: parse the container for the body and the files inside
|
||||
* it, and hide the opaque winmail.dat chip.
|
||||
*/
|
||||
export function useBodyOverride(email: Email, jmapAccountId?: string): BodyOverrideState {
|
||||
const [state, setState] = React.useState<BodyOverrideState>(EMPTY);
|
||||
|
||||
const picked = React.useMemo(() => pickEmailBody(email), [email]);
|
||||
const bodyEmpty = !picked.html && !(picked.text && picked.text.trim());
|
||||
const rfc822 = React.useMemo(
|
||||
() => (bodyEmpty ? (email.attachments ?? []).find((a): a is Attachment => isRfc822Attachment(a)) : undefined),
|
||||
[email.attachments, bodyEmpty],
|
||||
);
|
||||
const tnef = React.useMemo(
|
||||
() => (email.attachments ?? []).find((a) => isTnefAttachment(a.name, a.type)),
|
||||
[email.attachments],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!rfc822 && !tnef) {
|
||||
setState(EMPTY);
|
||||
return;
|
||||
}
|
||||
setState({ ...EMPTY, loading: true });
|
||||
void (async () => {
|
||||
try {
|
||||
if (tnef) {
|
||||
if ((tnef.size ?? 0) > MAX_UNWRAP_BYTES) throw new Error('winmail.dat too large');
|
||||
const bytes = await fetchBlobBytes(tnef.blobId, tnef.name, tnef.type, jmapAccountId);
|
||||
const res = parseTnef(bytes);
|
||||
if (cancelled) return;
|
||||
const extracted = res.attachments.map((a) => ({
|
||||
name: a.name, type: a.mimeType, size: a.data.byteLength, bytes: a.data,
|
||||
}));
|
||||
const unpacked = !!(res.htmlBody || res.body || extracted.length);
|
||||
setState({
|
||||
override: bodyEmpty && (res.htmlBody || res.body) ? { html: res.htmlBody, text: res.body } : null,
|
||||
extracted,
|
||||
tnefUnpacked: unpacked,
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rfc822) {
|
||||
if ((rfc822.size ?? 0) > MAX_UNWRAP_BYTES) throw new Error('embedded message too large');
|
||||
const bytes = await fetchBlobBytes(rfc822.blobId, rfc822.name, rfc822.type, jmapAccountId);
|
||||
const res = await unwrapEmbeddedMessage(bytes);
|
||||
if (cancelled) return;
|
||||
setState({
|
||||
override: res.html || res.text ? { html: res.html, text: res.text } : null,
|
||||
extracted: res.attachments,
|
||||
tnefUnpacked: false,
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[reader] body unwrap failed', err);
|
||||
if (!cancelled) setState(EMPTY);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [rfc822, tnef, bodyEmpty, jmapAccountId]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, isReportPart, isRfc822Attachment,
|
||||
} from '../attachment-display';
|
||||
import { buildForwardAsAttachmentPayload } from '../forward-as-attachment';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
describe('getAttachmentDisplayName', () => {
|
||||
it('uses the name, else a MIME-derived label', () => {
|
||||
expect(getAttachmentDisplayName('a.pdf', 'application/pdf')).toBe('a.pdf');
|
||||
expect(getAttachmentDisplayName(null, 'application/pdf')).toBe('Document.pdf');
|
||||
expect(getAttachmentDisplayName(undefined, 'message/rfc822')).toBe('Email.eml');
|
||||
expect(getAttachmentDisplayName(undefined, 'application/x-foo')).toBe('Attachment.foo');
|
||||
expect(getAttachmentDisplayName(undefined, undefined)).toBe('Attachment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibleAttachments', () => {
|
||||
const email = {
|
||||
attachments: [
|
||||
{ blobId: '1', type: 'image/png', name: 'logo.png', cid: 'logo', disposition: 'inline' },
|
||||
{ blobId: '2', type: 'image/png', name: 'photo.png', cid: 'photo', disposition: 'attachment' },
|
||||
{ blobId: '3', type: 'message/disposition-notification', name: undefined },
|
||||
{ blobId: '4', type: 'text/calendar', name: 'invite.ics' },
|
||||
{ blobId: '5', type: 'application/ms-tnef', name: 'winmail.dat' },
|
||||
{ blobId: '6', type: 'application/pdf', name: 'doc.pdf' },
|
||||
],
|
||||
};
|
||||
|
||||
it('hides only inline-disposition cid images, report parts, shown calendar parts and unpacked TNEF', () => {
|
||||
const shown = visibleAttachments(email, { hideInlineImageAttachments: true, calendarBannerShown: true, tnefUnpacked: true });
|
||||
expect(shown.map((a) => a.blobId)).toEqual(['2', '6']);
|
||||
});
|
||||
|
||||
it('keeps calendar parts and inline images when asked', () => {
|
||||
const shown = visibleAttachments(email, { hideInlineImageAttachments: false, calendarBannerShown: false });
|
||||
expect(shown.map((a) => a.blobId)).toEqual(['1', '2', '4', '5', '6']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewKindFor / classification', () => {
|
||||
it('classifies previewable types and refuses script-bearing ones', () => {
|
||||
expect(previewKindFor({ type: 'image/jpeg', name: 'a.jpg' })).toBe('image');
|
||||
expect(previewKindFor({ type: 'application/pdf', name: 'a.pdf' })).toBe('pdf');
|
||||
expect(previewKindFor({ type: 'text/plain', name: 'a.txt' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'application/octet-stream', name: 'notes.md' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'message/rfc822', name: 'fwd.eml' })).toBe('eml');
|
||||
expect(previewKindFor({ type: 'text/html', name: 'a.html' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'image/svg+xml', name: 'a.svg' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'application/zip', name: 'a.zip' })).toBe('none');
|
||||
expect(isReportPart('message/delivery-status')).toBe(true);
|
||||
expect(isRfc822Attachment({ type: 'application/octet-stream', name: 'x.eml' })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildForwardAsAttachmentPayload', () => {
|
||||
const email = {
|
||||
id: 'e1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1234,
|
||||
receivedAt: '2026-03-04T10:00:00Z', subject: 'Invoice', blobId: 'blob-1', hasAttachment: false,
|
||||
from: [{ email: 'a@b.co', name: 'A' }], to: [{ email: 'me@x.co' }],
|
||||
} as Email;
|
||||
|
||||
it('references the blob with a {date}-{subject}.eml name and a Fwd: subject', () => {
|
||||
const p = buildForwardAsAttachmentPayload(email, 'Fwd:', { template: '{from}-{to}', lowercase: true });
|
||||
expect(p?.subject).toBe('Fwd: Invoice');
|
||||
expect(p?.attachment).toMatchObject({ blobId: 'blob-1', type: 'message/rfc822', size: 1234 });
|
||||
expect(p?.attachment.name).toMatch(/^2026-03-04.*-invoice\.eml$/);
|
||||
expect(p?.attachment.name).not.toContain('a@b.co');
|
||||
});
|
||||
|
||||
it('returns null without a blob and leaves an empty subject alone', () => {
|
||||
expect(buildForwardAsAttachmentPayload({ ...email, blobId: undefined }, 'Fwd:')).toBeNull();
|
||||
expect(buildForwardAsAttachmentPayload({ ...email, subject: undefined }, 'Fwd:')?.subject).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { emailDisplayDate, formatHeaderTime, MAX_FUTURE_SENT_AT_MS } from '../email-date';
|
||||
|
||||
describe('emailDisplayDate', () => {
|
||||
it('prefers sentAt over receivedAt (#891)', () => {
|
||||
expect(emailDisplayDate({ sentAt: '2024-01-01T00:00:00Z', receivedAt: '2026-06-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('falls back when sentAt is missing, unparsable or forged into the future', () => {
|
||||
expect(emailDisplayDate({ receivedAt: '2026-06-01T00:00:00Z' })).toBe('2026-06-01T00:00:00Z');
|
||||
expect(emailDisplayDate({ sentAt: 'garbage', receivedAt: '2026-06-01T00:00:00Z' })).toBe('2026-06-01T00:00:00Z');
|
||||
const received = Date.parse('2026-06-01T00:00:00Z');
|
||||
const future = new Date(received + MAX_FUTURE_SENT_AT_MS + 1000).toISOString();
|
||||
expect(emailDisplayDate({ sentAt: future, receivedAt: '2026-06-01T00:00:00Z' })).toBe('2026-06-01T00:00:00Z');
|
||||
const slightlyAhead = new Date(received + 60_000).toISOString();
|
||||
expect(emailDisplayDate({ sentAt: slightlyAhead, receivedAt: '2026-06-01T00:00:00Z' })).toBe(slightlyAhead);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatHeaderTime', () => {
|
||||
it('honours the time-format setting', () => {
|
||||
const iso = '2026-06-01T15:07:00';
|
||||
expect(formatHeaderTime(iso, '24h', 'en')).toMatch(/15:07/);
|
||||
expect(formatHeaderTime(iso, '12h', 'en')).toMatch(/3:07\s?PM/i);
|
||||
expect(formatHeaderTime(undefined, '24h')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAuthenticationResults, parseSpamScore, parseSpamLLM, extractListHeaders,
|
||||
isAuthenticationSpoofed, headersToRecord, deriveHeaderInfo, deliveryDeltaMs, formatDelta,
|
||||
findReceivingIdentity,
|
||||
} from '../email-headers';
|
||||
|
||||
describe('parseAuthenticationResults', () => {
|
||||
it('parses spf/dkim/dmarc/iprev', () => {
|
||||
const r = parseAuthenticationResults(
|
||||
'mx.example; spf=pass smtp.mailfrom=news.example; dkim=pass header.d=news.example header.s=s1; dmarc=pass header.from=news.example policy.dmarc=none; iprev=pass policy.iprev=1.2.3.4',
|
||||
);
|
||||
expect(r.spf).toEqual({ result: 'pass', domain: 'news.example' });
|
||||
expect(r.dkim).toEqual({ result: 'pass', domain: 'news.example', selector: 's1' });
|
||||
expect(r.dmarc).toEqual({ result: 'pass', domain: 'news.example', policy: 'none' });
|
||||
expect(r.iprev).toEqual({ result: 'pass', ip: '1.2.3.4' });
|
||||
});
|
||||
|
||||
it('escalates a HELO hard fail over a MAIL FROM pass, but not a HELO none (#650)', () => {
|
||||
const fail = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=fail smtp.helo=b.example');
|
||||
expect(fail.spf?.result).toBe('fail');
|
||||
expect(fail.spf?.all).toHaveLength(2);
|
||||
const none = parseAuthenticationResults('spf=pass smtp.mailfrom=a.example; spf=none smtp.helo=b.example');
|
||||
expect(none.spf?.result).toBe('pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticationSpoofed', () => {
|
||||
it('flags dmarc fail and spf fail without dkim', () => {
|
||||
expect(isAuthenticationSpoofed({ dmarc: { result: 'fail' } })).toBe(true);
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'fail' } })).toBe(true);
|
||||
expect(isAuthenticationSpoofed({ spf: { result: 'fail' }, dkim: { result: 'pass' } })).toBe(false);
|
||||
expect(isAuthenticationSpoofed(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSpamScore / parseSpamLLM', () => {
|
||||
it('reads Stalwart and SpamAssassin formats', () => {
|
||||
expect(parseSpamScore('ham, score=-0.25')).toEqual({ status: 'ham', score: -0.25 });
|
||||
expect(parseSpamScore('No, score=1.5 required=5.0')).toEqual({ status: 'no', score: 1.5 });
|
||||
expect(parseSpamScore('score: 7.2')).toEqual({ status: 'spam', score: 7.2 });
|
||||
expect(parseSpamScore('nonsense')).toBeNull();
|
||||
expect(parseSpamLLM(' LEGITIMATE (Looks like a receipt) ')).toEqual({ verdict: 'LEGITIMATE', explanation: 'Looks like a receipt' });
|
||||
expect(parseSpamLLM('???')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractListHeaders / headersToRecord', () => {
|
||||
it('collects list headers case-insensitively', () => {
|
||||
const rec = headersToRecord([
|
||||
{ name: 'List-ID', value: '<news.list.example>' },
|
||||
{ name: 'list-unsubscribe', value: '<mailto:u@list.example>, <https://list.example/u>' },
|
||||
{ name: 'List-Unsubscribe-Post', value: 'List-Unsubscribe=One-Click' },
|
||||
{ name: 'Received', value: 'a' },
|
||||
{ name: 'Received', value: 'b' },
|
||||
]);
|
||||
expect(rec.Received).toEqual(['a', 'b']);
|
||||
const list = extractListHeaders(rec);
|
||||
expect(list.listId).toBe('<news.list.example>');
|
||||
expect(list.listUnsubscribe?.preferred).toBe('http');
|
||||
expect(list.listUnsubscribePost).toBe('List-Unsubscribe=One-Click');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHeaderInfo', () => {
|
||||
it('pulls the receipt request, message id and spam score out of raw headers', () => {
|
||||
const info = deriveHeaderInfo({
|
||||
headers: [
|
||||
{ name: 'Disposition-Notification-To', value: 'Sender <s@x.example>' },
|
||||
{ name: 'X-Spam-Status', value: 'No, score=0.1' },
|
||||
{ name: 'Authentication-Results', value: 'mx; dmarc=fail header.from=x.example' },
|
||||
],
|
||||
messageId: ['abc@x.example'],
|
||||
});
|
||||
expect(info.readReceiptRequestedBy).toBe('s@x.example');
|
||||
expect(info.messageId).toBe('abc@x.example');
|
||||
expect(info.spamScore?.score).toBe(0.1);
|
||||
expect(isAuthenticationSpoofed(info.auth)).toBe(true);
|
||||
});
|
||||
|
||||
it('copes with no headers', () => {
|
||||
const info = deriveHeaderInfo({ headers: undefined, messageId: null });
|
||||
expect(info.readReceiptRequestedBy).toBeNull();
|
||||
expect(info.auth).toBeUndefined();
|
||||
expect(info.list).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deliveryDeltaMs / formatDelta', () => {
|
||||
it('computes the routing delta and formats it', () => {
|
||||
expect(deliveryDeltaMs({ sentAt: '2026-01-01T10:00:00Z', receivedAt: '2026-01-01T12:05:00Z' })).toBe(125 * 60000);
|
||||
expect(deliveryDeltaMs({ sentAt: undefined, receivedAt: '2026-01-01T12:05:00Z' })).toBeNull();
|
||||
expect(formatDelta(125 * 60000)).toBe('2 h 5 min');
|
||||
expect(formatDelta(30 * 1000)).toBe('1 min');
|
||||
expect(formatDelta(26 * 3600000)).toBe('1 d 2 h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findReceivingIdentity', () => {
|
||||
const ids = [
|
||||
{ id: 'a', name: 'A', email: 'a@x.example', mayDelete: true },
|
||||
{ id: 'b', name: 'B', email: 'b@x.example', mayDelete: true },
|
||||
];
|
||||
it('matches exact and +tag recipients, else the first identity', () => {
|
||||
expect(findReceivingIdentity(ids, { to: [{ email: 'b@x.example' }] })?.id).toBe('b');
|
||||
expect(findReceivingIdentity(ids, { cc: [{ email: 'b+news@x.example' }] })?.id).toBe('b');
|
||||
expect(findReceivingIdentity(ids, { to: [{ email: 'other@y.example' }] })?.id).toBe('a');
|
||||
expect(findReceivingIdentity([], { to: [] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildMdnMessage, encodeHeaderWord, rfc5322Date } from '../mdn';
|
||||
|
||||
describe('buildMdnMessage', () => {
|
||||
it('builds a two-part multipart/report with CRLF line endings', () => {
|
||||
const raw = buildMdnMessage({
|
||||
to: 'sender@remote.example',
|
||||
fromEmail: 'me@x.example',
|
||||
fromName: 'Me',
|
||||
originalMessageId: ['orig@remote.example'],
|
||||
originalSubject: 'Hello',
|
||||
originalRecipient: 'me@x.example',
|
||||
automatic: false,
|
||||
});
|
||||
expect(raw).toContain('From: Me <me@x.example>\r\n');
|
||||
expect(raw).toContain('To: sender@remote.example\r\n');
|
||||
expect(raw).toContain('Subject: Read: Hello\r\n');
|
||||
expect(raw).toContain('In-Reply-To: <orig@remote.example>\r\n');
|
||||
expect(raw).toContain('Content-Type: multipart/report; report-type=disposition-notification;');
|
||||
expect(raw).toContain('Content-Type: message/disposition-notification\r\n');
|
||||
expect(raw).toContain('Original-Message-ID: <orig@remote.example>\r\n');
|
||||
expect(raw).toContain('Final-Recipient: rfc822;me@x.example\r\n');
|
||||
expect(raw).toContain('Disposition: manual-action/MDN-sent-manually; displayed\r\n');
|
||||
expect(raw.split('\n').every((l) => l === '' || l.endsWith('\r'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks automatic receipts and encodes non-ASCII headers', () => {
|
||||
const raw = buildMdnMessage({
|
||||
to: 'a@b.example',
|
||||
fromEmail: 'me@x.example',
|
||||
fromName: 'Jürgen',
|
||||
automatic: true,
|
||||
subject: 'Gelesen: Grüße',
|
||||
});
|
||||
expect(raw).toContain('Disposition: automatic-action/MDN-sent-automatically; displayed');
|
||||
expect(raw).toContain('From: =?UTF-8?B?');
|
||||
expect(raw).toContain('Subject: =?UTF-8?B?');
|
||||
expect(raw).not.toContain('In-Reply-To');
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('leaves ASCII header words alone', () => {
|
||||
expect(encodeHeaderWord('plain')).toBe('plain');
|
||||
});
|
||||
it('formats RFC 5322 dates in UTC', () => {
|
||||
expect(rfc5322Date(new Date(Date.UTC(2026, 4, 28, 14, 23, 0)))).toBe('Thu, 28 May 2026 14:23:00 +0000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseTnef, isTnefAttachment } from '../tnef';
|
||||
|
||||
function u32(n: number): number[] {
|
||||
return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff];
|
||||
}
|
||||
|
||||
function attr(level: number, id: number, data: number[]): number[] {
|
||||
return [level, ...u32(id), ...u32(data.length), ...data, 0, 0];
|
||||
}
|
||||
|
||||
function bytes(s: string): number[] {
|
||||
return Array.from(new TextEncoder().encode(s));
|
||||
}
|
||||
|
||||
describe('parseTnef', () => {
|
||||
it('rejects non-TNEF data', () => {
|
||||
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual({ body: null, htmlBody: null, attachments: [] });
|
||||
expect(parseTnef(new Uint8Array(2)).attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts the plain body and a simple attachment', () => {
|
||||
const data = new Uint8Array([
|
||||
...u32(0x223e9f78), 0x00, 0x00,
|
||||
...attr(0x01, 0x0002800c, bytes('Hello body')),
|
||||
...attr(0x02, 0x00069002, [0, 0, 0, 0]),
|
||||
...attr(0x02, 0x00018010, [...bytes('file.txt'), 0]),
|
||||
...attr(0x02, 0x0006800f, bytes('payload')),
|
||||
]);
|
||||
const res = parseTnef(data);
|
||||
expect(res.body).toBe('Hello body');
|
||||
expect(res.attachments).toHaveLength(1);
|
||||
expect(res.attachments[0].name).toBe('file.txt');
|
||||
expect(new TextDecoder().decode(res.attachments[0].data)).toBe('payload');
|
||||
expect(res.attachments[0].mimeType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('reads the HTML body and long filename from MAPI props', () => {
|
||||
// One PT_STRING8 property: type 0x001e, id 0x1013 (PR_BODY_HTML), count 1, value.
|
||||
const html = [...bytes('<p>hi</p>'), 0];
|
||||
const pad = (4 - (html.length % 4)) % 4;
|
||||
const mapi = [
|
||||
...u32(1),
|
||||
0x1e, 0x00, 0x13, 0x10,
|
||||
...u32(1),
|
||||
...u32(html.length), ...html, ...new Array(pad).fill(0),
|
||||
];
|
||||
const name = [...bytes('report.pdf'), 0];
|
||||
const npad = (4 - (name.length % 4)) % 4;
|
||||
const mime = [...bytes('application/pdf'), 0];
|
||||
const mpad = (4 - (mime.length % 4)) % 4;
|
||||
const attMapi = [
|
||||
...u32(2),
|
||||
0x1e, 0x00, 0x07, 0x37, ...u32(1), ...u32(name.length), ...name, ...new Array(npad).fill(0),
|
||||
0x1e, 0x00, 0x0e, 0x37, ...u32(1), ...u32(mime.length), ...mime, ...new Array(mpad).fill(0),
|
||||
];
|
||||
const data = new Uint8Array([
|
||||
...u32(0x223e9f78), 0x00, 0x00,
|
||||
...attr(0x01, 0x00069003, mapi),
|
||||
...attr(0x02, 0x00069002, [0]),
|
||||
...attr(0x02, 0x0006800f, bytes('%PDF')),
|
||||
...attr(0x02, 0x00069005, attMapi),
|
||||
]);
|
||||
const res = parseTnef(data);
|
||||
expect(res.htmlBody).toBe('<p>hi</p>');
|
||||
expect(res.attachments[0]).toMatchObject({ name: 'report.pdf', mimeType: 'application/pdf' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTnefAttachment', () => {
|
||||
it('matches by name or type', () => {
|
||||
expect(isTnefAttachment('WINMAIL.DAT', 'application/octet-stream')).toBe(true);
|
||||
expect(isTnefAttachment('x.bin', 'application/ms-tnef')).toBe(true);
|
||||
expect(isTnefAttachment('x.bin', 'application/pdf')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Attachment-chip helpers for the reader: display names for unnamed parts,
|
||||
// the parts that are hidden from the chip list, and preview classification.
|
||||
// Ports of the webmail's `getAttachmentDisplayName`, the attachment filter
|
||||
// chain in `components/email/email-viewer.tsx` and `lib/file-preview.ts`.
|
||||
|
||||
import type { Attachment, Email } from '../api/types';
|
||||
import { findCalendarAttachment } from './calendar-invitation';
|
||||
|
||||
const MIME_TYPE_LABELS: Record<string, string> = {
|
||||
'application/pdf': 'Document.pdf',
|
||||
'application/zip': 'Archive.zip',
|
||||
'application/x-zip-compressed': 'Archive.zip',
|
||||
'application/gzip': 'Archive.gz',
|
||||
'application/x-rar-compressed': 'Archive.rar',
|
||||
'application/x-7z-compressed': 'Archive.7z',
|
||||
'application/msword': 'Document.doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Document.docx',
|
||||
'application/vnd.ms-excel': 'Spreadsheet.xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Spreadsheet.xlsx',
|
||||
'application/vnd.ms-powerpoint': 'Presentation.ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Presentation.pptx',
|
||||
'text/plain': 'Text.txt',
|
||||
'text/html': 'Document.html',
|
||||
'text/csv': 'Data.csv',
|
||||
'application/json': 'Data.json',
|
||||
'application/xml': 'Data.xml',
|
||||
'application/octet-stream': 'Attachment',
|
||||
'message/rfc822': 'Email.eml',
|
||||
};
|
||||
|
||||
export function getAttachmentDisplayName(name: string | null | undefined, mimeType?: string): string {
|
||||
if (name) return name;
|
||||
if (mimeType) {
|
||||
const base = mimeType.split(';')[0].trim().toLowerCase();
|
||||
const label = MIME_TYPE_LABELS[base];
|
||||
if (label) return label;
|
||||
const sub = base.split('/')[1];
|
||||
if (sub) {
|
||||
const clean = sub.replace(/^x-/, '').replace(/^vnd\./, '');
|
||||
return `Attachment.${clean}`;
|
||||
}
|
||||
}
|
||||
return 'Attachment';
|
||||
}
|
||||
|
||||
const CAL_MIME = new Set(['text/calendar', 'application/ics', 'application/icalendar']);
|
||||
|
||||
export function isCalendarMimeType(type?: string | null): boolean {
|
||||
if (!type) return false;
|
||||
return CAL_MIME.has(type.split(';')[0].trim().toLowerCase());
|
||||
}
|
||||
|
||||
/** Machine-readable report parts (MDN receipts, DSN bounces) - never real attachments. */
|
||||
export function isReportPart(type?: string | null): boolean {
|
||||
const base = (type || '').split(';')[0].trim().toLowerCase();
|
||||
return base === 'message/disposition-notification' || base === 'message/delivery-status';
|
||||
}
|
||||
|
||||
export function isRfc822Attachment(att: Pick<Attachment, 'name' | 'type'>): boolean {
|
||||
const base = (att.type || '').split(';')[0].trim().toLowerCase();
|
||||
return base === 'message/rfc822' || /\.eml$/i.test(att.name || '');
|
||||
}
|
||||
|
||||
export function isTnefAttachment(name?: string | null, type?: string | null): boolean {
|
||||
const lowerName = (name || '').toLowerCase();
|
||||
const lowerType = (type || '').split(';')[0].trim().toLowerCase();
|
||||
return (
|
||||
lowerName === 'winmail.dat'
|
||||
|| lowerType === 'application/ms-tnef'
|
||||
|| lowerType === 'application/vnd.ms-tnef'
|
||||
);
|
||||
}
|
||||
|
||||
export interface VisibleAttachmentOptions {
|
||||
hideInlineImageAttachments: boolean;
|
||||
/** True when the calendar-invitation banner renders for this message. */
|
||||
calendarBannerShown: boolean;
|
||||
/** True when a TNEF container was unpacked (its inner files are listed instead). */
|
||||
tnefUnpacked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The attachments that get a chip. Mirrors the webmail filter chain:
|
||||
* - inline cid images are hidden only when their disposition really is
|
||||
* `inline` (an image with a Content-ID but `disposition: attachment` is a
|
||||
* real attachment the body may never reference);
|
||||
* - calendar parts are hidden while the invitation banner shows them;
|
||||
* - MDN/DSN report parts are never listed;
|
||||
* - winmail.dat is hidden once its content was extracted.
|
||||
*/
|
||||
export function visibleAttachments(email: Pick<Email, 'attachments'>, opts: VisibleAttachmentOptions): Attachment[] {
|
||||
const all = email.attachments ?? [];
|
||||
return all.filter((att) => {
|
||||
if (isReportPart(att.type)) return false;
|
||||
if (opts.calendarBannerShown && isCalendarMimeType(att.type)) return false;
|
||||
if (opts.tnefUnpacked && isTnefAttachment(att.name, att.type)) return false;
|
||||
if (
|
||||
opts.hideInlineImageAttachments
|
||||
&& att.cid
|
||||
&& att.disposition === 'inline'
|
||||
&& (att.type || '').toLowerCase().startsWith('image/')
|
||||
) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether the calendar banner will render for this message. */
|
||||
export function calendarBannerShownFor(email: Pick<Email, 'attachments'>, parsingEnabled: boolean): boolean {
|
||||
return parsingEnabled && !!findCalendarAttachment(email);
|
||||
}
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'text' | 'eml' | 'none';
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|log|json|xml|ics|vcf|yml|yaml|ini|conf|diff|patch)$/i;
|
||||
// Script-bearing types are never previewed inline (html/svg/js) - webmail lib/file-preview.ts.
|
||||
const SCRIPT_TYPES = /^(text\/html|image\/svg\+xml|application\/(x-)?javascript|text\/javascript|application\/xhtml\+xml)$/i;
|
||||
|
||||
/** How the in-app preview should render a part, if at all. */
|
||||
export function previewKindFor(att: Pick<Attachment, 'name' | 'type'>): PreviewKind {
|
||||
const base = (att.type || '').split(';')[0].trim().toLowerCase();
|
||||
const name = att.name || '';
|
||||
if (SCRIPT_TYPES.test(base) || /\.(html?|svg|js)$/i.test(name)) return 'none';
|
||||
if (isRfc822Attachment(att)) return 'eml';
|
||||
if (base.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic|avif)$/i.test(name)) return 'image';
|
||||
if (base === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
|
||||
if (base.startsWith('text/') || base === 'application/json' || base === 'application/xml' || TEXT_EXT.test(name)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Which timestamp to show for a message. Port of the webmail's
|
||||
* `lib/email-date.ts`.
|
||||
*
|
||||
* JMAP `receivedAt` is the server's internal date - the moment the message
|
||||
* landed in the store. An import, migration or backup restore that does not
|
||||
* preserve internal dates stamps every message with the import time, so a
|
||||
* whole mailbox suddenly "arrived" today (#891). `sentAt` is the RFC 5322
|
||||
* `Date` header, which survives such moves and is what Thunderbird, Outlook
|
||||
* and Apple Mail display.
|
||||
*
|
||||
* Prefer `sentAt`; fall back to `receivedAt` when the header is missing,
|
||||
* unparsable, or implausibly far in the future relative to the receive time
|
||||
* (spam forges future dates to float to the top of date-sorted lists).
|
||||
*/
|
||||
|
||||
import type { TimeFormat } from '../stores/settings-store';
|
||||
|
||||
/** How far ahead of `receivedAt` a `Date` header may be before it is ignored. */
|
||||
export const MAX_FUTURE_SENT_AT_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface DatedEmail {
|
||||
sentAt?: string | null;
|
||||
receivedAt?: string | null;
|
||||
}
|
||||
|
||||
type DisplayDate<T extends DatedEmail> = T extends { receivedAt: string } ? string : string | undefined;
|
||||
|
||||
export function emailDisplayDate<T extends DatedEmail>(email: T): DisplayDate<T> {
|
||||
const { sentAt, receivedAt } = email;
|
||||
const fallback = (receivedAt ?? undefined) as DisplayDate<T>;
|
||||
if (!sentAt) return fallback;
|
||||
const sent = Date.parse(sentAt);
|
||||
if (Number.isNaN(sent)) return fallback;
|
||||
if (receivedAt) {
|
||||
const received = Date.parse(receivedAt);
|
||||
if (!Number.isNaN(received) && sent - received > MAX_FUTURE_SENT_AT_MS) return fallback;
|
||||
}
|
||||
return sentAt as DisplayDate<T>;
|
||||
}
|
||||
|
||||
function intlLocale(locale?: string): string {
|
||||
return !locale || locale === 'en' ? 'en-US' : locale;
|
||||
}
|
||||
|
||||
/** "Mon, 3 Jun 2026" in the app locale. */
|
||||
export function formatHeaderDate(iso: string | undefined, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(intlLocale(locale), {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
/** "14:05" / "2:05 PM" honouring the app's time-format setting. */
|
||||
export function formatHeaderTime(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString(intlLocale(locale), {
|
||||
hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h',
|
||||
});
|
||||
}
|
||||
|
||||
/** Full date + time for the details panel. */
|
||||
export function formatFullDateTime(iso: string | undefined, timeFormat: TimeFormat, locale?: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString(intlLocale(locale), {
|
||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h',
|
||||
});
|
||||
}
|
||||
+49
-13
@@ -270,9 +270,30 @@ export async function downloadAttachment(
|
||||
});
|
||||
}
|
||||
|
||||
/** Share an already-materialised local file (zip bundles, extracted parts). */
|
||||
export async function shareLocalFile(file: File, mimeType: string, dialogTitle?: string): Promise<void> {
|
||||
if (Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
|
||||
/** Write bytes into the exports cache (caller shares / previews / cleans up). */
|
||||
export function writeTempFile(bytes: Uint8Array, filename: string, mimeType?: string): File {
|
||||
void sweepStaleExportFiles();
|
||||
const dir = exportsDir();
|
||||
ensureDir(dir);
|
||||
const file = new File(dir, safeAttachmentName(filename, mimeType));
|
||||
if (file.exists) file.delete();
|
||||
file.create();
|
||||
file.write(bytes);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share an already-materialised local file (zip bundles, extracted parts).
|
||||
* `forceSheet` skips the Android viewer handoff and always shows the share
|
||||
* sheet - the explicit "Share" action, as opposed to "Open".
|
||||
*/
|
||||
export async function shareLocalFile(
|
||||
file: File,
|
||||
mimeType: string,
|
||||
dialogTitle?: string,
|
||||
opts: { forceSheet?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (!opts.forceSheet && Platform.OS === 'android' && (await openWithViewer(file, mimeType))) return;
|
||||
if (!(await Sharing.isAvailableAsync())) {
|
||||
throw new Error('Sharing is not available on this device');
|
||||
}
|
||||
@@ -284,15 +305,30 @@ export async function shareLocalFile(file: File, mimeType: string, dialogTitle?:
|
||||
}
|
||||
|
||||
/** Write bytes to the exports cache and hand them to a viewer / the share sheet. */
|
||||
export async function shareBytes(bytes: Uint8Array, filename: string, mimeType: string): Promise<void> {
|
||||
void sweepStaleExportFiles();
|
||||
const dir = exportsDir();
|
||||
ensureDir(dir);
|
||||
const file = new File(dir, safeAttachmentName(filename, mimeType));
|
||||
if (file.exists) file.delete();
|
||||
file.create();
|
||||
file.write(bytes);
|
||||
await shareLocalFile(file, mimeType, filename);
|
||||
export async function shareBytes(
|
||||
bytes: Uint8Array,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
opts: { forceSheet?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const file = writeTempFile(bytes, filename, mimeType);
|
||||
await shareLocalFile(file, mimeType, filename, opts);
|
||||
}
|
||||
|
||||
/** Share a blob through the share sheet only (no viewer handoff). */
|
||||
export async function shareAttachmentViaSheet(
|
||||
blobId: string,
|
||||
name: string | undefined,
|
||||
type: string | undefined,
|
||||
email?: Email | null,
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
const filename = email
|
||||
? attachmentDownloadFilename(email, { name, type }, attachmentFileOptions())
|
||||
: safeAttachmentName(name, type);
|
||||
const mimeType = type || 'application/octet-stream';
|
||||
const downloaded = await cacheBlobFile(blobId, filename, mimeType, accountId);
|
||||
await shareLocalFile(downloaded, mimeType, filename, { forceSheet: true });
|
||||
}
|
||||
|
||||
async function authedBlobFetch(url: string): Promise<Response> {
|
||||
@@ -323,7 +359,7 @@ export async function fetchBlobBytes(
|
||||
return new Uint8Array(await r.arrayBuffer());
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
const chunk = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// Header-derived metadata for the reader: SPF/DKIM/DMARC results, spam
|
||||
// scores, mailing-list headers and read-receipt requests. Port of the
|
||||
// webmail's `lib/email-headers.ts` plus the header lookups the viewer did
|
||||
// inline; RN gets raw `headers` (RFC 8621 §4.1.3, an array of {name, value})
|
||||
// from Email/get instead of the webmail's pre-parsed fields.
|
||||
|
||||
import type { Email, Identity } from '../api/types';
|
||||
import { parseUnsubscribeUrls, type UnsubscribeUrls } from './unsubscribe';
|
||||
import { resolveReplyFrom } from './reply-identity';
|
||||
|
||||
/**
|
||||
* The identity a received message was addressed to (exact or `+tag`), for
|
||||
* the "via <identity>" badge and as the MDN sender. Falls back to the first
|
||||
* identity.
|
||||
*/
|
||||
export function findReceivingIdentity(
|
||||
identities: Identity[],
|
||||
email: Pick<Email, 'to' | 'cc' | 'bcc'>,
|
||||
): Identity | undefined {
|
||||
if (identities.length === 0) return undefined;
|
||||
const resolved = resolveReplyFrom(identities, {
|
||||
to: email.to ?? undefined, cc: email.cc ?? undefined, bcc: email.bcc ?? undefined,
|
||||
});
|
||||
if (resolved && !resolved.overrideEmail) {
|
||||
return identities.find((i) => i.id === resolved.identityId) ?? identities[0];
|
||||
}
|
||||
return identities[0];
|
||||
}
|
||||
|
||||
export type SpfResult = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror';
|
||||
export type DkimResult = 'pass' | 'fail' | 'policy' | 'neutral' | 'temperror' | 'permerror';
|
||||
export type DmarcResult = 'pass' | 'fail' | 'none';
|
||||
export type DmarcPolicy = 'reject' | 'quarantine' | 'none';
|
||||
|
||||
export interface SpfEntry {
|
||||
result: SpfResult;
|
||||
identity?: 'mailfrom' | 'helo';
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResults {
|
||||
spf?: { result: SpfResult; domain?: string; all?: SpfEntry[] };
|
||||
dkim?: { result: DkimResult; domain?: string; selector?: string };
|
||||
dmarc?: { result: DmarcResult; domain?: string; policy?: DmarcPolicy };
|
||||
iprev?: { result: 'pass' | 'fail'; ip?: string };
|
||||
}
|
||||
|
||||
type HeaderList = Email['headers'];
|
||||
|
||||
/** All values of a header, case-insensitively, in message order. */
|
||||
export function headerValues(headers: HeaderList, name: string): string[] {
|
||||
if (!headers) return [];
|
||||
const needle = name.toLowerCase();
|
||||
return headers.filter((h) => h.name.toLowerCase() === needle).map((h) => h.value.trim());
|
||||
}
|
||||
|
||||
/** First value of a header, case-insensitively. */
|
||||
export function headerValue(headers: HeaderList, name: string): string | undefined {
|
||||
return headerValues(headers, name)[0];
|
||||
}
|
||||
|
||||
/** Collapse the header array to a name → value(s) record (first-seen casing). */
|
||||
export function headersToRecord(headers: HeaderList): Record<string, string | string[]> {
|
||||
const out: Record<string, string | string[]> = {};
|
||||
const canonical = new Map<string, string>();
|
||||
for (const h of headers ?? []) {
|
||||
const key = canonical.get(h.name.toLowerCase()) ?? h.name;
|
||||
canonical.set(h.name.toLowerCase(), key);
|
||||
const value = h.value.trim();
|
||||
const existing = out[key];
|
||||
if (existing === undefined) out[key] = value;
|
||||
else if (Array.isArray(existing)) existing.push(value);
|
||||
else out[key] = [existing, value];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Severity ranking for SPF results. Higher = more severe / more actionable.
|
||||
* A hard `fail` is a definitive policy violation and must outrank ambiguous
|
||||
* states like `temperror`.
|
||||
*/
|
||||
const SPF_SEVERITY: Record<SpfResult, number> = {
|
||||
fail: 6,
|
||||
softfail: 5,
|
||||
permerror: 4,
|
||||
temperror: 3,
|
||||
neutral: 2,
|
||||
none: 1,
|
||||
pass: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the authentication results indicate the visible From identity can't
|
||||
* be trusted (i.e. the message is likely spoofed). Used to suppress UI that
|
||||
* would otherwise imply the message legitimately came from one of the user's
|
||||
* own identities (e.g. the "via <identity>" badge).
|
||||
*/
|
||||
export function isAuthenticationSpoofed(auth?: AuthenticationResults): boolean {
|
||||
if (!auth) return false;
|
||||
if (auth.dmarc?.result === 'fail') return true;
|
||||
if (auth.spf?.result === 'fail' && auth.dkim?.result !== 'pass') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse an Authentication-Results header into SPF, DKIM, DMARC, iprev results. */
|
||||
export function parseAuthenticationResults(header: string): AuthenticationResults {
|
||||
const results: AuthenticationResults = {};
|
||||
|
||||
// A single header can carry more than one SPF result when the server
|
||||
// evaluates multiple identities (HELO and MAIL FROM). Collect them all so a
|
||||
// hard fail on any identity isn't softened to an ambiguous state recorded
|
||||
// for another one.
|
||||
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
|
||||
const spfResults: SpfEntry[] = [];
|
||||
let spfM: RegExpExecArray | null;
|
||||
while ((spfM = spfRegex.exec(header)) !== null) {
|
||||
spfResults.push({
|
||||
result: spfM[1] as SpfResult,
|
||||
identity: spfM[2] as SpfEntry['identity'],
|
||||
domain: spfM[3],
|
||||
});
|
||||
}
|
||||
if (spfResults.length > 0) {
|
||||
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
|
||||
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
|
||||
// escalate the headline to a genuine failure state - a HELO `none` or
|
||||
// `neutral` must not downgrade a MAIL FROM `pass`.
|
||||
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
|
||||
let primary = spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
|
||||
for (const cur of spfResults) {
|
||||
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
|
||||
primary = cur;
|
||||
}
|
||||
}
|
||||
results.spf = {
|
||||
result: primary.result,
|
||||
domain: primary.domain,
|
||||
...(spfResults.length > 1 ? { all: spfResults } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const dkimMatch = header.match(/dkim=(\w+)(?:\s+header\.d=([^\s;]+))?(?:\s+header\.s=([^\s;]+))?/);
|
||||
if (dkimMatch) {
|
||||
results.dkim = {
|
||||
result: dkimMatch[1] as DkimResult,
|
||||
domain: dkimMatch[2],
|
||||
selector: dkimMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
const dmarcMatch = header.match(/dmarc=(\w+)(?:\s+header\.from=([^\s;]+))?(?:\s+policy\.dmarc=(\w+))?/);
|
||||
if (dmarcMatch) {
|
||||
results.dmarc = {
|
||||
result: dmarcMatch[1] as DmarcResult,
|
||||
domain: dmarcMatch[2],
|
||||
policy: dmarcMatch[3] as DmarcPolicy | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const iprevMatch = header.match(/iprev=(\w+)(?:\s+policy\.iprev=([\d.]+))?/);
|
||||
if (iprevMatch) {
|
||||
results.iprev = {
|
||||
result: iprevMatch[1] as 'pass' | 'fail',
|
||||
ip: iprevMatch[2],
|
||||
};
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Parse a spam score from X-Spam-Result / X-Spam-Status / X-Spam-Score. */
|
||||
export function parseSpamScore(header: string): { score: number; status: string } | null {
|
||||
// X-Spam-Status: "No, score=-0.25" / Stalwart X-Spam-Result: "ham, score=-0.25"
|
||||
const statusMatch = header.match(/^(Yes|No|spam|ham),?\s+score=([-\d.]+)/i);
|
||||
if (statusMatch) {
|
||||
return {
|
||||
status: statusMatch[1].toLowerCase(),
|
||||
score: parseFloat(statusMatch[2]),
|
||||
};
|
||||
}
|
||||
|
||||
const scoreMatch = header.match(/score[=:]?\s*([-\d.]+)/i);
|
||||
if (scoreMatch) {
|
||||
const score = parseFloat(scoreMatch[1]);
|
||||
return {
|
||||
score,
|
||||
status: score > 5 ? 'spam' : 'ham',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse the X-Spam-LLM header: "LEGITIMATE (explanation)" / "SPAM (...)". */
|
||||
export function parseSpamLLM(header: string): { verdict: string; explanation: string } | null {
|
||||
const trimmed = header.trim();
|
||||
const match = trimmed.match(/^(LEGITIMATE|SPAM|SUSPICIOUS)\s*\((.+)\)\s*$/i);
|
||||
if (match) {
|
||||
return {
|
||||
verdict: match[1].toUpperCase(),
|
||||
explanation: match[2].trim(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ListHeaders {
|
||||
listId?: string;
|
||||
listUnsubscribe?: UnsubscribeUrls;
|
||||
/** RFC 8058: present when the http URL accepts a one-click POST. */
|
||||
listUnsubscribePost?: string;
|
||||
listHelp?: string;
|
||||
listPost?: string;
|
||||
}
|
||||
|
||||
/** Extract list headers (List-Unsubscribe, List-Id, ...). */
|
||||
export function extractListHeaders(headers: Record<string, string | string[]>): ListHeaders {
|
||||
const result: ListHeaders = {};
|
||||
const first = (key: string): string | undefined => {
|
||||
const found = Object.keys(headers).find((k) => k.toLowerCase() === key.toLowerCase());
|
||||
if (!found) return undefined;
|
||||
const v = headers[found];
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
};
|
||||
|
||||
const listId = first('List-Id');
|
||||
if (listId) result.listId = listId;
|
||||
|
||||
const unsub = first('List-Unsubscribe');
|
||||
if (unsub) {
|
||||
const parsed = parseUnsubscribeUrls(unsub);
|
||||
if (parsed.preferred) result.listUnsubscribe = parsed;
|
||||
}
|
||||
|
||||
const post = first('List-Unsubscribe-Post');
|
||||
if (post) result.listUnsubscribePost = post;
|
||||
|
||||
const help = first('List-Help');
|
||||
if (help) result.listHelp = help;
|
||||
|
||||
const listPost = first('List-Post');
|
||||
if (listPost) result.listPost = listPost;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface EmailHeaderInfo {
|
||||
auth?: AuthenticationResults;
|
||||
spamScore?: { score: number; status: string } | null;
|
||||
spamLLM?: { verdict: string; explanation: string } | null;
|
||||
list: ListHeaders;
|
||||
/** Bare address from Disposition-Notification-To, when a receipt was requested. */
|
||||
readReceiptRequestedBy: string | null;
|
||||
/** `<...>`-stripped Message-ID header, for dedupe keys. */
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
/** Everything the reader derives from a message's raw headers, in one pass. */
|
||||
export function deriveHeaderInfo(email: Pick<Email, 'headers' | 'messageId'>): EmailHeaderInfo {
|
||||
const headers = email.headers;
|
||||
const authHeaders = headerValues(headers, 'Authentication-Results');
|
||||
// The last hop's results are prepended, so the first header is the
|
||||
// receiving server's own verdict.
|
||||
const auth = authHeaders.length ? parseAuthenticationResults(authHeaders.join('; ')) : undefined;
|
||||
|
||||
const spamRaw = headerValue(headers, 'X-Spam-Status')
|
||||
?? headerValue(headers, 'X-Spam-Result')
|
||||
?? headerValue(headers, 'X-Spam-Score');
|
||||
const spamScore = spamRaw ? parseSpamScore(spamRaw) : null;
|
||||
const llmRaw = headerValue(headers, 'X-Spam-LLM');
|
||||
const spamLLM = llmRaw ? parseSpamLLM(llmRaw) : null;
|
||||
|
||||
const list = extractListHeaders(headersToRecord(headers));
|
||||
|
||||
const dnt = headerValue(headers, 'Disposition-Notification-To');
|
||||
let readReceiptRequestedBy: string | null = null;
|
||||
if (dnt) {
|
||||
const m = dnt.match(/<([^>]+)>/);
|
||||
const addr = (m ? m[1] : dnt).trim();
|
||||
readReceiptRequestedBy = addr || null;
|
||||
}
|
||||
|
||||
const rawId = email.messageId?.[0] ?? headerValue(headers, 'Message-ID') ?? null;
|
||||
const messageId = rawId ? rawId.trim().replace(/^<|>$/g, '') : null;
|
||||
|
||||
return { auth, spamScore, spamLLM, list, readReceiptRequestedBy, messageId };
|
||||
}
|
||||
|
||||
/** Milliseconds between the Date header and delivery, or null when unknown. */
|
||||
export function deliveryDeltaMs(email: Pick<Email, 'sentAt' | 'receivedAt'>): number | null {
|
||||
if (!email.sentAt || !email.receivedAt) return null;
|
||||
const sent = Date.parse(email.sentAt);
|
||||
const received = Date.parse(email.receivedAt);
|
||||
if (Number.isNaN(sent) || Number.isNaN(received)) return null;
|
||||
return received - sent;
|
||||
}
|
||||
|
||||
/** "2 h 5 min" style rendering of a positive delta. */
|
||||
export function formatDelta(ms: number): string {
|
||||
const totalMinutes = Math.round(Math.abs(ms) / 60000);
|
||||
const days = Math.floor(totalMinutes / 1440);
|
||||
const hours = Math.floor((totalMinutes % 1440) / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const parts: string[] = [];
|
||||
if (days) parts.push(`${days} d`);
|
||||
if (hours) parts.push(`${hours} h`);
|
||||
if (minutes || parts.length === 0) parts.push(`${minutes} min`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Attachment, Email } from '../api/types';
|
||||
import { buildForwardSubject } from './subject-prefix';
|
||||
import { emailExportFilename, type EmailFilenameOptions } from './download-filename';
|
||||
|
||||
export interface ForwardAsAttachmentPayload {
|
||||
subject: string;
|
||||
attachment: Attachment & { type: 'message/rfc822' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the subject and synthetic attachment entry for forwarding a message
|
||||
* as a message/rfc822 attachment instead of inline-quoted text (e.g.
|
||||
* reporting spam to a gateway that expects the raw original, or preserving
|
||||
* exact formatting/headers). Port of the webmail's `lib/forward-as-attachment.ts`.
|
||||
*
|
||||
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped, so
|
||||
* the blobId a message already has can be attached to a new outgoing email
|
||||
* directly (the composer re-uploads when the owner is a shared account).
|
||||
*
|
||||
* `filenameOptions` carries the user's space/case/diacritics transforms; its
|
||||
* `template` is ignored: the attachment goes out to a possibly external
|
||||
* recipient, so the filename is always "{date}-{subject}.eml" - never the
|
||||
* user's own from/to naming template, which could leak names.
|
||||
*
|
||||
* Returns null when the email has no blobId.
|
||||
*/
|
||||
export function buildForwardAsAttachmentPayload(
|
||||
email: Email,
|
||||
forwardPrefix: string,
|
||||
filenameOptions?: EmailFilenameOptions,
|
||||
): ForwardAsAttachmentPayload | null {
|
||||
if (!email.blobId) return null;
|
||||
|
||||
return {
|
||||
// Match the normal Forward flow, which leaves the subject blank rather
|
||||
// than prefix-only when the original has none.
|
||||
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : '',
|
||||
attachment: {
|
||||
blobId: email.blobId,
|
||||
name: emailExportFilename(email, { ...filenameOptions, template: '{date}-{subject}' }),
|
||||
type: 'message/rfc822',
|
||||
size: email.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
|
||||
// message string. JMAP/Stalwart has no native MDN support, so the client
|
||||
// constructs the multipart/report itself and sends it via
|
||||
// blob-upload -> Email/import -> EmailSubmission/set (see api/email sendMdn).
|
||||
// Port of the webmail's `lib/mdn.ts`.
|
||||
//
|
||||
// The message has two parts:
|
||||
// 1. text/plain - human-readable explanation (rarely shown)
|
||||
// 2. message/disposition-notification - the machine-readable fields
|
||||
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
|
||||
// permits a two-part report.
|
||||
|
||||
export interface MdnOptions {
|
||||
/** Address that requested the receipt (Disposition-Notification-To) - the MDN recipient. */
|
||||
to: string;
|
||||
/** Our identity address (sender of the MDN). */
|
||||
fromEmail: string;
|
||||
/** Optional display name for the From header. */
|
||||
fromName?: string;
|
||||
/** Original Message-ID. JMAP may hand this back as a string[]. */
|
||||
originalMessageId?: string | string[] | null;
|
||||
/** Original Subject (used to build the MDN subject). */
|
||||
originalSubject?: string;
|
||||
/**
|
||||
* The address the original message was delivered to (our address/alias).
|
||||
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
|
||||
*/
|
||||
originalRecipient?: string;
|
||||
/** true => automatic-action (setting "always"); false => manual-action (user tapped send). */
|
||||
automatic?: boolean;
|
||||
/** Reporting-UA value, e.g. "mail.example; Bulwark Mobile". */
|
||||
reportingUa?: string;
|
||||
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
|
||||
subject?: string;
|
||||
/** Localized human-readable explanation (first report part). Defaults to English. */
|
||||
humanText?: string;
|
||||
}
|
||||
|
||||
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
|
||||
export function rfc5322Date(d: Date = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} `
|
||||
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): Uint8Array {
|
||||
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value);
|
||||
// Hermes ships TextEncoder; this is a defensive fallback for test runtimes.
|
||||
const out: number[] = [];
|
||||
for (const ch of value) {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
if (cp < 0x80) out.push(cp);
|
||||
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
|
||||
else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/** UTF-8 string -> base64. */
|
||||
export function utf8ToBase64(value: string): string {
|
||||
const bytes = utf8Bytes(value);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
|
||||
function base64Body(text: string): string {
|
||||
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join('\r\n');
|
||||
}
|
||||
|
||||
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
|
||||
export function encodeHeaderWord(value: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!/[^\x00-\x7F]/.test(value)) return value;
|
||||
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
|
||||
}
|
||||
|
||||
function ensureAngles(messageId: string | string[] | null | undefined): string {
|
||||
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.startsWith('<') ? trimmed : `<${trimmed}>`;
|
||||
}
|
||||
|
||||
function randomToken(): string {
|
||||
const rnd = Math.random().toString(36).slice(2);
|
||||
return `${Date.now().toString(36)}.${rnd}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
|
||||
* by the MIME standard so the bytes import/transmit verbatim.
|
||||
*/
|
||||
export function buildMdnMessage(opts: MdnOptions): string {
|
||||
const finalRecipient = opts.originalRecipient || opts.fromEmail;
|
||||
const domain = (opts.fromEmail.split('@')[1] || 'localhost').trim();
|
||||
const messageId = `<mdn.${randomToken()}@${domain}>`;
|
||||
const boundary = `----=_MDN_${randomToken()}`;
|
||||
const origMsgId = ensureAngles(opts.originalMessageId);
|
||||
|
||||
const fromHeader = opts.fromName
|
||||
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
|
||||
: opts.fromEmail;
|
||||
|
||||
const subject = encodeHeaderWord(
|
||||
opts.subject ?? `Read: ${opts.originalSubject || ''}`.trim(),
|
||||
);
|
||||
|
||||
const disposition = opts.automatic
|
||||
? 'automatic-action/MDN-sent-automatically; displayed'
|
||||
: 'manual-action/MDN-sent-manually; displayed';
|
||||
|
||||
const reportingUa = opts.reportingUa || `${domain}; Bulwark Mobile`;
|
||||
|
||||
const humanText = opts.humanText ?? [
|
||||
`This is a return receipt for the message you sent to ${finalRecipient}.`,
|
||||
'',
|
||||
'Note: This receipt only acknowledges that the message was displayed on the',
|
||||
"recipient's device. There is no guarantee that the recipient has read or",
|
||||
'understood the message contents.',
|
||||
].join('\r\n');
|
||||
|
||||
const mdnFields = [
|
||||
`Reporting-UA: ${reportingUa}`,
|
||||
`Final-Recipient: rfc822;${finalRecipient}`,
|
||||
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
|
||||
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
|
||||
`Disposition: ${disposition}`,
|
||||
].join('\r\n');
|
||||
|
||||
return [
|
||||
`Date: ${rfc5322Date()}`,
|
||||
`From: ${fromHeader}`,
|
||||
`To: ${opts.to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/report; report-type=disposition-notification;',
|
||||
`\tboundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'',
|
||||
base64Body(humanText),
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: message/disposition-notification',
|
||||
'Content-Transfer-Encoding: 7bit',
|
||||
'',
|
||||
mdnFields,
|
||||
'',
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BodyPart, Email } from '../api/types';
|
||||
|
||||
/**
|
||||
* True when the message is S/MIME signed or encrypted (pkcs7 parts). Port of
|
||||
* the webmail client's `isSmimeEmail`. The mobile app has no crypto path, so
|
||||
* the reader only shows a "not supported" note for these.
|
||||
*/
|
||||
export function isSmimeEmail(email: Pick<Email, 'bodyStructure' | 'attachments'>): boolean {
|
||||
const types: string[] = [];
|
||||
const collect = (part: (BodyPart & { subParts?: BodyPart[] }) | undefined): void => {
|
||||
if (!part) return;
|
||||
if (part.type) types.push(part.type.toLowerCase());
|
||||
part.subParts?.forEach(collect);
|
||||
};
|
||||
collect(email.bodyStructure as (BodyPart & { subParts?: BodyPart[] }) | undefined);
|
||||
email.attachments?.forEach((att) => types.push((att.type || '').toLowerCase()));
|
||||
return types.some((type) =>
|
||||
type.includes('pkcs7')
|
||||
|| type.includes('x-pkcs7')
|
||||
|| type === 'application/pkcs7-mime'
|
||||
|| type === 'application/pkcs7-signature',
|
||||
);
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Minimal TNEF (Transport Neutral Encapsulation Format) parser. Port of the
|
||||
* webmail's `lib/tnef.ts` without the debug logging.
|
||||
*
|
||||
* Parses winmail.dat files sent by Microsoft Outlook to extract the HTML
|
||||
* body, plain text body, and embedded attachments.
|
||||
*
|
||||
* Reference: MS-OXTNEF / MS-TNEF specification.
|
||||
*/
|
||||
|
||||
const TNEF_SIGNATURE = 0x223e9f78;
|
||||
|
||||
const LVL_MESSAGE = 0x01;
|
||||
const LVL_ATTACHMENT = 0x02;
|
||||
|
||||
// Message-level attribute IDs
|
||||
const attBody = 0x0002800c;
|
||||
const attMAPIProps = 0x00069003;
|
||||
|
||||
// Attachment-level attribute IDs
|
||||
const attAttachRenddata = 0x00069002;
|
||||
const attAttachData = 0x0006800f;
|
||||
const attAttachTitle = 0x00018010;
|
||||
const attAttachment = 0x00069005;
|
||||
|
||||
// MAPI property types
|
||||
const PT_SHORT = 0x0002;
|
||||
const PT_LONG = 0x0003;
|
||||
const PT_BOOLEAN = 0x000b;
|
||||
const PT_STRING8 = 0x001e;
|
||||
const PT_UNICODE = 0x001f;
|
||||
const PT_BINARY = 0x0102;
|
||||
const PT_SYSTIME = 0x0040;
|
||||
const PT_CLSID = 0x0048;
|
||||
const PT_I8 = 0x0014;
|
||||
|
||||
const MV_FLAG = 0x1000;
|
||||
|
||||
// MAPI property IDs
|
||||
const PR_BODY = 0x1000;
|
||||
const PR_BODY_HTML = 0x1013;
|
||||
const PR_ATTACH_LONG_FILENAME = 0x3707;
|
||||
const PR_ATTACH_MIME_TAG = 0x370e;
|
||||
const PR_ATTACH_DATA_BIN = 0x3701;
|
||||
|
||||
export interface TnefAttachment {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface TnefResult {
|
||||
body: string | null;
|
||||
htmlBody: string | null;
|
||||
attachments: TnefAttachment[];
|
||||
}
|
||||
|
||||
class BinaryReader {
|
||||
private view: DataView;
|
||||
private offset: number;
|
||||
private bytes: Uint8Array;
|
||||
|
||||
constructor(data: Uint8Array) {
|
||||
this.bytes = data;
|
||||
this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
readUint8(): number {
|
||||
const val = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return val;
|
||||
}
|
||||
|
||||
readUint16LE(): number {
|
||||
const val = this.view.getUint16(this.offset, true);
|
||||
this.offset += 2;
|
||||
return val;
|
||||
}
|
||||
|
||||
readUint32LE(): number {
|
||||
const val = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return val;
|
||||
}
|
||||
|
||||
readBytes(length: number): Uint8Array {
|
||||
const slice = this.bytes.slice(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return slice;
|
||||
}
|
||||
|
||||
skip(n: number): void {
|
||||
this.offset += n;
|
||||
}
|
||||
|
||||
get remaining(): number {
|
||||
return this.bytes.byteLength - this.offset;
|
||||
}
|
||||
}
|
||||
|
||||
function pad4(len: number): number {
|
||||
return (4 - (len % 4)) % 4;
|
||||
}
|
||||
|
||||
function decodeUtf8(bytes: Uint8Array): string {
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
}
|
||||
|
||||
function decodeUtf16le(bytes: Uint8Array): string {
|
||||
try {
|
||||
return new TextDecoder('utf-16le').decode(bytes);
|
||||
} catch {
|
||||
// Hermes' TextDecoder only guarantees utf-8; decode by hand.
|
||||
let out = '';
|
||||
for (let i = 0; i + 1 < bytes.length; i += 2) {
|
||||
out += String.fromCharCode(bytes[i] | (bytes[i + 1] << 8));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function readMAPIFixedValue(r: BinaryReader, propType: number): Uint8Array | number | null {
|
||||
switch (propType) {
|
||||
case PT_SHORT: {
|
||||
const val = r.readUint16LE();
|
||||
r.skip(2);
|
||||
return val;
|
||||
}
|
||||
case PT_LONG:
|
||||
case PT_BOOLEAN:
|
||||
return r.readUint32LE();
|
||||
case PT_I8:
|
||||
case PT_SYSTIME:
|
||||
return r.readBytes(8);
|
||||
case PT_CLSID:
|
||||
return r.readBytes(16);
|
||||
default:
|
||||
if (r.remaining >= 4) return r.readBytes(4);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMAPIVarValue(r: BinaryReader): Uint8Array | null {
|
||||
if (r.remaining < 4) return null;
|
||||
const length = r.readUint32LE();
|
||||
if (length > r.remaining) return null;
|
||||
const data = r.readBytes(length);
|
||||
r.skip(pad4(length));
|
||||
return data;
|
||||
}
|
||||
|
||||
function isVarLengthType(baseType: number): boolean {
|
||||
return baseType === PT_STRING8 || baseType === PT_UNICODE || baseType === PT_BINARY;
|
||||
}
|
||||
|
||||
function decodeMAPIString(data: Uint8Array, propType: number): string {
|
||||
if (propType === PT_UNICODE) {
|
||||
let len = data.byteLength;
|
||||
if (len >= 2 && data[len - 1] === 0 && data[len - 2] === 0) len -= 2;
|
||||
return decodeUtf16le(data.subarray(0, len));
|
||||
}
|
||||
let len = data.byteLength;
|
||||
if (len >= 1 && data[len - 1] === 0) len -= 1;
|
||||
return decodeUtf8(data.subarray(0, len));
|
||||
}
|
||||
|
||||
function parseMAPIProps(data: Uint8Array): Map<number, { type: number; value: Uint8Array | number | null }> {
|
||||
const props = new Map<number, { type: number; value: Uint8Array | number | null }>();
|
||||
const r = new BinaryReader(data);
|
||||
|
||||
if (r.remaining < 4) return props;
|
||||
const count = r.readUint32LE();
|
||||
|
||||
for (let i = 0; i < count && r.remaining >= 4; i++) {
|
||||
const propType = r.readUint16LE();
|
||||
const propID = r.readUint16LE();
|
||||
|
||||
// Named properties (ID >= 0x8000) carry extra GUID + name data
|
||||
if (propID >= 0x8000) {
|
||||
if (r.remaining < 20) break;
|
||||
r.skip(16);
|
||||
const kind = r.readUint32LE();
|
||||
if (kind === 0) {
|
||||
if (r.remaining < 4) break;
|
||||
r.skip(4);
|
||||
} else {
|
||||
if (r.remaining < 4) break;
|
||||
const nameLen = r.readUint32LE();
|
||||
if (nameLen > r.remaining) break;
|
||||
r.skip(nameLen);
|
||||
r.skip(pad4(nameLen));
|
||||
}
|
||||
}
|
||||
|
||||
const baseType = propType & 0x0fff;
|
||||
const isMultiValue = (propType & MV_FLAG) !== 0;
|
||||
|
||||
if (isVarLengthType(baseType)) {
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
let lastValue: Uint8Array | null = null;
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
lastValue = readMAPIVarValue(r);
|
||||
}
|
||||
if (!isMultiValue && lastValue) {
|
||||
props.set(propID, { type: propType, value: lastValue });
|
||||
}
|
||||
} else if (isMultiValue) {
|
||||
if (r.remaining < 4) break;
|
||||
const valueCount = r.readUint32LE();
|
||||
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
||||
readMAPIFixedValue(r, baseType);
|
||||
}
|
||||
} else {
|
||||
const value = readMAPIFixedValue(r, baseType);
|
||||
props.set(propID, { type: propType, value });
|
||||
}
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
/** Parse a TNEF (winmail.dat) file and extract the body and attachments. */
|
||||
export function parseTnef(data: Uint8Array): TnefResult {
|
||||
const result: TnefResult = { body: null, htmlBody: null, attachments: [] };
|
||||
|
||||
if (data.byteLength < 6) return result;
|
||||
|
||||
const r = new BinaryReader(data);
|
||||
const signature = r.readUint32LE();
|
||||
if (signature !== TNEF_SIGNATURE) return result;
|
||||
|
||||
r.skip(2); // legacy key
|
||||
|
||||
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
||||
|
||||
while (r.remaining >= 11) {
|
||||
const level = r.readUint8();
|
||||
const attrID = r.readUint32LE();
|
||||
const attrLen = r.readUint32LE();
|
||||
|
||||
if (attrLen > r.remaining - 2) break;
|
||||
|
||||
const attrData = r.readBytes(attrLen);
|
||||
r.skip(2); // checksum
|
||||
|
||||
if (level === LVL_MESSAGE) {
|
||||
if (attrID === attBody) {
|
||||
result.body = decodeUtf8(attrData);
|
||||
} else if (attrID === attMAPIProps) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
|
||||
const htmlProp = props.get(PR_BODY_HTML);
|
||||
if (htmlProp?.value instanceof Uint8Array) {
|
||||
const baseType = htmlProp.type & 0x0fff;
|
||||
result.htmlBody = baseType === PT_STRING8 || baseType === PT_UNICODE
|
||||
? decodeMAPIString(htmlProp.value, baseType)
|
||||
: decodeUtf8(htmlProp.value);
|
||||
}
|
||||
|
||||
if (!result.body) {
|
||||
const bodyProp = props.get(PR_BODY);
|
||||
if (bodyProp?.value instanceof Uint8Array) {
|
||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0fff);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (level === LVL_ATTACHMENT) {
|
||||
if (attrID === attAttachRenddata) {
|
||||
if (curAttach?.data) {
|
||||
result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, data: curAttach.data });
|
||||
}
|
||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||
} else if (attrID === attAttachTitle && curAttach) {
|
||||
let len = attrData.byteLength;
|
||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||
curAttach.name = decodeUtf8(attrData.subarray(0, len));
|
||||
} else if (attrID === attAttachData && curAttach) {
|
||||
curAttach.data = attrData;
|
||||
} else if (attrID === attAttachment && curAttach) {
|
||||
const props = parseMAPIProps(attrData);
|
||||
|
||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||
if (longName?.value instanceof Uint8Array) {
|
||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0fff);
|
||||
}
|
||||
|
||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||
if (mimeTag?.value instanceof Uint8Array) {
|
||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0fff);
|
||||
}
|
||||
|
||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||
if (attachData?.value instanceof Uint8Array) {
|
||||
curAttach.data = attachData.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (curAttach?.data) {
|
||||
result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, data: curAttach.data });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Check if a MIME attachment is a TNEF (winmail.dat) file. */
|
||||
export function isTnefAttachment(name?: string | null, type?: string): boolean {
|
||||
const lowerName = (name || '').toLowerCase();
|
||||
const lowerType = (type || '').toLowerCase();
|
||||
return (
|
||||
lowerName === 'winmail.dat'
|
||||
|| lowerType === 'application/ms-tnef'
|
||||
|| lowerType === 'application/vnd.ms-tnef'
|
||||
);
|
||||
}
|
||||
@@ -3,23 +3,31 @@ import {
|
||||
View, Text, StyleSheet, ScrollView, ActivityIndicator, Pressable, Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { ArrowLeft, Share2 } from 'lucide-react-native';
|
||||
import { ArrowLeft, Share2, Copy, Check } from 'lucide-react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { fetchRawEmail, shareEmailEml } from '../lib/email-export';
|
||||
import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'EmailSource'>;
|
||||
|
||||
// A single <Text> holding a multi-megabyte string stalls the JS thread and
|
||||
// the native text layout; anything past this is offered via share instead.
|
||||
const MAX_DISPLAY_BYTES = 1024 * 1024;
|
||||
|
||||
export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// Blobs are account-scoped, so a message from a shared/group mailbox has
|
||||
// to be fetched against its owning account.
|
||||
const { blobId, subject, jmapAccountId } = route.params;
|
||||
const [raw, setRaw] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -28,17 +36,31 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
const text = await fetchRawEmail(blobId, jmapAccountId);
|
||||
if (!cancelled) setRaw(text);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source');
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : t('email_viewer.load_failed', 'Failed to load email source'));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [blobId, jmapAccountId]);
|
||||
}, [blobId, jmapAccountId, t]);
|
||||
|
||||
const truncated = !!raw && raw.length > MAX_DISPLAY_BYTES;
|
||||
const shown = React.useMemo(() => (raw && truncated ? raw.slice(0, MAX_DISPLAY_BYTES) : raw), [raw, truncated]);
|
||||
|
||||
const onShare = async () => {
|
||||
try {
|
||||
await shareEmailEml(blobId, undefined, subject, jmapAccountId);
|
||||
} catch (e) {
|
||||
Alert.alert('Share failed', e instanceof Error ? e.message : String(e));
|
||||
Alert.alert(t('email_viewer.export_failed', 'Share failed'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!raw) return;
|
||||
try {
|
||||
await Clipboard.setStringAsync(raw);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
Alert.alert(t('common.error', 'Error'), e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,8 +71,17 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
<ArrowLeft size={22} color={c.text} />
|
||||
</Pressable>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{subject || 'Email source'}
|
||||
{subject || t('email_viewer.email_source', 'Email Source')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={onCopy}
|
||||
style={styles.headerBtn}
|
||||
hitSlop={8}
|
||||
disabled={!raw}
|
||||
accessibilityLabel={t('email_viewer.copy_source', 'Copy to clipboard')}
|
||||
>
|
||||
{copied ? <Check size={20} color={c.success} /> : <Copy size={20} color={raw ? c.text : c.textMuted} />}
|
||||
</Pressable>
|
||||
<Pressable onPress={onShare} style={styles.headerBtn} hitSlop={8} disabled={!raw}>
|
||||
<Share2 size={20} color={raw ? c.text : c.textMuted} />
|
||||
</Pressable>
|
||||
@@ -66,7 +97,17 @@ export default function EmailSourceScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.body}>
|
||||
<Text selectable style={styles.source}>{raw}</Text>
|
||||
{truncated && (
|
||||
<View style={styles.notice}>
|
||||
<Text style={styles.noticeText}>
|
||||
{t('email_viewer.source_too_large', 'The message is too large to display in full. Share the full source instead.')}
|
||||
</Text>
|
||||
<Pressable onPress={onShare} hitSlop={6}>
|
||||
<Text style={styles.noticeAction}>{t('email_viewer.export_email', 'Export as .eml')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
<Text selectable style={styles.source}>{shown}</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -92,6 +133,15 @@ function makeStyles(c: ThemePalette) {
|
||||
loading: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
||||
error: { ...typography.body, color: c.error, paddingHorizontal: spacing.lg, textAlign: 'center' },
|
||||
body: { padding: spacing.lg },
|
||||
notice: {
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: c.warningBg,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
noticeText: { ...typography.caption, color: c.warning },
|
||||
noticeAction: { ...typography.caption, color: c.primary, fontWeight: '600' },
|
||||
source: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
|
||||
+509
-483
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user