@novacraft-engineering/mailbox 0.4.21 → 0.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/app/mail/page.tsx +55 -10
  2. package/package.json +1 -1
package/app/mail/page.tsx CHANGED
@@ -663,6 +663,12 @@ function htmlToQuoteText(html: string | null, text: string | null): string {
663
663
  * imported without ids fall back to the names mail clients give such parts. Anything
664
664
  * larger, or not an image, is a real file.
665
665
  */
666
+ // Some senders put the plain-text body straight into the HTML part. Rendered as markup its
667
+ // line breaks are just whitespace and the message collapses into one paragraph, so a part
668
+ // carrying no tags has to be treated as the text it actually is.
669
+ const hasMarkup = (html: string | null | undefined): html is string =>
670
+ typeof html === 'string' && /<[a-z!/][^>]*>/i.test(html)
671
+
666
672
  const INLINE_IMAGE_MAX_BYTES = 150_000
667
673
  const INLINE_IMAGE_NAME = /^(image\d*|outlook-[\w-]+|blocked|[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(png|gif|jpe?g)$/i
668
674
  function inlineAttachmentNames(message: InboundEmail): Set<string> {
@@ -3712,6 +3718,9 @@ export default function DevMailPage() {
3712
3718
  .catch(() => {})
3713
3719
  .finally(() => {
3714
3720
  if (flags.read === true) ids.forEach(id => pendingRead.current.delete(id))
3721
+ // The starred tally counts conversations, not messages, so it cannot be derived
3722
+ // from the ids alone — the server says what it is now.
3723
+ if (flags.starred !== undefined) loadCounts(true)
3715
3724
  })
3716
3725
  },
3717
3726
  [apiHeaders],
@@ -3748,6 +3757,40 @@ export default function DevMailPage() {
3748
3757
  },
3749
3758
  [apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
3750
3759
  )
3760
+ /**
3761
+ * The row carries the newest message's id, but the star belongs to the conversation —
3762
+ * starring the newest message leaves an older starred one lit and the row never clears.
3763
+ */
3764
+ const toggleRowStar = useCallback(
3765
+ (id: string, threadId: string | null, starred: boolean) => {
3766
+ if (!threadId) {
3767
+ setInboundFlag(threadIds(id), { starred })
3768
+ return
3769
+ }
3770
+ setInboxEmails(list =>
3771
+ list.map(entry => (threadKeys.get(entry.id) === threadKeys.get(id) ? { ...entry, starred } : entry)),
3772
+ )
3773
+ setThreads(list =>
3774
+ list.map(thread =>
3775
+ thread.threadId === threadId
3776
+ ? { ...thread, starredCount: starred ? Math.max(1, thread.count) : 0 }
3777
+ : thread,
3778
+ ),
3779
+ )
3780
+ setServerCounts(current =>
3781
+ current ? { ...current, starred: Math.max(0, current.starred + (starred ? 1 : -1)) } : current,
3782
+ )
3783
+ fetch('/api/mail/inbox', {
3784
+ method: 'PATCH',
3785
+ headers: apiHeaders(),
3786
+ body: JSON.stringify({ threadId, starred }),
3787
+ })
3788
+ .then(() => loadCounts(true))
3789
+ .catch(() => {})
3790
+ },
3791
+ [apiHeaders, setInboundFlag, threadIds, threadKeys, loadCounts],
3792
+ )
3793
+
3751
3794
  useEffect(() => { swipeCommitRef.current = commitRowFlags }, [commitRowFlags])
3752
3795
  useEffect(() => { setInboundFlagRef.current = setInboundFlag }, [setInboundFlag])
3753
3796
 
@@ -4978,7 +5021,7 @@ export default function DevMailPage() {
4978
5021
  img { max-width: 100%; }
4979
5022
  </style>`
4980
5023
  doc.open()
4981
- doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title>${printCss}</head><body>${meta}${html ? stripOwnPixel(html) : `<pre style="font-family:Arial,sans-serif;white-space:pre-wrap;">${escapeHtml(text ?? '')}</pre>`}</body></html>`)
5024
+ doc.write(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(subject)}</title>${printCss}</head><body>${meta}${hasMarkup(html) ? stripOwnPixel(html) : `<pre style="font-family:Arial,sans-serif;white-space:pre-wrap;">${escapeHtml(text?.trim() || html || '')}</pre>`}</body></html>`)
4982
5025
  doc.close()
4983
5026
  frame.contentWindow?.focus()
4984
5027
  window.setTimeout(() => {
@@ -5881,13 +5924,14 @@ export default function DevMailPage() {
5881
5924
  if (readerMode === 'html') return <pre className={styles.readerSource}>{formatHtmlSource(message.html ?? '')}</pre>
5882
5925
  if (readerMode === 'raw') return <pre className={styles.readerSource}>{rawInboundMessage(message)}</pre>
5883
5926
  // preview
5884
- if (!message.html) {
5885
- const plain = splitQuotedText(message.text ?? '')
5927
+ if (!hasMarkup(message.html)) {
5928
+ const source = message.text?.trim() ? message.text : (message.html ?? '')
5929
+ const plain = splitQuotedText(source)
5886
5930
  const textShown = quoteOpen.has(message.id)
5887
5931
  return (
5888
5932
  <>
5889
- {(textShown ? message.text : plain.head)
5890
- ? <pre className={styles.readerText}>{textShown ? message.text : plain.head}</pre>
5933
+ {(textShown ? source : plain.head)
5934
+ ? <pre className={styles.readerText}>{textShown ? source : plain.head}</pre>
5891
5935
  : renderBodylessAttachment(inboundAttachments[message.id] ?? []) ?? <pre className={styles.readerText}>(no content)</pre>}
5892
5936
  {plain.tail && renderQuoteLine(message.id, textShown)}
5893
5937
  </>
@@ -5936,7 +5980,7 @@ export default function DevMailPage() {
5936
5980
  const renderSentBody = (sent: SentEmail) => {
5937
5981
  const detail = detailCache[sent.id]
5938
5982
  if (!detail) return <div className={styles.threadPending}>Loading message…</div>
5939
- if (detail.html && detail.html.trim()) {
5983
+ if (hasMarkup(detail.html)) {
5940
5984
  return (
5941
5985
  <iframe
5942
5986
  className={styles.threadFrame}
@@ -5952,8 +5996,9 @@ export default function DevMailPage() {
5952
5996
  />
5953
5997
  )
5954
5998
  }
5955
- return detail.text?.trim()
5956
- ? <pre className={styles.readerText}>{detail.text}</pre>
5999
+ const plainSent = detail.text?.trim() ? detail.text : (detail.html ?? '').trim()
6000
+ return plainSent
6001
+ ? <pre className={styles.readerText}>{plainSent}</pre>
5957
6002
  : renderBodylessAttachment(detail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
5958
6003
  }
5959
6004
 
@@ -7891,12 +7936,12 @@ export default function DevMailPage() {
7891
7936
  className={`${styles.itemStar} ${item.starred ? styles.itemStarOn : ''}`}
7892
7937
  onClick={event => {
7893
7938
  event.stopPropagation()
7894
- setInboundFlag([item.id], { starred: !item.starred })
7939
+ toggleRowStar(item.id, item.kind === 'inbound' ? item.threadId : null, !item.starred)
7895
7940
  }}
7896
7941
  onKeyDown={event => {
7897
7942
  if (event.key === 'Enter') {
7898
7943
  event.stopPropagation()
7899
- setInboundFlag([item.id], { starred: !item.starred })
7944
+ toggleRowStar(item.id, item.kind === 'inbound' ? item.threadId : null, !item.starred)
7900
7945
  }
7901
7946
  }}
7902
7947
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",