@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/README.md +17 -0
- package/app/api/mail/inbox/counts/route.ts +7 -7
- package/app/api/mail/inbox/route.ts +23 -4
- package/app/api/mail/maintenance/received/route.ts +11 -6
- package/app/api/mail/maintenance/rejudge/route.ts +32 -0
- package/app/api/mail/scheduled/route.ts +48 -0
- package/app/api/mail/scheduled/run/route.ts +19 -0
- package/app/api/mail/send/route.ts +35 -3
- package/app/api/mail/threads/route.ts +1 -1
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +197 -1
- package/app/mail/page.tsx +278 -35
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/lib/dev-auth.ts +25 -9
- package/lib/mail-provider.ts +22 -2
- package/lib/mailbox.ts +519 -42
- package/lib/receive.test.ts +20 -8
- package/lib/receive.ts +109 -12
- package/lib/scheduled.ts +168 -0
- package/lib/schema.test.ts +50 -0
- package/lib/ses-send.ts +14 -2
- package/package.json +1 -1
- package/tools/__pycache__/import-mbox.cpython-314.pyc +0 -0
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
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
-
|
|
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' ||
|
|
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)
|
|
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 =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}>
|
|
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
|
-
{
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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>
|