@novacraft-engineering/mailbox 0.4.5 → 0.4.18

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
@@ -2,11 +2,12 @@
2
2
 
3
3
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { extractUrls, inspectUrl, type LinkVerdict } from '@/lib/link-safety'
5
- import AttachmentLightbox, { attachmentKind, formatSize, type PreviewItem } from './AttachmentLightbox'
5
+ import AttachmentLightbox, { attachmentKind, formatSize, inlineUrl, type PreviewItem } from './AttachmentLightbox'
6
6
  import AccessCheck from './AccessCheck'
7
7
  import { parseQuery, matchesQuery, serverSearchParams, splitForServer } from './search'
8
8
  import { useConfirm, usePrompt } from './ConfirmDialog'
9
9
  import { subscribePush, unsubscribePush, useInstall, useNotifications } from './pwa'
10
+ import { InstallGuide } from './InstallGuide'
10
11
  import RichEditor from './RichEditor'
11
12
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel, healGooglePrivateImages } from '@/lib/email-html'
12
13
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
@@ -151,7 +152,7 @@ type Accessor = {
151
152
  invitedBy: string | null
152
153
  }
153
154
 
154
- type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'trash'
155
+ type Folder = 'inbox' | 'starred' | 'snoozed' | 'sent' | 'scheduled' | 'drafts' | 'archived' | 'spam' | 'trash'
155
156
 
