@novacraft-engineering/mailbox 0.4.2 → 0.4.3

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.
package/app/mail/page.tsx CHANGED
@@ -8,7 +8,7 @@ import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './
8
8
  import { useConfirm } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
10
  import RichEditor from './RichEditor'
11
- import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
11
+ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
12
12
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
13
13
  import MailSelect, { GLYPH } from './MailSelect'
14
14
 
@@ -923,6 +923,14 @@ function pastedMarkdown(event: React.ClipboardEvent): string | null {
923
923
  */
924
924
  const OWN_IMAGE_HOSTS = `${CLIENT_BRAND.websiteUrl} ${CLIENT_BRAND.publicUrl}`
925
925
 
926
+ /** Mail one of our own addresses sent, so a signature logo in it was ours to restore. */
927
+ function sentByUs(address: string): boolean {
928
+ const email = (address.match(/<([^>]+)>/)?.[1] ?? address).trim().toLowerCase()
929
+ if (!email.includes('@')) return false
930
+ const domain = email.slice(email.lastIndexOf('@') + 1)
931
+ return MAIL_ADDRESSES.includes(email) || LOGIN_DOMAINS.includes(domain)
932
+ }
933
+
926
934
  // When allowRemote is false the CSP blocks remote fetches so tracking pixels never load.
927
935
  function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SPACING, dark = false): string {
928
936
  const csp = allowRemote
@@ -1668,6 +1676,8 @@ export default function DevMailPage() {
1668
1676
  setThreadsResolved(false)
1669
1677
  threadsFetch.current = null
1670
1678
  threadsLoadedFolder.current = null
1679
+ setInboxEmails([])
1680
+ setServerCounts(null)
1671
1681
  }, [mailboxQuery])