156
157
  type SentEmail = {
157
158
  id: string
@@ -189,6 +190,8 @@ type SentDetail = SentEmail & {
189
190
  }
190
191
 
191
192
  type ConversationRow = {
193
+ addressed?: 'direct' | 'copied' | 'other'
194
+ risk?: 'clean' | 'suspicious' | 'spam' | 'virus'
192
195
  threadId: string
193
196
  snoozedUntil?: string | null
194
197
  subject: string
@@ -201,6 +204,7 @@ type ConversationRow = {
201
204
  inboxCount: number
202
205
  archivedCount: number
203
206
  trashedCount: number
207
+ spamCount: number
204
208
  attachCount: number
205
209
  senders: string[]
206
210
  snippet: string
@@ -213,6 +217,11 @@ type InboundEmail = {
213
217
  from: string
214
218
  to: string[]
215
219
  cc: string[]
220
+ /** Written to, copied in, or reached some other way — judged for the holding mailbox. */
221
+ addressed?: 'direct' | 'copied' | 'other'
222
+ risk?: 'clean' | 'suspicious' | 'spam' | 'virus'
223
+ riskReasons?: string[]
224
+ spam?: boolean
216
225
  bcc: string[]
217
226
  replyTo: string[]
218
227
  subject: string
@@ -1254,6 +1263,8 @@ const ICONS = {
1254
1263
  inbox: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5h13L22 12v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-6l3.5-7z"/></svg>,
1255
1264
  sent: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m22 2-7 20-4-9-9-4 20-7z"/><path d="M22 2 11 13"/></svg>,
1256
1265
  scheduled: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>,
1266
+ info: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/></svg>,
1267
+ warn: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>,
1257
1268
  drafts: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>,
1258
1269
  refresh: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>,
1259
1270
  search: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>,
@@ -1315,6 +1326,7 @@ const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
1315
1326
  scheduled: ICONS.scheduled,
1316
1327
  drafts: ICONS.drafts,
1317
1328
  archived: ICONS.archive,
1329
+ spam: ICONS.warn,
1318
1330
  trash: ICONS.trash,
1319
1331
  }
1320
1332
 
@@ -1347,6 +1359,9 @@ export default function DevMailPage() {
1347
1359
  const [folder, setFolder] = useState<Folder>('inbox')
1348
1360
  const [search, setSearch] = useState('')
1349
1361
  const [sentEmails, setSentEmails] = useState<SentEmail[]>([])
1362
+ // What is waiting to be sent lives here now, not at the provider: a delayed message is
1363
+ // held by this app, so the folder has to read the app's own queue to see it.
1364
+ const [queuedSends, setQueuedSends] = useState<SentEmail[]>([])
1350
1365
  const [inboxEmails, setInboxEmails] = useState<InboundEmail[]>([])
1351
1366
  // The server owns search and paging now; the client holds one page at a time
1352
1367
  // rather than the whole mailbox.
@@ -1354,7 +1369,7 @@ export default function DevMailPage() {
1354
1369
  const [loadingMore, setLoadingMore] = useState(false)
1355
1370
  const [inboxCursor, setInboxCursor] = useState<string | null>(null)
1356
1371
  const inboxFetch = useRef<string | null>(null)
1357
- type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; snoozed: number }
1372
+ type FolderTally = { inbox: number; unread: number; starred: number; archived: number; trashed: number; spam: number; snoozed: number }
1358
1373
  const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
1359
1374
  const [countsLoading, setCountsLoading] = useState(false)
1360
1375
  const [composeExpanded, setComposeExpanded] = useState(true)
@@ -1491,7 +1506,7 @@ export default function DevMailPage() {
1491
1506
  const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
1492
1507
  const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
1493
1508
  const messagesAlwaysOpen = settings.threadMessages === 'open'
1494
- const { canInstall, installed, install } = useInstall()
1509
+ const { canInstall, installed, install, ios } = useInstall()
1495
1510
  const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
1496
1511
  settings.desktopNotifications,
1497
1512
  )
@@ -1688,7 +1703,7 @@ export default function DevMailPage() {
1688
1703
 
1689
1704
  const [threads, setThreads] = useState<ConversationRow[]>([])
1690
1705
  const [threadsResolved, setThreadsResolved] = useState(false)
1691
- const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : 'inbox'
1706
+ const threadFolder = folder === 'archived' ? 'archive' : folder === 'trash' ? 'trash' : folder === 'starred' ? 'starred' : folder === 'snoozed' ? 'snoozed' : folder === 'spam' ? 'spam' : 'inbox'
1692
1707
  const threadsFetch = useRef<string | null>(null)
1693
1708
  const threadsLoadedFolder = useRef<string | null>(null)
1694
1709
  const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
@@ -2001,6 +2016,33 @@ export default function DevMailPage() {
2001
2016
  } catch {}
2002
2017
  }, [apiHeaders, mailboxQuery])
2003
2018
 
2019
+ const loadQueued = useCallback(async () => {
2020
+ try {
2021
+ const response = await fetch(`/api/mail/scheduled${mailboxQuery}`, { headers: apiHeaders() })
2022
+ const data = await response.json()
2023
+ if (!data.ok) return
2024
+ type Waiting = { id: string; subject: string; to: string[]; sendAfter: string; createdAt: string; owner: string }
2025
+ setQueuedSends(
2026
+ (data.scheduled as Waiting[]).map(entry => ({
2027
+ id: entry.id,
2028
+ from: entry.owner,
2029
+ to: entry.to,
2030
+ subject: entry.subject,
2031
+ createdAt: entry.createdAt,
2032
+ scheduledAt: entry.sendAfter,
2033
+ lastEvent: 'scheduled',
2034
+ starred: false,
2035
+ archived: false,
2036
+ trashed: false,
2037
+ opened: false,
2038
+ openCount: 0,
2039
+ openedAt: null,
2040
+ owner: entry.owner,
2041
+ })),
2042
+ )
2043
+ } catch {}
2044
+ }, [apiHeaders, mailboxQuery])
2045
+
2004
2046
  /**
2005
2047
  * Searching Sent asks the server. The list holds the newest few hundred sends and a
2006
2048
  * search that only looked through those found nothing older, with no sign that it had
@@ -2547,7 +2589,7 @@ export default function DevMailPage() {
2547
2589
  const refreshAll = useCallback(async () => {
2548
2590
  setRefreshing(true)
2549
2591
  try {
2550
- await Promise.all([loadSent(), refreshInbox(), loadThreads(), loadEvents()])
2592
+ await Promise.all([loadSent(), loadQueued(), refreshInbox(), loadThreads(), loadEvents()])
2551
2593
  lastRefreshAt.current = Date.now()
2552
2594
  } finally {
2553
2595
  setRefreshing(false)
@@ -2883,13 +2925,21 @@ export default function DevMailPage() {
2883
2925
  .catch(() => {})
2884
2926
  }, [isLoggedIn, apiHeaders])
2885
2927
 
2886
- const scheduledEmails = useMemo(
2887
- () =>
2888
- sentEmails
2889
- .filter(entry => entry.lastEvent === 'scheduled' || (entry.scheduledAt && new Date(entry.scheduledAt).getTime() > now))
2890
- .sort((a, b) => new Date(a.scheduledAt ?? a.createdAt).getTime() - new Date(b.scheduledAt ?? b.createdAt).getTime()),
2891
- [sentEmails, now],
2892
- )
2928
+ const scheduledEmails = useMemo(() => {
2929
+ // The app's own queue, plus anything a provider is still holding from before this was
2930
+ // ours to keep. Matched on id so one message cannot appear twice.
2931
+ const waiting = [...queuedSends]
2932
+ const seen = new Set(waiting.map(entry => entry.id))
2933
+ for (const entry of sentEmails) {
2934
+ if (seen.has(entry.id)) continue
2935
+ if (entry.lastEvent === 'scheduled' || (entry.scheduledAt && new Date(entry.scheduledAt).getTime() > now)) {
2936
+ waiting.push(entry)
2937
+ }
2938
+ }
2939
+ return waiting.sort(
2940
+ (a, b) => new Date(a.scheduledAt ?? a.createdAt).getTime() - new Date(b.scheduledAt ?? b.createdAt).getTime(),
2941
+ )
2942
+ }, [queuedSends, sentEmails, now])
2893
2943
  const deliveredEmails = useMemo(
2894
2944
  () => sentEmails.filter(entry => !scheduledEmails.some(scheduled => scheduled.id === entry.id)),
2895
2945
  [sentEmails, scheduledEmails],
@@ -2947,6 +2997,7 @@ export default function DevMailPage() {
2947
2997
  starred: serverCounts?.starred ?? 0,
2948
2998
  archived: serverCounts?.archived ?? 0,
2949
2999
  trashed: serverCounts?.trashed ?? 0,
3000
+ spam: serverCounts?.spam ?? 0,
2950
3001
  snoozed: serverCounts?.snoozed ?? 0,
2951
3002
  }),
2952
3003
  [serverCounts],
@@ -2965,6 +3016,7 @@ export default function DevMailPage() {
2965
3016
  if (folder === 'trash') return tally.trashed
2966
3017
  if (folder === 'starred') return tally.starred
2967
3018
  if (folder === 'snoozed') return tally.snoozed
3019
+ if (folder === 'spam') return tally.spam
2968
3020
  if (folder === 'inbox') return tally.inbox
2969
3021
  return null
2970
3022
  }, [serverCounts, search, folder])
@@ -2999,6 +3051,7 @@ export default function DevMailPage() {
2999
3051
  read: entry.read,
3000
3052
  starred: entry.starred,
3001
3053
  hasAttachment: entry.attachments.length > 0,
3054
+ addressed: entry.addressed,
3002
3055
  labels: entry.labels,
3003
3056
  folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
3004
3057
  })
@@ -3091,6 +3144,9 @@ export default function DevMailPage() {
3091
3144
  const OPERATORS: Suggestion[] = [
3092
3145
  { label: 'is:unread', hint: 'only unread', apply: `${before}is:unread ` },
3093
3146
  { label: 'is:starred', hint: 'only starred', apply: `${before}is:starred ` },
3147
+ { label: 'is:copied', hint: 'only mail you were copied in', apply: `${before}is:copied ` },
3148
+ { label: '-is:copied', hint: 'hide mail you were copied in', apply: `${before}-is:copied ` },
3149
+ { label: 'is:direct', hint: 'only mail addressed to you', apply: `${before}is:direct ` },
3094
3150
  { label: 'has:attachment', hint: 'with files', apply: `${before}has:attachment ` },
3095
3151
  { label: 'from:', hint: 'sender', apply: `${before}from:` },
3096
3152
  { label: 'to:', hint: 'recipient', apply: `${before}to:` },
@@ -3116,7 +3172,8 @@ export default function DevMailPage() {
3116
3172
  if (folder === 'starred') return entry.starred && !entry.trashed
3117
3173
  if (folder === 'archived') return entry.archived && !entry.trashed
3118
3174
  if (folder === 'trash') return entry.trashed
3119
- return !entry.archived && !entry.trashed && !asleep
3175
+ if (folder === 'spam') return Boolean(entry.spam)
3176
+ return !entry.archived && !entry.trashed && !asleep && !entry.spam
3120
3177
  },
3121
3178
  [folder],
3122
3179
  )
@@ -3207,7 +3264,8 @@ export default function DevMailPage() {
3207
3264
 
3208
3265
  /** The folders backed by the paged inbound list, as opposed to ones loaded whole. */
3209
3266
  const isInboundFolder =
3210
- folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' || folder === 'trash'
3267
+ folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' ||
3268
+ folder === 'spam' || folder === 'trash'
3211
3269
 
3212
3270
  const listItems = useMemo(() => {
3213
3271
  const sentToItem = (entry: SentEmail) => ({
@@ -3216,6 +3274,8 @@ export default function DevMailPage() {
3216
3274
  primary: `To: ${entry.to.join(', ')}`,
3217
3275
  subject: entry.subject,
3218
3276
  snippet: '',
3277
+ addressed: 'direct' as const,
3278
+ risk: 'clean' as const,
3219
3279
  time:
3220
3280
  folder === 'scheduled' && entry.scheduledAt
3221
3281
  ? `in ${formatCountdown(new Date(entry.scheduledAt).getTime() - now)}`
@@ -3242,6 +3302,7 @@ export default function DevMailPage() {
3242
3302
  }
3243
3303
  const inThreadFolder = (thread: ConversationRow) => {
3244
3304
  const asleep = Boolean(thread.snoozedUntil && Date.parse(thread.snoozedUntil) > Date.now())
3305
+ if (folder === 'spam') return thread.spamCount > 0
3245
3306
  if (folder === 'snoozed') return asleep
3246
3307
  if (folder === 'archived') return thread.archivedCount > 0
3247
3308
  if (folder === 'trash') return thread.trashedCount > 0
@@ -3266,6 +3327,8 @@ export default function DevMailPage() {
3266
3327
  starred: thread.starredCount > 0,
3267
3328
  hasAttachment: thread.attachCount > 0,
3268
3329
  chip: null as string | null,
3330
+ addressed: thread.addressed ?? 'direct',
3331
+ risk: thread.risk ?? 'clean',
3269
3332
  threadCount: thread.count,
3270
3333
  latestAt: new Date(thread.latestAt).getTime(),
3271
3334
  labels: thread.labels,
@@ -3299,6 +3362,15 @@ export default function DevMailPage() {
3299
3362
  starred: members.some(member => member.starred),
3300
3363
  hasAttachment: members.some(member => member.attachments.length > 0),
3301
3364
  chip: null as string | null,
3365
+ // The row speaks for its newest message; the banner in the reader is per message.
3366
+ addressed: latest.addressed ?? 'direct',
3367
+ risk: members.some(member => member.risk === 'virus')
3368
+ ? 'virus'
3369
+ : members.some(member => member.risk === 'spam')
3370
+ ? 'spam'
3371
+ : members.some(member => member.risk === 'suspicious')
3372
+ ? 'suspicious'
3373
+ : 'clean',
3302
3374
  threadCount: members.length,
3303
3375
  latestAt: new Date(latest.receivedAt).getTime(),
3304
3376
  labels: Array.from(new Set(members.flatMap(member => member.labels))),
@@ -3367,7 +3439,10 @@ export default function DevMailPage() {
3367
3439
  const selectedInbound = selectedId ? inboxEmails.find(entry => entry.id === selectedId) ?? null : null
3368
3440
  const selectedSent =
3369
3441
  selectedId && !selectedInbound
3370
- ? sentEmails.find(entry => entry.id === selectedId) ?? sentSearchResults?.find(entry => entry.id === selectedId) ?? null
3442
+ ? sentEmails.find(entry => entry.id === selectedId)
3443
+ ?? queuedSends.find(entry => entry.id === selectedId)
3444
+ ?? sentSearchResults?.find(entry => entry.id === selectedId)
3445
+ ?? null
3371
3446
  : null
3372
3447
  const selectedDetail = selectedSent ? detailCache[selectedSent.id] ?? null : null
3373
3448
  const selectedIsThread = selectedInbound ? unifiedThread(selectedInbound.id).length > 1 : false
@@ -3578,6 +3653,36 @@ export default function DevMailPage() {
3578
3653
  const setInboundFlagRef = useRef<(ids: string[], flags: InboundFlagPatch) => void>(() => {})
3579
3654
  const threadIdsRef = useRef<(id: string) => string[]>(id => [id])
3580
3655
 
3656
+ /**
3657
+ * Moves mail into or out of quarantine. Both directions teach: marking spam counts
3658
+ * against the sender, rescuing counts for them, and the next message from that domain
3659
+ * is judged with the answer already in hand.
3660
+ */
3661
+ const markSpam = useCallback(
3662
+ async (ids: string[], spam: boolean) => {
3663
+ if (!ids.length) return
3664
+ setInboxEmails(list => list.map(entry => (ids.includes(entry.id) ? { ...entry, spam } : entry)))
3665
+ setSelectedId(null)
3666
+ setSelectedBulk(new Set())
3667
+ try {
3668
+ await fetch('/api/mail/inbox', {
3669
+ method: 'PATCH',
3670
+ headers: apiHeaders(),
3671
+ body: JSON.stringify({ ids, spam }),
3672
+ })
3673
+ } catch {}
3674
+ setSentFlash(spam ? 'Moved to Spam' : 'Moved back to the inbox')
3675
+ window.setTimeout(() => setSentFlash(''), 2500)
3676
+ // The refresh dedupes on folder+query, which is unchanged here — clearing the key is
3677
+ // what lets the list re-read the folder the message just left.
3678
+ inboxFetch.current = null
3679
+ refreshInbox()
3680
+ loadThreads()
3681
+ loadCounts(true)
3682
+ },
3683
+ [apiHeaders],
3684
+ )
3685
+
3581
3686
  const setInboundFlag = useCallback(
3582
3687
  (ids: string[], flags: Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>) => {
3583
3688
  if (flags.read === true) ids.forEach(id => pendingRead.current.add(id))
@@ -3728,14 +3833,22 @@ export default function DevMailPage() {
3728
3833
  const bulkCancelScheduled = useCallback(async () => {
3729
3834
  const ids = Array.from(selectedBulk)
3730
3835
  if (!ids.length) return
3836
+ // Two kinds of waiting message, two places to cancel it: one this app is holding, and
3837
+ // one a provider still has from before the queue was ours.
3838
+ const queuedIds = new Set(queuedSends.map(entry => entry.id))
3731
3839
  await Promise.all(
3732
- ids.map(id => fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})),
3840
+ ids.map(id =>
3841
+ queuedIds.has(id)
3842
+ ? fetch(`/api/mail/scheduled?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})
3843
+ : fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {}),
3844
+ ),
3733
3845
  )
3734
3846
  setSelectedBulk(new Set())
3735
3847
  setSentFlash('Canceled')
3736
3848
  window.setTimeout(() => setSentFlash(''), 2500)
3737
3849
  loadSent()
3738
- }, [selectedBulk, apiHeaders, loadSent])
3850
+ loadQueued()
3851
+ }, [selectedBulk, apiHeaders, loadSent, loadQueued, queuedSends])
3739
3852
 
3740
3853
  // Admin: bulk-reassign the selection to a mailbox (routes inbound + sent ids separately).
3741
3854
  const bulkAssign = useCallback(
@@ -4098,7 +4211,17 @@ export default function DevMailPage() {
4098
4211
  if (request.status >= 200 && request.status < 300) resolve()
4099
4212
  else reject(new Error(`Upload failed (${request.status})`))
4100
4213
  }
4101
- request.onerror = () => { settle(); reject(new Error('Upload failed. Check your connection and retry.')) }
4214
+ // A browser reports a refused cross-origin PUT exactly as it reports being
4215
+ // offline: status 0, no headers, no detail. Sending everyone to check their
4216
+ // connection is what made a bucket missing its CORS rules look like flaky wifi.
4217
+ request.onerror = () => {
4218
+ settle()
4219
+ reject(new Error(
4220
+ navigator.onLine
4221
+ ? 'The storage bucket refused the upload. If it keeps happening the bucket is not accepting uploads from this site, which is its CORS rules rather than your connection.'
4222
+ : 'You are offline. Retry once the connection is back.',
4223
+ ))
4224
+ }
4102
4225
  request.onabort = () => settle()
4103
4226
  request.send(file)
4104
4227
  }),
@@ -4392,6 +4515,7 @@ export default function DevMailPage() {
4392
4515
  setSentFlash('Canceled — back to draft')
4393
4516
  window.setTimeout(() => setSentFlash(''), 2500)
4394
4517
  loadSent()
4518
+ loadQueued()
4395
4519
  } else {
4396
4520
  setSentFlash(data.error?.includes('cancel') ? 'Too late — already sent' : `Could not cancel: ${data.error}`)
4397
4521
  window.setTimeout(() => setSentFlash(''), 3000)
@@ -4408,33 +4532,45 @@ export default function DevMailPage() {
4408
4532
  setSentFlash('Sent')
4409
4533
  window.setTimeout(() => setSentFlash(''), 2500)
4410
4534
  loadSent()
4535
+ loadQueued()
4411
4536
  }
4412
4537
  }, [undo, now, loadSent])
4413
4538
 
4414
4539
  const cancelScheduled = async (id: string) => {
4415
4540
  try {
4416
- const response = await fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() })
4541
+ const waiting = queuedSends.some(entry => entry.id === id)
4542
+ const response = waiting
4543
+ ? await fetch(`/api/mail/scheduled?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() })
4544
+ : await fetch(`/api/mail/emails/${id}`, { method: 'DELETE', headers: apiHeaders() })
4417
4545
  const data = await response.json()
4418
4546
  setSentFlash(data.ok ? 'Canceled' : `Could not cancel: ${data.error}`)
4419
4547
  window.setTimeout(() => setSentFlash(''), 2500)
4420
4548
  if (data.ok) {
4421
4549
  setSelectedId(null)
4422
4550
  loadSent()
4551
+ loadQueued()
4423
4552
  }
4424
4553
  } catch {}
4425
4554
  }
4426
4555
 
4427
4556
  const rescheduleScheduled = async (id: string, iso: string) => {
4428
4557
  try {
4429
- const response = await fetch(`/api/mail/emails/${id}`, {
4430
- method: 'PATCH',
4431
- headers: apiHeaders(),
4432
- body: JSON.stringify({ scheduledAt: iso }),
4433
- })
4558
+ const waiting = queuedSends.some(entry => entry.id === id)
4559
+ const response = waiting
4560
+ ? await fetch('/api/mail/scheduled', {
4561
+ method: 'PATCH',
4562
+ headers: apiHeaders(),
4563
+ body: JSON.stringify({ id, scheduledAt: iso }),
4564
+ })
4565
+ : await fetch(`/api/mail/emails/${id}`, {
4566
+ method: 'PATCH',
4567
+ headers: apiHeaders(),
4568
+ body: JSON.stringify({ scheduledAt: iso }),
4569
+ })
4434
4570
  const data = await response.json()
4435
4571
  setSentFlash(data.ok ? 'Rescheduled' : `Could not reschedule: ${data.error}`)
4436
4572
  window.setTimeout(() => setSentFlash(''), 2500)
4437
- if (data.ok) loadSent()
4573
+ if (data.ok) { loadSent(); loadQueued() }
4438
4574
  } catch {}
4439
4575
  }
4440
4576
 
@@ -5223,6 +5359,7 @@ export default function DevMailPage() {
5223
5359
  scheduled: 'Scheduled',
5224
5360
  drafts: 'Drafts',
5225
5361
  archived: 'Archive',
5362
+ spam: 'Spam',
5226
5363
  trash: 'Trash',
5227
5364
  }
5228
5365
 
@@ -5294,7 +5431,11 @@ export default function DevMailPage() {
5294
5431
  onChange={event => {
5295
5432
  const typed = event.target.value
5296
5433
  setLoginRaw(typed)
5297
- setEmail(`${typed.split('@')[0].trim()}@${loginDomain}`)
5434
+ // A seat's sign-in address can live outside this mailbox's own domains, so a
5435
+ // full address is taken as typed; the picker only completes a bare local part.
5436
+ const trimmed = typed.trim()
5437
+ const [local, domain] = trimmed.split('@')
5438
+ setEmail(domain ? trimmed.toLowerCase() : `${local}@${loginDomain}`)
5298
5439
  }}
5299
5440
  autoComplete="username"
5300
5441
  placeholder="you"
@@ -5657,6 +5798,42 @@ export default function DevMailPage() {
5657
5798
  )
5658
5799
  }
5659
5800
 
5801
+ /**
5802
+ * A message whose whole content is its attachment — a scan, a statement, a DMARC report —
5803
+ * used to read as "(no content)". Show the file itself where the body would have been,
5804
+ * for the types a browser can actually render; the rest get named rather than hidden.
5805
+ */
5806
+ const renderBodylessAttachment = (list: DownloadAttachment[]) => {
5807
+ const file = list.find(entry => entry.downloadUrl && !entry.shareId)
5808
+ if (!file) return null
5809
+ const kind = attachmentKind(file.filename, file.contentType)
5810
+ const source = inlineUrl(file.downloadUrl)
5811
+
5812
+ if (kind === 'image') {
5813
+ return (
5814
+ // eslint-disable-next-line @next/next/no-img-element
5815
+ <img className={styles.bodyFileImage} src={source} alt={file.filename} />
5816
+ )
5817
+ }
5818
+ if (kind === 'pdf') {
5819
+ return <iframe className={styles.bodyFileFrame} src={source} title={file.filename} />
5820
+ }
5821
+ if (kind === 'video') return <video className={styles.bodyFileImage} src={source} controls preload="metadata" />
5822
+ if (kind === 'audio') return <audio className={styles.bodyFileAudio} src={source} controls preload="metadata" />
5823
+
5824
+ // Office documents and archives have no native viewer; naming the file and offering it
5825
+ // beats a blank panel, and beats pretending a preview exists.
5826
+ return (
5827
+ <a className={styles.bodyFileCard} href={file.downloadUrl} target="_blank" rel="noopener noreferrer">
5828
+ <span className={styles.bodyFileGlyph}>{ICONS.file}</span>
5829
+ <span>
5830
+ <strong>{file.filename}</strong>
5831
+ <small>{formatSize(file.size)} · opens in a new tab</small>
5832
+ </span>
5833
+ </a>
5834
+ )
5835
+ }
5836
+
5660
5837
  const renderInboundAttachments = (message: InboundEmail) => {
5661
5838
  if (!visibleAttachments(message).length) return null
5662
5839
  // The stored-blob lookup answers with an empty array for anything it does not
@@ -5709,7 +5886,9 @@ export default function DevMailPage() {
5709
5886
  const textShown = quoteOpen.has(message.id)
5710
5887
  return (
5711
5888
  <>
5712
- <pre className={styles.readerText}>{(textShown ? message.text : plain.head) || '(no content)'}</pre>
5889
+ {(textShown ? message.text : plain.head)
5890
+ ? <pre className={styles.readerText}>{textShown ? message.text : plain.head}</pre>
5891
+ : renderBodylessAttachment(inboundAttachments[message.id] ?? []) ?? <pre className={styles.readerText}>(no content)</pre>}
5713
5892
  {plain.tail && renderQuoteLine(message.id, textShown)}
5714
5893
  </>
5715
5894
  )
@@ -5773,7 +5952,9 @@ export default function DevMailPage() {
5773
5952
  />
5774
5953
  )
5775
5954
  }
5776
- return <pre className={styles.readerText}>{detail.text?.trim() || '(no content)'}</pre>
5955
+ return detail.text?.trim()
5956
+ ? <pre className={styles.readerText}>{detail.text}</pre>
5957
+ : renderBodylessAttachment(detail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
5777
5958
  }
5778
5959
 
5779
5960
  /** What we attached to a message we sent. The inbound side has always shown these. */
@@ -6119,6 +6300,38 @@ export default function DevMailPage() {
6119
6300
  </div>
6120
6301
  <div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
6121
6302
  </div>
6303
+ {inbound.risk && inbound.risk !== 'clean' && (
6304
+ <div className={styles.riskBanner} role="alert">
6305
+ <span className={styles.riskBannerIcon}>{ICONS.warn}</span>
6306
+ <span>
6307
+ <strong>
6308
+ {inbound.risk === 'virus'
6309
+ ? 'A virus scan failed on this message'
6310
+ : inbound.risk === 'spam'
6311
+ ? 'This message looks like spam'
6312
+ : 'This message may not be from who it claims'}
6313
+ </strong>
6314
+ {(inbound.riskReasons ?? []).length > 0 && (
6315
+ <ul className={styles.riskBannerReasons}>
6316
+ {(inbound.riskReasons ?? []).map(reason => (
6317
+ <li key={reason}>{reason}</li>
6318
+ ))}
6319
+ </ul>
6320
+ )}
6321
+ <span className={styles.riskBannerWhy}>
6322
+ Treat links and attachments here with care, and do not enter passwords or payment details.
6323
+ </span>
6324
+ </span>
6325
+ </div>
6326
+ )}
6327
+ {inbound.addressed && inbound.addressed !== 'direct' && (
6328
+ <p className={styles.copiedBanner} role="note">
6329
+ <span className={styles.copiedBannerIcon}>{ICONS.info}</span>
6330
+ {inbound.addressed === 'copied'
6331
+ ? 'You were copied in this mail — it was addressed to someone else.'
6332
+ : 'You were not addressed directly — this copy reached you another way.'}
6333
+ </p>
6334
+ )}
6122
6335
  {showFullHeaders && (
6123
6336
  <dl className={styles.fullHeaders}>
6124
6337
  <div><dt>From</dt><dd>{inbound.from}</dd></div>
@@ -6888,8 +7101,10 @@ export default function DevMailPage() {
6888
7101
  <BodySkeleton />
6889
7102
  ) : selectedDetail.html ? (
6890
7103
  <iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
7104
+ ) : selectedDetail.text ? (
7105
+ <pre className={styles.readerText}>{selectedDetail.text}</pre>
6891
7106
  ) : (
6892
- <pre className={styles.readerText}>{selectedDetail.text ?? '(no content)'}</pre>
7107
+ renderBodylessAttachment(selectedDetail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
6893
7108
  )}
6894
7109
  </div>
6895
7110
  </>
@@ -7014,10 +7229,10 @@ export default function DevMailPage() {
7014
7229
  {ICONS.refresh}
7015
7230
  </button>
7016
7231
  )}
7017
- {canInstall && !installed && (
7232
+ {!installed && (canInstall || ios) && (
7018
7233
  <button
7019
7234
  className={styles.themeToggle}
7020
- onClick={install}
7235
+ onClick={canInstall ? install : () => { setSettingsTab('app'); setSettingsOpen(true) }}
7021
7236
  aria-label={`Install ${CLIENT_BRAND.name} Mail as an app`}
7022
7237
  >
7023
7238
  {ICONS.install}
@@ -7151,7 +7366,7 @@ export default function DevMailPage() {
7151
7366
  <span className={styles.composePen}>{ICONS.pencil}</span>
7152
7367
  <span className={styles.railLabel}>Compose</span>
7153
7368
  </button>
7154
- {(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
7369
+ {(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'spam', 'trash'] as Folder[]).map(key => {
7155
7370
  const count =
7156
7371
  key === 'inbox'
7157
7372
  ? folderCounts.inbox || ''
@@ -7167,7 +7382,9 @@ export default function DevMailPage() {
7167
7382
  ? drafts.length || ''
7168
7383
  : key === 'archived'
7169
7384
  ? folderCounts.archived || ''
7170
- : folderCounts.trashed || ''
7385
+ : key === 'spam'
7386
+ ? folderCounts.spam || ''
7387
+ : folderCounts.trashed || ''
7171
7388
  return (
7172
7389
  <button
7173
7390
  key={key}
@@ -7341,6 +7558,11 @@ export default function DevMailPage() {
7341
7558
  {ICONS.trash}
7342
7559
  <span className={styles.bulkBtnLabel}>Delete</span>
7343
7560
  </button>
7561
+ ) : folder === 'spam' ? (
7562
+ <button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), false)} title="Not spam">
7563
+ {ICONS.inbox}
7564
+ <span className={styles.bulkBtnLabel}>Not spam</span>
7565
+ </button>
7344
7566
  ) : folder === 'scheduled' ? (
7345
7567
  <button className={styles.bulkBtn} onClick={bulkCancelScheduled} title="Cancel sends">
7346
7568
  {ICONS.trash}
@@ -7350,6 +7572,10 @@ export default function DevMailPage() {
7350
7572
  <>
7351
7573
  {folder === 'inbox' && (
7352
7574
  <>
7575
+ <button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), true)} title="Report spam">
7576
+ {ICONS.warn}
7577
+ <span className={styles.bulkBtnLabel}>Spam</span>
7578
+ </button>
7353
7579
  <button className={styles.bulkBtn} onClick={() => bulkFlag({ read: true })} title="Mark read">
7354
7580
  {ICONS.unread}
7355
7581
  <span className={styles.bulkBtnLabel}>Read</span>
@@ -7592,6 +7818,20 @@ export default function DevMailPage() {
7592
7818
  <Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
7593
7819
  </p>
7594
7820
  {item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
7821
+ {item.kind === 'inbound' && item.risk && item.risk !== 'clean' && (
7822
+ <span className={styles.riskNote}>
7823
+ <span className={styles.riskNoteIcon}>{ICONS.warn}</span>
7824
+ {item.risk === 'virus' ? 'Virus scan failed' : item.risk === 'spam' ? 'Looks like spam' : 'Sender unverified'}
7825
+ </span>
7826
+ )}
7827
+ {item.kind === 'inbound' && item.addressed !== 'direct' && (
7828
+ <span className={styles.copiedNote}>
7829
+ <span className={styles.copiedNoteIcon}>{ICONS.info}</span>
7830
+ {item.addressed === 'copied'
7831
+ ? 'You were copied in this mail'
7832
+ : 'You were not addressed directly'}
7833
+ </span>
7834
+ )}
7595
7835
  {item.labels.length > 0 && (
7596
7836
  <span className={styles.itemLabels}>
7597
7837
  {item.labels.map(labelId => {
@@ -9066,13 +9306,16 @@ export default function DevMailPage() {
9066
9306
  ? 'Installed. Launch it from your dock, home screen or app list.'
9067
9307
  : canInstall
9068
9308
  ? 'Install it as an app so it opens in its own window and can show notifications.'
9069
- : 'Your browser handles this from its own menu — look for Install or Add to Home Screen.'}
9309
+ : ios
9310
+ ? 'Three taps, and it lives on your Home Screen like any other app.'
9311
+ : 'Your browser handles this from its own menu — look for Install or Add to Home Screen.'}
9070
9312
  </p>
9071
9313
  {!installed && canInstall && (
9072
9314
  <button type="button" className={styles.sendBtn} onClick={install}>
9073
9315
  {ICONS.install} Install app
9074
9316
  </button>
9075
9317
  )}
9318
+ {!installed && !canInstall && ios && <InstallGuide browser={ios} appName={CLIENT_BRAND.name} />}
9076
9319
  </div>
9077
9320
  <div className={styles.settingsField}>
9078
9321
  <span>Signed in as</span>