1672
1682
  const loadThreads = useCallback(async () => {
1673
1683
  if (!isLoggedIn) return
@@ -2143,13 +2153,18 @@ export default function DevMailPage() {
2143
2153
  * is loaded describes the page, not the mailbox, and grows as you scroll.
2144
2154
  */
2145
2155
  const lastCountAt = useRef(0)
2156
+ // Keyed on the mailbox as well as the clock: the throttle is there to absorb a burst of
2157
+ // actions on one mailbox, and must never swallow the recount that a switch requires.
2158
+ const lastCountKey = useRef<string | null>(null)
2146
2159
  const loadCounts = useCallback(async (force = false) => {
2147
2160
  // Counting reads the whole mailbox — about seventy thousand rows here — so it runs when
2148
2161
  // the figures can actually have changed, not on the periodic refresh. A minute's
2149
2162
  // throttle keeps a burst of actions from re-counting once each.
2150
- if (!force && Date.now() - lastCountAt.current < 300000) return
2151
- if (!force && document.visibilityState !== 'visible') return
2163
+ const sameMailbox = lastCountKey.current === mailboxQuery
2164
+ if (!force && sameMailbox && Date.now() - lastCountAt.current < 300000) return
2165
+ if (!force && sameMailbox && document.visibilityState !== 'visible') return
2152
2166
  lastCountAt.current = Date.now()
2167
+ lastCountKey.current = mailboxQuery
2153
2168
  setCountsLoading(true)
2154
2169
  try {
2155
2170
  const params = new URLSearchParams(mailboxQuery.replace(/^\?/, ''))
@@ -2204,7 +2219,7 @@ export default function DevMailPage() {
2204
2219
  useEffect(() => {
2205
2220
  if (!isLoggedIn) return
2206
2221
  try {
2207
- const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}`)
2222
+ const cached = localStorage.getItem(`${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`)
2208
2223
  if (!cached) return
2209
2224
  const saved = JSON.parse(cached) as { at?: number; rows?: InboundEmail[]; threads?: ConversationRow[]; folder?: string; counts?: typeof serverCounts }
2210
2225
  if (saved?.counts) setServerCounts(current => current ?? saved.counts!)
@@ -2221,13 +2236,13 @@ export default function DevMailPage() {
2221
2236
  // A cache that will not parse is not worth a broken mailbox.
2222
2237
  }
2223
2238
  // eslint-disable-next-line react-hooks/exhaustive-deps
2224
- }, [isLoggedIn, email])
2239
+ }, [isLoggedIn, email, mailboxQuery])
2225
2240
 
2226
2241
  useEffect(() => {
2227
2242
  if (!isLoggedIn || mailboxStale || inboxEmails.length === 0) return
2228
2243
  try {
2229
2244
  localStorage.setItem(
2230
- `${LS_INBOX_CACHE_KEY}:${email}`,
2245
+ `${LS_INBOX_CACHE_KEY}:${email}${mailboxQuery}`,
2231
2246
  JSON.stringify({
2232
2247
  at: Date.now(),
2233
2248
  folder: threadFolder,
@@ -2239,7 +2254,7 @@ export default function DevMailPage() {
2239
2254
  } catch {
2240
2255
  // Storage full or blocked: the cache is an optimisation, never a requirement.
2241
2256
  }
2242
- }, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, serverCounts])
2257
+ }, [inboxEmails, threads, threadFolder, isLoggedIn, mailboxStale, email, mailboxQuery, serverCounts])
2243
2258
 
2244
2259
  const typedOnce = useRef(false)
2245
2260
  useEffect(() => {
@@ -5574,7 +5589,10 @@ export default function DevMailPage() {
5574
5589
  }
5575
5590
  const split = splitQuotedTail(message.html)
5576
5591
  const quoteShown = quoteOpen.has(message.id)
5577
- const bodyHtml = split.tail && !quoteShown ? split.head : message.html
5592
+ const bodyHtml = healGooglePrivateImages(
5593
+ split.tail && !quoteShown ? split.head : message.html,
5594
+ sentByUs(message.from) ? CLIENT_BRAND.markUrl : null,
5595
+ )
5578
5596
  if (framed) {
5579
5597
  // Thread messages auto-size to content (whole conversation scrolls as one).
5580
5598
  // sandbox omits allow-scripts, so no email JS runs; CSP still blocks fetches.
@@ -5617,7 +5635,7 @@ export default function DevMailPage() {
5617
5635
  <iframe
5618
5636
  className={styles.threadFrame}
5619
5637
  sandbox="allow-same-origin"
5620
- srcDoc={frameHtml(detail.html, true, readerSpacing, resolvedTheme === 'dark')}
5638
+ srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
5621
5639
  title="Sent email"
5622
5640
  onLoad={event => {
5623
5641
  try {
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict'
2
- import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages } from './email-html.ts'
2
+ import { inlineEmailStyles, htmlToPlainText, safeHref, dropUnreachableImages, outlookSafeImages, healGooglePrivateImages } from './email-html.ts'
3
3
 
4
4
  // A paragraph with no styling of its own gets the base inline style.
5
5
  assert.match(inlineEmailStyles('<p>Hello</p>'), /<p style="font-family:Arial[^"]*">Hello<\/p>/)
@@ -72,3 +72,19 @@ assert.ok(!centred.includes('data-align'), 'the editor marker is stripped')
72
72
  // A linked logo keeps its link, inside the cell rather than around the table.
73
73
  const linked = outlookSafeImages('<a href="https://x.test"><img src="https://x.test/m.png" style="display:block;width:100px;border:1px solid #000;"></a>')
74
74
  assert.match(linked, /<td[^>]*><a href="https:\/\/x\.test"><img/, 'the anchor sits inside the cell')
75
+
76
+ // A Gmail-hosted signature logo cannot load outside that mailbox: drop it, or put ours back.
77
+ assert.equal(
78
+ healGooglePrivateImages('<p>Hi</p><img src="https://ci3.googleusercontent.com/mail-sig/ABC"><p>Regards</p>'),
79
+ '<p>Hi</p><p>Regards</p>',
80
+ )
81
+ assert.equal(
82
+ healGooglePrivateImages('<img src="https://ci3.googleusercontent.com/mail-sig/ABC" alt="logo">', 'https://mail.example.com/brand/mark.png'),
83
+ '<img src="https://mail.example.com/brand/mark.png" alt="logo">',
84
+ )
85
+ // Entity-encoded query strings still match, and everything else is left alone.
86
+ assert.equal(healGooglePrivateImages('<img src="https://mail.google.com/mail/u/0?ui=2&amp;ik=x">'), '')
87
+ assert.equal(
88
+ healGooglePrivateImages('<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">'),
89
+ '<img src="cid:abc"><img src="https://lh3.googleusercontent.com/real.png">',
90
+ )
package/lib/email-html.ts CHANGED
@@ -112,6 +112,29 @@ export function dropUnreachableImages(html: string): string {
112
112
  })
113
113
  }
114
114
 
115
+ /**
116
+ * Addresses Gmail serves only to the account that owns them.
117
+ *
118
+ * A signature logo written by Gmail lives on Google's own CDN, and Takeout exports the
119
+ * link but never the bytes. Outside that mailbox the address answers 400, so the picture
120
+ * has been broken since the day the mail was exported and can never load again.
121
+ */
122
+ const GOOGLE_PRIVATE_IMAGE = /^https?:\/\/(?:ci\d*\.googleusercontent\.com\/mail-sig\/|mail\.google\.com\/mail\/|docs\.google\.com\/uc\?)/i
123
+
124
+ /**
125
+ * Removes those pictures so the signature reads as text rather than a broken icon.
126
+ * When `mark` is given — only for mail one of our own addresses sent, where we know whose
127
+ * logo it was — the tenant's own mark is put back in its place instead.
128
+ */
129
+ export function healGooglePrivateImages(html: string, mark?: string | null): string {
130
+ return html.replace(/<img\b[^>]*>/gi, tag => {
131
+ const src = /\bsrc\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1]?.trim() ?? ''
132
+ if (!GOOGLE_PRIVATE_IMAGE.test(src)) return tag
133
+ if (!mark) return ''
134
+ return tag.replace(/\bsrc\s*=\s*["'][^"']*["']/i, `src="${mark}"`)
135
+ })
136
+ }
137
+
115
138
  export function stripCidPlaceholders(text: string): string {
116
139
  return text
117
140
  .replace(/\[cid:[^\]\n]{0,300}\]/gi, '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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",