@novacraft-engineering/mailbox 0.4.4 → 0.4.17
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/accessors/reset-link/route.ts +46 -0
- package/app/api/mail/accessors/route.ts +16 -0
- package/app/api/mail/emails/[id]/route.ts +11 -3
- package/app/api/mail/emails/route.ts +14 -4
- 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/recovery/route.ts +95 -0
- package/app/api/mail/recovery/verify/route.ts +29 -0
- package/app/api/mail/request-reset/route.ts +36 -8
- 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 +39 -7
- package/app/api/mail/threads/route.ts +1 -1
- package/app/api/share/[id]/download/route.ts +71 -0
- package/app/api/share/[id]/route.ts +11 -6
- package/app/globals.css +7 -0
- package/app/mail/AttachmentLightbox.tsx +1 -1
- package/app/mail/ConfirmDialog.tsx +53 -8
- package/app/mail/InstallGuide.tsx +133 -0
- package/app/mail/page.module.css +254 -1
- package/app/mail/page.tsx +539 -87
- package/app/mail/pwa.test.ts +30 -0
- package/app/mail/pwa.ts +25 -1
- package/app/mail/search.ts +17 -0
- package/app/share/[id]/page.tsx +61 -11
- package/app/share/[id]/share.module.css +19 -0
- package/lib/dev-auth.test.ts +15 -0
- package/lib/dev-auth.ts +29 -9
- package/lib/mail-provider.test.ts +23 -0
- package/lib/mail-provider.ts +39 -3
- package/lib/mailbox.ts +578 -51
- package/lib/receive.test.ts +25 -0
- package/lib/receive.ts +117 -17
- package/lib/scheduled.ts +168 -0
- package/lib/ses-send.ts +14 -2
- package/lib/share-ticket.ts +42 -0
- 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
|
-
import { useConfirm } from './ConfirmDialog'
|
|
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
|
|
@@ -173,6 +174,8 @@ type SentEmail = {
|
|
|
173
174
|
openedAt: string | null
|
|
174
175
|
owner?: string
|
|
175
176
|
inReplyTo?: string | null
|
|
177
|
+
/** Sent by the app itself — an invite, a reset, an auto-reply. */
|
|
178
|
+
isAuto?: boolean
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
type DownloadAttachment = { filename: string; size: number; downloadUrl: string; shareId?: string; contentType?: string; contentId?: string }
|
|
@@ -187,6 +190,8 @@ type SentDetail = SentEmail & {
|
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
type ConversationRow = {
|
|
193
|
+
addressed?: 'direct' | 'copied' | 'other'
|
|
194
|
+
risk?: 'clean' | 'suspicious' | 'spam' | 'virus'
|
|
190
195
|
threadId: string
|
|
191
196
|
snoozedUntil?: string | null
|
|
192
197
|
subject: string
|
|
@@ -199,6 +204,7 @@ type ConversationRow = {
|
|
|
199
204
|
inboxCount: number
|
|
200
205
|
archivedCount: number
|
|
201
206
|
trashedCount: number
|
|
207
|
+
spamCount: number
|
|
202
208
|
attachCount: number
|
|
203
209
|
senders: string[]
|
|
204
210
|
snippet: string
|
|
@@ -211,6 +217,11 @@ type InboundEmail = {
|
|
|
211
217
|
from: string
|
|
212
218
|
to: string[]
|
|
213
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
|
|
214
225
|
bcc: string[]
|
|
215
226
|
replyTo: string[]
|
|
216
227
|
subject: string
|
|
@@ -551,22 +562,34 @@ function markdownToHtml(src: string): string {
|
|
|
551
562
|
return out.join('\n')
|
|
552
563
|
}
|
|
553
564
|
|
|
554
|
-
function buildEmailHtml(
|
|
555
|
-
|
|
565
|
+
function buildEmailHtml(
|
|
566
|
+
data: ComposeData,
|
|
567
|
+
signatureHtml: string,
|
|
568
|
+
fontCss = '',
|
|
569
|
+
base: BaseFont = EMPTY_FONT,
|
|
570
|
+
/**
|
|
571
|
+
* Links to files too big to attach. They belong with the message, above the signature —
|
|
572
|
+
* appended at the end they landed below the sign-off AND below the quoted thread, which
|
|
573
|
+
* in Outlook is behind the "show trimmed content" fold, so the recipient saw a covering
|
|
574
|
+
* note with no sign of the document it was about.
|
|
575
|
+
*/
|
|
576
|
+
linksHtml = '',
|
|
577
|
+
): string {
|
|
578
|
+
if (data.htmlDirty && data.htmlSource.trim()) return data.htmlSource + linksHtml
|
|
556
579
|
// Editor HTML for anything composed here; markdown only for drafts saved before it.
|
|
557
580
|
const body = data.bodyHtml.trim() ? data.bodyHtml : markdownToHtml(data.markdown)
|
|
558
|
-
const inner = body + (data.useSignature ? signatureHtml : '')
|
|
581
|
+
const inner = body + linksHtml + (data.useSignature ? signatureHtml : '')
|
|
559
582
|
const fonts = fontCss ? `<style>${fontCss}</style>` : ''
|
|
560
583
|
return `${fonts}<div style="font-family:${fontStack(base.family)};font-size:${base.size || '15px'};line-height:${lineSpacingOf(base)};color:#030712;">${outlookSafeImages(inlineEmailStyles(inner, base))}</div>${data.quoteHtml ?? ''}`
|
|
561
584
|
}
|
|
562
585
|
|
|
563
|
-
function buildEmailText(data: ComposeData, signatureText: string): string {
|
|
586
|
+
function buildEmailText(data: ComposeData, signatureText: string, linksText = ''): string {
|
|
564
587
|
// Every message carries a text part: some clients prefer it, and its absence reads as spam.
|
|
565
588
|
const quoteText = data.quoteHtml ? `\n\n${htmlToPlainText(data.quoteHtml)}` : ''
|
|
566
589
|
if (data.bodyHtml.trim()) {
|
|
567
|
-
return (htmlToPlainText(data.bodyHtml) + (data.useSignature ? signatureText : '') + quoteText).trim()
|
|
590
|
+
return (htmlToPlainText(data.bodyHtml) + linksText + (data.useSignature ? signatureText : '') + quoteText).trim()
|
|
568
591
|
}
|
|
569
|
-
const body = data.markdown + (data.useSignature ? signatureText : '') + quoteText
|
|
592
|
+
const body = data.markdown + linksText + (data.useSignature ? signatureText : '') + quoteText
|
|
570
593
|
return body
|
|
571
594
|
// Images first, for the same precedence reason as inlineMd. The replacement must not
|
|
572
595
|
// contain brackets, or the link rule below matches across it and mangles the line.
|
|
@@ -936,6 +959,10 @@ function frameHtml(html: string, allowRemote: boolean, spacing = DEFAULT_LINE_SP
|
|
|
936
959
|
const csp = allowRemote
|
|
937
960
|
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src * data:; style-src 'unsafe-inline' *; font-src * data:; media-src * data:">`
|
|
938
961
|
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${OWN_IMAGE_HOSTS} data:; style-src 'unsafe-inline'; font-src data:">`
|
|
962
|
+
// Links open in a new tab, which a sandboxed frame may only do with allow-popups. Without
|
|
963
|
+
// it every link in every message was dead: the click was swallowed with no error and no
|
|
964
|
+
// navigation, which reads as a broken button rather than a blocked one. Scripts are still
|
|
965
|
+
// not allowed, so nothing here can open a tab on its own — only a real click can.
|
|
939
966
|
return `<!doctype html><html><head><meta charset="utf-8">${csp}${readerTheme(spacing, dark)}<base target="_blank"></head><body><div class="nc-paper">${stripOwnPixel(html)}</div></body>`
|
|
940
967
|
}
|
|
941
968
|
|
|
@@ -1236,6 +1263,8 @@ const ICONS = {
|
|
|
1236
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>,
|
|
1237
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>,
|
|
1238
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>,
|
|
1239
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>,
|
|
1240
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>,
|
|
1241
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>,
|
|
@@ -1297,11 +1326,13 @@ const FOLDER_ICONS: Record<Folder, React.ReactNode> = {
|
|
|
1297
1326
|
scheduled: ICONS.scheduled,
|
|
1298
1327
|
drafts: ICONS.drafts,
|
|
1299
1328
|
archived: ICONS.archive,
|
|
1329
|
+
spam: ICONS.warn,
|
|
1300
1330
|
trash: ICONS.trash,
|
|
1301
1331
|
}
|
|
1302
1332
|
|
|
1303
1333
|
export default function DevMailPage() {
|
|
1304
1334
|
const confirm = useConfirm()
|
|
1335
|
+
const promptFor = usePrompt()
|
|
1305
1336
|
const [email, setEmail] = useState('')
|
|
1306
1337
|
const [password, setPassword] = useState('')
|
|
1307
1338
|
const [isLoggedIn, setIsLoggedIn] = useState(false)
|
|
@@ -1312,6 +1343,12 @@ export default function DevMailPage() {
|
|
|
1312
1343
|
const [loginBusy, setLoginBusy] = useState(false)
|
|
1313
1344
|
const [resetMode, setResetMode] = useState(false)
|
|
1314
1345
|
const [resetSent, setResetSent] = useState(false)
|
|
1346
|
+
const [resetMessage, setResetMessage] = useState('')
|
|
1347
|
+
const [recoveryEmail, setRecoveryEmail] = useState('')
|
|
1348
|
+
const [recoverySaved, setRecoverySaved] = useState<{ address: string | null; verified: boolean }>({ address: null, verified: false })
|
|
1349
|
+
const [recoveryMsg, setRecoveryMsg] = useState('')
|
|
1350
|
+
const [recoveryBad, setRecoveryBad] = useState(false)
|
|
1351
|
+
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
|
1315
1352
|
const [resetBusy, setResetBusy] = useState(false)
|
|
1316
1353
|
const [showPassword, setShowPassword] = useState(false)
|
|
1317
1354
|
const [loginDomain, setLoginDomain] = useState(DEFAULT_MAIL_DOMAIN)
|
|
@@ -1322,6 +1359,9 @@ export default function DevMailPage() {
|
|
|
1322
1359
|
const [folder, setFolder] = useState<Folder>('inbox')
|
|
1323
1360
|
const [search, setSearch] = useState('')
|
|
1324
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[]>([])
|
|
1325
1365
|
const [inboxEmails, setInboxEmails] = useState<InboundEmail[]>([])
|
|
1326
1366
|
// The server owns search and paging now; the client holds one page at a time
|
|
1327
1367
|
// rather than the whole mailbox.
|
|
@@ -1329,7 +1369,7 @@ export default function DevMailPage() {
|
|
|
1329
1369
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
1330
1370
|
const [inboxCursor, setInboxCursor] = useState<string | null>(null)
|
|
1331
1371
|
const inboxFetch = useRef<string | null>(null)
|
|
1332
|
-
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 }
|
|
1333
1373
|
const [serverCounts, setServerCounts] = useState<(FolderTally & { conversations: FolderTally | null }) | null>(null)
|
|
1334
1374
|
const [countsLoading, setCountsLoading] = useState(false)
|
|
1335
1375
|
const [composeExpanded, setComposeExpanded] = useState(true)
|
|
@@ -1441,6 +1481,7 @@ export default function DevMailPage() {
|
|
|
1441
1481
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1442
1482
|
const [settingsTab, setSettingsTab] = useState<SettingsTab>('profile')
|
|
1443
1483
|
const [pwCurrent, setPwCurrent] = useState('')
|
|
1484
|
+
const [pwReveal, setPwReveal] = useState(false)
|
|
1444
1485
|
const [pwNext, setPwNext] = useState('')
|
|
1445
1486
|
const [pwRepeat, setPwRepeat] = useState('')
|
|
1446
1487
|
const [pwBusy, setPwBusy] = useState(false)
|
|
@@ -1465,7 +1506,7 @@ export default function DevMailPage() {
|
|
|
1465
1506
|
const [settings, setMailSettings] = useState<MailSettings>(DEFAULT_SETTINGS)
|
|
1466
1507
|
const tickerOn = (spot: TickerSpot) => settings.ticker?.[spot] ?? tickerDefault(spot)
|
|
1467
1508
|
const messagesAlwaysOpen = settings.threadMessages === 'open'
|
|
1468
|
-
const { canInstall, installed, install } = useInstall()
|
|
1509
|
+
const { canInstall, installed, install, ios } = useInstall()
|
|
1469
1510
|
const { permission: notifyPermission, request: requestNotifyPermission, announce } = useNotifications(
|
|
1470
1511
|
settings.desktopNotifications,
|
|
1471
1512
|
)
|
|
@@ -1525,6 +1566,7 @@ export default function DevMailPage() {
|
|
|
1525
1566
|
const [renameDomain, setRenameDomain] = useState('')
|
|
1526
1567
|
const [inviteBusy, setInviteBusy] = useState(false)
|
|
1527
1568
|
const [accessorsMsg, setAccessorsMsg] = useState('')
|
|
1569
|
+
const [accessorsBad, setAccessorsBad] = useState(false)
|
|
1528
1570
|
const isAdmin = account?.role === 'admin'
|
|
1529
1571
|
|
|
1530
1572
|
const [composeOpen, setComposeOpen] = useState(false)
|
|
@@ -1661,7 +1703,7 @@ export default function DevMailPage() {
|
|
|
1661
1703
|
|
|
1662
1704
|
const [threads, setThreads] = useState<ConversationRow[]>([])
|
|
1663
1705
|
const [threadsResolved, setThreadsResolved] = useState(false)
|
|
1664
|
-
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'
|
|
1665
1707
|
const threadsFetch = useRef<string | null>(null)
|
|
1666
1708
|
const threadsLoadedFolder = useRef<string | null>(null)
|
|
1667
1709
|
const [threadsCursor, setThreadsCursor] = useState<string | null>(null)
|
|
@@ -1974,6 +2016,33 @@ export default function DevMailPage() {
|
|
|
1974
2016
|
} catch {}
|
|
1975
2017
|
}, [apiHeaders, mailboxQuery])
|
|
1976
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
|
+
|
|
1977
2046
|
/**
|
|
1978
2047
|
* Searching Sent asks the server. The list holds the newest few hundred sends and a
|
|
1979
2048
|
* search that only looked through those found nothing older, with no sign that it had
|
|
@@ -2520,7 +2589,7 @@ export default function DevMailPage() {
|
|
|
2520
2589
|
const refreshAll = useCallback(async () => {
|
|
2521
2590
|
setRefreshing(true)
|
|
2522
2591
|
try {
|
|
2523
|
-
await Promise.all([loadSent(), refreshInbox(), loadThreads(), loadEvents()])
|
|
2592
|
+
await Promise.all([loadSent(), loadQueued(), refreshInbox(), loadThreads(), loadEvents()])
|
|
2524
2593
|
lastRefreshAt.current = Date.now()
|
|
2525
2594
|
} finally {
|
|
2526
2595
|
setRefreshing(false)
|
|
@@ -2560,14 +2629,96 @@ export default function DevMailPage() {
|
|
|
2560
2629
|
if (isAdmin) loadAccessors()
|
|
2561
2630
|
}, [isAdmin, loadAccessors])
|
|
2562
2631
|
|
|
2632
|
+
const loadRecovery = useCallback(async () => {
|
|
2633
|
+
try {
|
|
2634
|
+
const response = await fetch('/api/mail/recovery', { headers: apiHeaders() })
|
|
2635
|
+
const data = await response.json()
|
|
2636
|
+
if (!data.ok) return
|
|
2637
|
+
setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
|
|
2638
|
+
setRecoveryEmail(data.recoveryEmail ?? '')
|
|
2639
|
+
} catch {}
|
|
2640
|
+
}, [apiHeaders])
|
|
2641
|
+
|
|
2642
|
+
useEffect(() => {
|
|
2643
|
+
if (isLoggedIn) loadRecovery()
|
|
2644
|
+
}, [isLoggedIn, loadRecovery])
|
|
2645
|
+
|
|
2646
|
+
const saveRecovery = useCallback(async () => {
|
|
2647
|
+
setRecoveryBusy(true)
|
|
2648
|
+
setRecoveryMsg('')
|
|
2649
|
+
setRecoveryBad(false)
|
|
2650
|
+
try {
|
|
2651
|
+
const response = await fetch('/api/mail/recovery', {
|
|
2652
|
+
method: 'POST',
|
|
2653
|
+
headers: apiHeaders(),
|
|
2654
|
+
body: JSON.stringify({ recovery: recoveryEmail.trim() }),
|
|
2655
|
+
})
|
|
2656
|
+
const data = await response.json()
|
|
2657
|
+
if (data.ok) {
|
|
2658
|
+
setRecoverySaved({ address: data.recoveryEmail ?? null, verified: Boolean(data.verified) })
|
|
2659
|
+
setRecoveryMsg(
|
|
2660
|
+
data.recoveryEmail
|
|
2661
|
+
? `Confirm it from ${data.recoveryEmail} — the link is on its way and expires in 24 hours.`
|
|
2662
|
+
: 'Recovery address removed.',
|
|
2663
|
+
)
|
|
2664
|
+
} else {
|
|
2665
|
+
setRecoveryBad(true)
|
|
2666
|
+
setRecoveryMsg(data.error || 'Could not save that address')
|
|
2667
|
+
}
|
|
2668
|
+
} catch {
|
|
2669
|
+
setRecoveryBad(true)
|
|
2670
|
+
setRecoveryMsg('Could not reach the server')
|
|
2671
|
+
} finally {
|
|
2672
|
+
setRecoveryBusy(false)
|
|
2673
|
+
}
|
|
2674
|
+
}, [apiHeaders, recoveryEmail])
|
|
2675
|
+
|
|
2676
|
+
const mintResetLink = useCallback(async (target: string) => {
|
|
2677
|
+
setAccessorsBad(false)
|
|
2678
|
+
setAccessorsMsg('')
|
|
2679
|
+
try {
|
|
2680
|
+
const response = await fetch('/api/mail/accessors/reset-link', {
|
|
2681
|
+
method: 'POST',
|
|
2682
|
+
headers: apiHeaders(),
|
|
2683
|
+
body: JSON.stringify({ email: target }),
|
|
2684
|
+
})
|
|
2685
|
+
const data = await response.json()
|
|
2686
|
+
if (!data.ok) {
|
|
2687
|
+
setAccessorsBad(true)
|
|
2688
|
+
setAccessorsMsg(data.error || 'Could not make a reset link')
|
|
2689
|
+
return
|
|
2690
|
+
}
|
|
2691
|
+
await navigator.clipboard?.writeText(data.url).catch(() => {})
|
|
2692
|
+
setAccessorsMsg(`Reset link for ${data.email} copied — it works once and expires in ${data.expiresInMinutes} minutes.`)
|
|
2693
|
+
} catch {
|
|
2694
|
+
setAccessorsBad(true)
|
|
2695
|
+
setAccessorsMsg('Could not reach the server')
|
|
2696
|
+
}
|
|
2697
|
+
}, [apiHeaders])
|
|
2698
|
+
|
|
2699
|
+
const failInvite = useCallback((message: string) => {
|
|
2700
|
+
setAccessorsMsg(message)
|
|
2701
|
+
setAccessorsBad(true)
|
|
2702
|
+
}, [])
|
|
2703
|
+
|
|
2704
|
+
useEffect(() => {
|
|
2705
|
+
if (!accessorsBad) return
|
|
2706
|
+
const timer = setTimeout(() => {
|
|
2707
|
+
setAccessorsBad(false)
|
|
2708
|
+
setAccessorsMsg('')
|
|
2709
|
+
}, 6000)
|
|
2710
|
+
return () => clearTimeout(timer)
|
|
2711
|
+
}, [accessorsBad, accessorsMsg])
|
|
2712
|
+
|
|
2563
2713
|
const inviteAccessor = useCallback(async () => {
|
|
2564
2714
|
const target = inviteEmail.trim().toLowerCase()
|
|
2565
|
-
if (
|
|
2566
|
-
|
|
2715
|
+
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(target)) {
|
|
2716
|
+
failInvite('That is not an email address we can send to.')
|
|
2567
2717
|
return
|
|
2568
2718
|
}
|
|
2569
2719
|
setInviteBusy(true)
|
|
2570
2720
|
setAccessorsMsg('')
|
|
2721
|
+
setAccessorsBad(false)
|
|
2571
2722
|
try {
|
|
2572
2723
|
const response = await fetch('/api/mail/accessors', {
|
|
2573
2724
|
method: 'POST',
|
|
@@ -2582,6 +2733,7 @@ export default function DevMailPage() {
|
|
|
2582
2733
|
})
|
|
2583
2734
|
const data = await response.json()
|
|
2584
2735
|
if (data.ok) {
|
|
2736
|
+
setAccessorsBad(false)
|
|
2585
2737
|
setAccessorsMsg(`Invite sent to ${data.email} — mailbox ${data.address}.`)
|
|
2586
2738
|
setInviteEmail('')
|
|
2587
2739
|
setInviteName('')
|
|
@@ -2589,14 +2741,14 @@ export default function DevMailPage() {
|
|
|
2589
2741
|
setInviteRole('member')
|
|
2590
2742
|
loadAccessors()
|
|
2591
2743
|
} else {
|
|
2592
|
-
|
|
2744
|
+
failInvite(data.error || 'Could not send the invite')
|
|
2593
2745
|
}
|
|
2594
2746
|
} catch {
|
|
2595
|
-
|
|
2747
|
+
failInvite('Could not reach the server')
|
|
2596
2748
|
} finally {
|
|
2597
2749
|
setInviteBusy(false)
|
|
2598
2750
|
}
|
|
2599
|
-
}, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors])
|
|
2751
|
+
}, [apiHeaders, inviteEmail, inviteName, inviteRole, inviteHandle, inviteDomain, loadAccessors, failInvite])
|
|
2600
2752
|
|
|
2601
2753
|
const saveMailboxName = useCallback(
|
|
2602
2754
|
async (targetEmail: string) => {
|
|
@@ -2773,13 +2925,21 @@ export default function DevMailPage() {
|
|
|
2773
2925
|
.catch(() => {})
|
|
2774
2926
|
}, [isLoggedIn, apiHeaders])
|
|
2775
2927
|
|
|
2776
|
-
const scheduledEmails = useMemo(
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
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])
|
|
2783
2943
|
const deliveredEmails = useMemo(
|
|
2784
2944
|
() => sentEmails.filter(entry => !scheduledEmails.some(scheduled => scheduled.id === entry.id)),
|
|
2785
2945
|
[sentEmails, scheduledEmails],
|
|
@@ -2837,6 +2997,7 @@ export default function DevMailPage() {
|
|
|
2837
2997
|
starred: serverCounts?.starred ?? 0,
|
|
2838
2998
|
archived: serverCounts?.archived ?? 0,
|
|
2839
2999
|
trashed: serverCounts?.trashed ?? 0,
|
|
3000
|
+
spam: serverCounts?.spam ?? 0,
|
|
2840
3001
|
snoozed: serverCounts?.snoozed ?? 0,
|
|
2841
3002
|
}),
|
|
2842
3003
|
[serverCounts],
|
|
@@ -2855,6 +3016,7 @@ export default function DevMailPage() {
|
|
|
2855
3016
|
if (folder === 'trash') return tally.trashed
|
|
2856
3017
|
if (folder === 'starred') return tally.starred
|
|
2857
3018
|
if (folder === 'snoozed') return tally.snoozed
|
|
3019
|
+
if (folder === 'spam') return tally.spam
|
|
2858
3020
|
if (folder === 'inbox') return tally.inbox
|
|
2859
3021
|
return null
|
|
2860
3022
|
}, [serverCounts, search, folder])
|
|
@@ -2889,6 +3051,7 @@ export default function DevMailPage() {
|
|
|
2889
3051
|
read: entry.read,
|
|
2890
3052
|
starred: entry.starred,
|
|
2891
3053
|
hasAttachment: entry.attachments.length > 0,
|
|
3054
|
+
addressed: entry.addressed,
|
|
2892
3055
|
labels: entry.labels,
|
|
2893
3056
|
folder: entry.trashed ? 'trash' : entry.archived ? 'archive' : 'inbox',
|
|
2894
3057
|
})
|
|
@@ -2981,6 +3144,9 @@ export default function DevMailPage() {
|
|
|
2981
3144
|
const OPERATORS: Suggestion[] = [
|
|
2982
3145
|
{ label: 'is:unread', hint: 'only unread', apply: `${before}is:unread ` },
|
|
2983
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 ` },
|
|
2984
3150
|
{ label: 'has:attachment', hint: 'with files', apply: `${before}has:attachment ` },
|
|
2985
3151
|
{ label: 'from:', hint: 'sender', apply: `${before}from:` },
|
|
2986
3152
|
{ label: 'to:', hint: 'recipient', apply: `${before}to:` },
|
|
@@ -3006,7 +3172,8 @@ export default function DevMailPage() {
|
|
|
3006
3172
|
if (folder === 'starred') return entry.starred && !entry.trashed
|
|
3007
3173
|
if (folder === 'archived') return entry.archived && !entry.trashed
|
|
3008
3174
|
if (folder === 'trash') return entry.trashed
|
|
3009
|
-
|
|
3175
|
+
if (folder === 'spam') return Boolean(entry.spam)
|
|
3176
|
+
return !entry.archived && !entry.trashed && !asleep && !entry.spam
|
|
3010
3177
|
},
|
|
3011
3178
|
[folder],
|
|
3012
3179
|
)
|
|
@@ -3097,7 +3264,8 @@ export default function DevMailPage() {
|
|
|
3097
3264
|
|
|
3098
3265
|
/** The folders backed by the paged inbound list, as opposed to ones loaded whole. */
|
|
3099
3266
|
const isInboundFolder =
|
|
3100
|
-
folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' ||
|
|
3267
|
+
folder === 'inbox' || folder === 'starred' || folder === 'snoozed' || folder === 'archived' ||
|
|
3268
|
+
folder === 'spam' || folder === 'trash'
|
|
3101
3269
|
|
|
3102
3270
|
const listItems = useMemo(() => {
|
|
3103
3271
|
const sentToItem = (entry: SentEmail) => ({
|
|
@@ -3106,6 +3274,8 @@ export default function DevMailPage() {
|
|
|
3106
3274
|
primary: `To: ${entry.to.join(', ')}`,
|
|
3107
3275
|
subject: entry.subject,
|
|
3108
3276
|
snippet: '',
|
|
3277
|
+
addressed: 'direct' as const,
|
|
3278
|
+
risk: 'clean' as const,
|
|
3109
3279
|
time:
|
|
3110
3280
|
folder === 'scheduled' && entry.scheduledAt
|
|
3111
3281
|
? `in ${formatCountdown(new Date(entry.scheduledAt).getTime() - now)}`
|
|
@@ -3113,7 +3283,7 @@ export default function DevMailPage() {
|
|
|
3113
3283
|
unread: false,
|
|
3114
3284
|
starred: entry.starred,
|
|
3115
3285
|
hasAttachment: (entry.attachmentCount ?? 0) > 0,
|
|
3116
|
-
chip: (folder === 'scheduled' ? 'scheduled' : null) as string | null,
|
|
3286
|
+
chip: (folder === 'scheduled' ? 'scheduled' : entry.isAuto ? 'automated' : null) as string | null,
|
|
3117
3287
|
threadCount: 1,
|
|
3118
3288
|
latestAt: new Date(entry.createdAt).getTime(),
|
|
3119
3289
|
labels: [] as string[],
|
|
@@ -3132,6 +3302,7 @@ export default function DevMailPage() {
|
|
|
3132
3302
|
}
|
|
3133
3303
|
const inThreadFolder = (thread: ConversationRow) => {
|
|
3134
3304
|
const asleep = Boolean(thread.snoozedUntil && Date.parse(thread.snoozedUntil) > Date.now())
|
|
3305
|
+
if (folder === 'spam') return thread.spamCount > 0
|
|
3135
3306
|
if (folder === 'snoozed') return asleep
|
|
3136
3307
|
if (folder === 'archived') return thread.archivedCount > 0
|
|
3137
3308
|
if (folder === 'trash') return thread.trashedCount > 0
|
|
@@ -3156,6 +3327,8 @@ export default function DevMailPage() {
|
|
|
3156
3327
|
starred: thread.starredCount > 0,
|
|
3157
3328
|
hasAttachment: thread.attachCount > 0,
|
|
3158
3329
|
chip: null as string | null,
|
|
3330
|
+
addressed: thread.addressed ?? 'direct',
|
|
3331
|
+
risk: thread.risk ?? 'clean',
|
|
3159
3332
|
threadCount: thread.count,
|
|
3160
3333
|
latestAt: new Date(thread.latestAt).getTime(),
|
|
3161
3334
|
labels: thread.labels,
|
|
@@ -3189,6 +3362,15 @@ export default function DevMailPage() {
|
|
|
3189
3362
|
starred: members.some(member => member.starred),
|
|
3190
3363
|
hasAttachment: members.some(member => member.attachments.length > 0),
|
|
3191
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',
|
|
3192
3374
|
threadCount: members.length,
|
|
3193
3375
|
latestAt: new Date(latest.receivedAt).getTime(),
|
|
3194
3376
|
labels: Array.from(new Set(members.flatMap(member => member.labels))),
|
|
@@ -3257,7 +3439,10 @@ export default function DevMailPage() {
|
|
|
3257
3439
|
const selectedInbound = selectedId ? inboxEmails.find(entry => entry.id === selectedId) ?? null : null
|
|
3258
3440
|
const selectedSent =
|
|
3259
3441
|
selectedId && !selectedInbound
|
|
3260
|
-
? 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
|
|
3261
3446
|
: null
|
|
3262
3447
|
const selectedDetail = selectedSent ? detailCache[selectedSent.id] ?? null : null
|
|
3263
3448
|
const selectedIsThread = selectedInbound ? unifiedThread(selectedInbound.id).length > 1 : false
|
|
@@ -3468,6 +3653,36 @@ export default function DevMailPage() {
|
|
|
3468
3653
|
const setInboundFlagRef = useRef<(ids: string[], flags: InboundFlagPatch) => void>(() => {})
|
|
3469
3654
|
const threadIdsRef = useRef<(id: string) => string[]>(id => [id])
|
|
3470
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
|
+
|
|
3471
3686
|
const setInboundFlag = useCallback(
|
|
3472
3687
|
(ids: string[], flags: Partial<Pick<InboundEmail, 'read' | 'starred' | 'archived' | 'trashed'>>) => {
|
|
3473
3688
|
if (flags.read === true) ids.forEach(id => pendingRead.current.add(id))
|
|
@@ -3618,14 +3833,22 @@ export default function DevMailPage() {
|
|
|
3618
3833
|
const bulkCancelScheduled = useCallback(async () => {
|
|
3619
3834
|
const ids = Array.from(selectedBulk)
|
|
3620
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))
|
|
3621
3839
|
await Promise.all(
|
|
3622
|
-
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
|
+
),
|
|
3623
3845
|
)
|
|
3624
3846
|
setSelectedBulk(new Set())
|
|
3625
3847
|
setSentFlash('Canceled')
|
|
3626
3848
|
window.setTimeout(() => setSentFlash(''), 2500)
|
|
3627
3849
|
loadSent()
|
|
3628
|
-
|
|
3850
|
+
loadQueued()
|
|
3851
|
+
}, [selectedBulk, apiHeaders, loadSent, loadQueued, queuedSends])
|
|
3629
3852
|
|
|
3630
3853
|
// Admin: bulk-reassign the selection to a mailbox (routes inbound + sent ids separately).
|
|
3631
3854
|
const bulkAssign = useCallback(
|
|
@@ -3988,7 +4211,17 @@ export default function DevMailPage() {
|
|
|
3988
4211
|
if (request.status >= 200 && request.status < 300) resolve()
|
|
3989
4212
|
else reject(new Error(`Upload failed (${request.status})`))
|
|
3990
4213
|
}
|
|
3991
|
-
|
|
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
|
+
}
|
|
3992
4225
|
request.onabort = () => settle()
|
|
3993
4226
|
request.send(file)
|
|
3994
4227
|
}),
|
|
@@ -4183,15 +4416,10 @@ export default function DevMailPage() {
|
|
|
4183
4416
|
setComposeError(`${failedUpload.filename} did not upload. Remove it or try again.`)
|
|
4184
4417
|
return
|
|
4185
4418
|
}
|
|
4186
|
-
let html = buildEmailHtml(compose, signatureHtml, fontCss, defaultFont)
|
|
4187
|
-
let text = buildEmailText(compose, signatureText)
|
|
4188
|
-
if (!html.trim() && !text.trim()) {
|
|
4189
|
-
setComposeError('Write something first')
|
|
4190
|
-
return
|
|
4191
|
-
}
|
|
4192
|
-
|
|
4193
4419
|
const attachable = attachments.filter(entry => entry.size <= ATTACH_LIMIT_BYTES)
|
|
4194
4420
|
const linked = attachments.filter(entry => entry.size > ATTACH_LIMIT_BYTES)
|
|
4421
|
+
let linksHtml = ''
|
|
4422
|
+
let linksText = ''
|
|
4195
4423
|
if (linked.length) {
|
|
4196
4424
|
const rows = linked
|
|
4197
4425
|
.map(
|
|
@@ -4199,8 +4427,15 @@ export default function DevMailPage() {
|
|
|
4199
4427
|
`<tr><td style="padding:6px 0"><a href="${entry.shareUrl ?? entry.url}" style="display:inline-block;background:${CLIENT_BRAND.accent};color:#fff;text-decoration:none;font-weight:600;padding:9px 16px;border-radius:8px;font-family:Arial,sans-serif;font-size:14px">Download ${escapeHtml(entry.filename)} (${formatBytes(entry.size)})</a></td></tr>`,
|
|
4200
4428
|
)
|
|
4201
4429
|
.join('')
|
|
4202
|
-
|
|
4203
|
-
|
|
4430
|
+
linksHtml = `<div style="margin:18px 0"><p style="font-family:Arial,sans-serif;font-size:14px;color:#5A5170;margin:0 0 8px">${linked.length === 1 ? 'Attached, as a download link:' : 'Attached, as download links:'}</p><table role="presentation">${rows}</table></div>`
|
|
4431
|
+
linksText = `\n\n${linked.length === 1 ? 'Attached, as a download link:' : 'Attached, as download links:'}\n${linked.map(entry => `${entry.filename} — ${entry.shareUrl ?? entry.url}`).join('\n')}`
|
|
4432
|
+
}
|
|
4433
|
+
|
|
4434
|
+
const html = buildEmailHtml(compose, signatureHtml, fontCss, defaultFont, linksHtml)
|
|
4435
|
+
const text = buildEmailText(compose, signatureText, linksText)
|
|
4436
|
+
if (!html.trim() && !text.trim()) {
|
|
4437
|
+
setComposeError('Write something first')
|
|
4438
|
+
return
|
|
4204
4439
|
}
|
|
4205
4440
|
|
|
4206
4441
|
// Resend can't schedule a send that carries attachments, so those go out
|
|
@@ -4280,6 +4515,7 @@ export default function DevMailPage() {
|
|
|
4280
4515
|
setSentFlash('Canceled — back to draft')
|
|
4281
4516
|
window.setTimeout(() => setSentFlash(''), 2500)
|
|
4282
4517
|
loadSent()
|
|
4518
|
+
loadQueued()
|
|
4283
4519
|
} else {
|
|
4284
4520
|
setSentFlash(data.error?.includes('cancel') ? 'Too late — already sent' : `Could not cancel: ${data.error}`)
|
|
4285
4521
|
window.setTimeout(() => setSentFlash(''), 3000)
|
|
@@ -4296,33 +4532,45 @@ export default function DevMailPage() {
|
|
|
4296
4532
|
setSentFlash('Sent')
|
|
4297
4533
|
window.setTimeout(() => setSentFlash(''), 2500)
|
|
4298
4534
|
loadSent()
|
|
4535
|
+
loadQueued()
|
|
4299
4536
|
}
|
|
4300
4537
|
}, [undo, now, loadSent])
|
|
4301
4538
|
|
|
4302
4539
|
const cancelScheduled = async (id: string) => {
|
|
4303
4540
|
try {
|
|
4304
|
-
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() })
|
|
4305
4545
|
const data = await response.json()
|
|
4306
4546
|
setSentFlash(data.ok ? 'Canceled' : `Could not cancel: ${data.error}`)
|
|
4307
4547
|
window.setTimeout(() => setSentFlash(''), 2500)
|
|
4308
4548
|
if (data.ok) {
|
|
4309
4549
|
setSelectedId(null)
|
|
4310
4550
|
loadSent()
|
|
4551
|
+
loadQueued()
|
|
4311
4552
|
}
|
|
4312
4553
|
} catch {}
|
|
4313
4554
|
}
|
|
4314
4555
|
|
|
4315
4556
|
const rescheduleScheduled = async (id: string, iso: string) => {
|
|
4316
4557
|
try {
|
|
4317
|
-
const
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
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
|
+
})
|
|
4322
4570
|
const data = await response.json()
|
|
4323
4571
|
setSentFlash(data.ok ? 'Rescheduled' : `Could not reschedule: ${data.error}`)
|
|
4324
4572
|
window.setTimeout(() => setSentFlash(''), 2500)
|
|
4325
|
-
if (data.ok) loadSent()
|
|
4573
|
+
if (data.ok) { loadSent(); loadQueued() }
|
|
4326
4574
|
} catch {}
|
|
4327
4575
|
}
|
|
4328
4576
|
|
|
@@ -5006,13 +5254,24 @@ export default function DevMailPage() {
|
|
|
5006
5254
|
}
|
|
5007
5255
|
|
|
5008
5256
|
const revokeShareLink = async (id: string) => {
|
|
5009
|
-
|
|
5257
|
+
const agreed = await confirm({
|
|
5258
|
+
title: 'Revoke this link?',
|
|
5259
|
+
body: 'Anyone holding it loses access immediately, including people you have already sent it to.',
|
|
5260
|
+
confirmLabel: 'Revoke link',
|
|
5261
|
+
danger: true,
|
|
5262
|
+
})
|
|
5263
|
+
if (!agreed) return
|
|
5010
5264
|
await fetch(`/api/mail/share?id=${encodeURIComponent(id)}`, { method: 'DELETE', headers: apiHeaders() }).catch(() => {})
|
|
5011
5265
|
loadShares()
|
|
5012
5266
|
}
|
|
5013
5267
|
|
|
5014
5268
|
const changeSharePassword = async (id: string) => {
|
|
5015
|
-
const next =
|
|
5269
|
+
const next = await promptFor({
|
|
5270
|
+
title: 'Password for this link',
|
|
5271
|
+
body: 'Anyone opening the link is asked for this. Leave it blank to remove the password entirely.',
|
|
5272
|
+
confirmLabel: 'Save password',
|
|
5273
|
+
field: { label: 'Password', type: 'password', placeholder: 'Blank removes it' },
|
|
5274
|
+
})
|
|
5016
5275
|
if (next === null) return
|
|
5017
5276
|
await fetch('/api/mail/share', {
|
|
5018
5277
|
method: 'PATCH',
|
|
@@ -5072,11 +5331,13 @@ export default function DevMailPage() {
|
|
|
5072
5331
|
}
|
|
5073
5332
|
setResetBusy(true)
|
|
5074
5333
|
try {
|
|
5075
|
-
await fetch('/api/mail/request-reset', {
|
|
5334
|
+
const response = await fetch('/api/mail/request-reset', {
|
|
5076
5335
|
method: 'POST',
|
|
5077
5336
|
headers: { 'Content-Type': 'application/json' },
|
|
5078
5337
|
body: JSON.stringify({ email }),
|
|
5079
5338
|
})
|
|
5339
|
+
const data = await response.json().catch(() => ({}))
|
|
5340
|
+
setResetMessage(typeof data.message === 'string' ? data.message : '')
|
|
5080
5341
|
setResetSent(true)
|
|
5081
5342
|
} catch {
|
|
5082
5343
|
setLoginError('Network error — try again')
|
|
@@ -5098,6 +5359,7 @@ export default function DevMailPage() {
|
|
|
5098
5359
|
scheduled: 'Scheduled',
|
|
5099
5360
|
drafts: 'Drafts',
|
|
5100
5361
|
archived: 'Archive',
|
|
5362
|
+
spam: 'Spam',
|
|
5101
5363
|
trash: 'Trash',
|
|
5102
5364
|
}
|
|
5103
5365
|
|
|
@@ -5115,9 +5377,11 @@ export default function DevMailPage() {
|
|
|
5115
5377
|
{resetSent ? (
|
|
5116
5378
|
<>
|
|
5117
5379
|
<p className={styles.loginSub}>
|
|
5118
|
-
|
|
5380
|
+
{resetMessage
|
|
5381
|
+
? resetMessage
|
|
5382
|
+
: `If ${email} is an account, a reset link is on its way.`}
|
|
5119
5383
|
</p>
|
|
5120
|
-
<button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false) }}>
|
|
5384
|
+
<button className={styles.loginBtn} onClick={() => { setResetMode(false); setResetSent(false); setResetMessage('') }}>
|
|
5121
5385
|
Back to sign in
|
|
5122
5386
|
</button>
|
|
5123
5387
|
</>
|
|
@@ -5167,7 +5431,11 @@ export default function DevMailPage() {
|
|
|
5167
5431
|
onChange={event => {
|
|
5168
5432
|
const typed = event.target.value
|
|
5169
5433
|
setLoginRaw(typed)
|
|
5170
|
-
|
|
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}`)
|
|
5171
5439
|
}}
|
|
5172
5440
|
autoComplete="username"
|
|
5173
5441
|
placeholder="you"
|
|
@@ -5530,6 +5798,42 @@ export default function DevMailPage() {
|
|
|
5530
5798
|
)
|
|
5531
5799
|
}
|
|
5532
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
|
+
|
|
5533
5837
|
const renderInboundAttachments = (message: InboundEmail) => {
|
|
5534
5838
|
if (!visibleAttachments(message).length) return null
|
|
5535
5839
|
// The stored-blob lookup answers with an empty array for anything it does not
|
|
@@ -5582,7 +5886,9 @@ export default function DevMailPage() {
|
|
|
5582
5886
|
const textShown = quoteOpen.has(message.id)
|
|
5583
5887
|
return (
|
|
5584
5888
|
<>
|
|
5585
|
-
|
|
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>}
|
|
5586
5892
|
{plain.tail && renderQuoteLine(message.id, textShown)}
|
|
5587
5893
|
</>
|
|
5588
5894
|
)
|
|
@@ -5600,7 +5906,7 @@ export default function DevMailPage() {
|
|
|
5600
5906
|
<>
|
|
5601
5907
|
<iframe
|
|
5602
5908
|
className={styles.threadFrame}
|
|
5603
|
-
sandbox="allow-same-origin"
|
|
5909
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
5604
5910
|
srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')}
|
|
5605
5911
|
title="Email content"
|
|
5606
5912
|
onLoad={event => {
|
|
@@ -5616,7 +5922,7 @@ export default function DevMailPage() {
|
|
|
5616
5922
|
}
|
|
5617
5923
|
return (
|
|
5618
5924
|
<>
|
|
5619
|
-
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5925
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(bodyHtml, showRemote, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
5620
5926
|
{split.tail && renderQuoteLine(message.id, quoteShown)}
|
|
5621
5927
|
</>
|
|
5622
5928
|
)
|
|
@@ -5634,7 +5940,7 @@ export default function DevMailPage() {
|
|
|
5634
5940
|
return (
|
|
5635
5941
|
<iframe
|
|
5636
5942
|
className={styles.threadFrame}
|
|
5637
|
-
sandbox="allow-same-origin"
|
|
5943
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
5638
5944
|
srcDoc={frameHtml(healGooglePrivateImages(detail.html, CLIENT_BRAND.markUrl), true, readerSpacing, resolvedTheme === 'dark')}
|
|
5639
5945
|
title="Sent email"
|
|
5640
5946
|
onLoad={event => {
|
|
@@ -5646,7 +5952,9 @@ export default function DevMailPage() {
|
|
|
5646
5952
|
/>
|
|
5647
5953
|
)
|
|
5648
5954
|
}
|
|
5649
|
-
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>
|
|
5650
5958
|
}
|
|
5651
5959
|
|
|
5652
5960
|
/** What we attached to a message we sent. The inbound side has always shown these. */
|
|
@@ -5906,13 +6214,16 @@ export default function DevMailPage() {
|
|
|
5906
6214
|
)
|
|
5907
6215
|
) : mine ? (
|
|
5908
6216
|
<>
|
|
5909
|
-
{
|
|
6217
|
+
{/* Files first. The document is what a broker opened the message for; the
|
|
6218
|
+
covering note is read second, and burying the attachment under a long
|
|
6219
|
+
quoted thread meant scrolling past the reply to reach the thing itself. */}
|
|
5910
6220
|
{renderSentAttachments(item.sent)}
|
|
6221
|
+
{renderSentBody(item.sent)}
|
|
5911
6222
|
</>
|
|
5912
6223
|
) : (
|
|
5913
6224
|
<>
|
|
5914
|
-
{renderInboundBody(item.inbound, true)}
|
|
5915
6225
|
{renderInboundAttachments(item.inbound)}
|
|
6226
|
+
{renderInboundBody(item.inbound, true)}
|
|
5916
6227
|
</>
|
|
5917
6228
|
)}
|
|
5918
6229
|
</div>
|
|
@@ -5989,6 +6300,34 @@ export default function DevMailPage() {
|
|
|
5989
6300
|
</div>
|
|
5990
6301
|
<div className={styles.readerDate}>{new Date(inbound.receivedAt).toLocaleString()}</div>
|
|
5991
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
|
+
<span className={styles.riskBannerWhy}>{(inbound.riskReasons ?? []).join(' \u00b7 ')}</span>
|
|
6316
|
+
)}
|
|
6317
|
+
<span className={styles.riskBannerWhy}>
|
|
6318
|
+
Treat links and attachments here with care, and do not enter passwords or payment details.
|
|
6319
|
+
</span>
|
|
6320
|
+
</span>
|
|
6321
|
+
</div>
|
|
6322
|
+
)}
|
|
6323
|
+
{inbound.addressed && inbound.addressed !== 'direct' && (
|
|
6324
|
+
<p className={styles.copiedBanner} role="note">
|
|
6325
|
+
<span className={styles.copiedBannerIcon}>{ICONS.info}</span>
|
|
6326
|
+
{inbound.addressed === 'copied'
|
|
6327
|
+
? 'You were copied in this mail — it was addressed to someone else.'
|
|
6328
|
+
: 'You were not addressed directly — this copy reached you another way.'}
|
|
6329
|
+
</p>
|
|
6330
|
+
)}
|
|
5992
6331
|
{showFullHeaders && (
|
|
5993
6332
|
<dl className={styles.fullHeaders}>
|
|
5994
6333
|
<div><dt>From</dt><dd>{inbound.from}</dd></div>
|
|
@@ -6486,7 +6825,7 @@ export default function DevMailPage() {
|
|
|
6486
6825
|
<div className={styles.replyPreviewWrap}>
|
|
6487
6826
|
<iframe
|
|
6488
6827
|
className={styles.replyPreview}
|
|
6489
|
-
sandbox="allow-same-origin"
|
|
6828
|
+
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
|
6490
6829
|
srcDoc={frameHtml(replyEffHtml, true, readerSpacing, resolvedTheme === 'dark')}
|
|
6491
6830
|
title="Reply preview"
|
|
6492
6831
|
onLoad={event => {
|
|
@@ -6685,16 +7024,28 @@ export default function DevMailPage() {
|
|
|
6685
7024
|
const ordered = [...(eventsByEmail[selectedSent.id] ?? []), ...opened].sort(
|
|
6686
7025
|
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
|
|
6687
7026
|
)
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
7027
|
+
// One event arrives per recipient, so a message to nine people that reached
|
|
7028
|
+
// eight of them carries eight "delivered" and one "bounced". Collapsed to a
|
|
7029
|
+
// bare type each, that read as though the whole message had bounced. Group
|
|
7030
|
+
// them instead: keep the count, and name the addresses that actually failed.
|
|
7031
|
+
const groups = new Map<string, { type: string; at: string; count: number; who: string[]; meta?: Record<string, string> }>()
|
|
7032
|
+
for (const event of ordered) {
|
|
7033
|
+
const recipient = event.meta?.to
|
|
7034
|
+
const group = groups.get(event.type)
|
|
7035
|
+
if (!group) {
|
|
7036
|
+
groups.set(event.type, { type: event.type, at: event.at, count: 1, who: recipient ? [recipient] : [], meta: event.meta })
|
|
7037
|
+
continue
|
|
7038
|
+
}
|
|
7039
|
+
group.count += 1
|
|
7040
|
+
if (recipient && !group.who.includes(recipient)) group.who.push(recipient)
|
|
7041
|
+
// Keep the first reason given; they are the same message per bounce type.
|
|
7042
|
+
if (!group.meta?.bounceMessage && event.meta?.bounceMessage) group.meta = event.meta
|
|
7043
|
+
}
|
|
7044
|
+
const timeline = [...groups.values()]
|
|
6695
7045
|
// sent/delivered often share a timestamp, so order by the email
|
|
6696
7046
|
// lifecycle rather than time, which can flip them.
|
|
6697
7047
|
.sort((a, b) => statusRank(a.type) - statusRank(b.type))
|
|
7048
|
+
const audience = groups.get('email.sent')?.who.length ?? 0
|
|
6698
7049
|
if (!timeline.length) return null
|
|
6699
7050
|
return (
|
|
6700
7051
|
<div className={styles.activity}>
|
|
@@ -6719,9 +7070,16 @@ export default function DevMailPage() {
|
|
|
6719
7070
|
<span className={styles.eventType}>
|
|
6720
7071
|
{event.type.replace('email.', '')}
|
|
6721
7072
|
{event.meta?.count ? ` ×${event.meta.count}` : ''}
|
|
7073
|
+
{audience > 1 && event.count < audience && !/bounce|fail|complain/.test(event.type)
|
|
7074
|
+
? ` — ${event.count} of ${audience}`
|
|
7075
|
+
: ''}
|
|
6722
7076
|
</span>
|
|
6723
7077
|
<span className={styles.eventTime}>{new Date(event.at).toLocaleString()}</span>
|
|
6724
7078
|
</div>
|
|
7079
|
+
{/* Which addresses failed, not just that something did. */}
|
|
7080
|
+
{/bounce|fail|complain/.test(event.type) && event.who.length > 0 && (
|
|
7081
|
+
<span className={styles.eventMeta}>{event.who.join(', ')}</span>
|
|
7082
|
+
)}
|
|
6725
7083
|
{event.meta?.link && (
|
|
6726
7084
|
<span className={styles.eventMeta} title={event.meta.link}>
|
|
6727
7085
|
{event.meta.link}
|
|
@@ -6738,9 +7096,11 @@ export default function DevMailPage() {
|
|
|
6738
7096
|
{!selectedDetail ? (
|
|
6739
7097
|
<BodySkeleton />
|
|
6740
7098
|
) : selectedDetail.html ? (
|
|
6741
|
-
<iframe className={styles.readerFrame} sandbox="" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
7099
|
+
<iframe className={styles.readerFrame} sandbox="allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(selectedDetail.html, true, readerSpacing, resolvedTheme === 'dark')} title="Email content" />
|
|
7100
|
+
) : selectedDetail.text ? (
|
|
7101
|
+
<pre className={styles.readerText}>{selectedDetail.text}</pre>
|
|
6742
7102
|
) : (
|
|
6743
|
-
<pre className={styles.readerText}>
|
|
7103
|
+
renderBodylessAttachment(selectedDetail.attachments ?? []) ?? <pre className={styles.readerText}>(no content)</pre>
|
|
6744
7104
|
)}
|
|
6745
7105
|
</div>
|
|
6746
7106
|
</>
|
|
@@ -6865,10 +7225,10 @@ export default function DevMailPage() {
|
|
|
6865
7225
|
{ICONS.refresh}
|
|
6866
7226
|
</button>
|
|
6867
7227
|
)}
|
|
6868
|
-
{
|
|
7228
|
+
{!installed && (canInstall || ios) && (
|
|
6869
7229
|
<button
|
|
6870
7230
|
className={styles.themeToggle}
|
|
6871
|
-
onClick={install}
|
|
7231
|
+
onClick={canInstall ? install : () => { setSettingsTab('app'); setSettingsOpen(true) }}
|
|
6872
7232
|
aria-label={`Install ${CLIENT_BRAND.name} Mail as an app`}
|
|
6873
7233
|
>
|
|
6874
7234
|
{ICONS.install}
|
|
@@ -7002,7 +7362,7 @@ export default function DevMailPage() {
|
|
|
7002
7362
|
<span className={styles.composePen}>{ICONS.pencil}</span>
|
|
7003
7363
|
<span className={styles.railLabel}>Compose</span>
|
|
7004
7364
|
</button>
|
|
7005
|
-
{(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'trash'] as Folder[]).map(key => {
|
|
7365
|
+
{(['inbox', 'starred', 'snoozed', 'sent', 'scheduled', 'drafts', 'archived', 'spam', 'trash'] as Folder[]).map(key => {
|
|
7006
7366
|
const count =
|
|
7007
7367
|
key === 'inbox'
|
|
7008
7368
|
? folderCounts.inbox || ''
|
|
@@ -7018,7 +7378,9 @@ export default function DevMailPage() {
|
|
|
7018
7378
|
? drafts.length || ''
|
|
7019
7379
|
: key === 'archived'
|
|
7020
7380
|
? folderCounts.archived || ''
|
|
7021
|
-
:
|
|
7381
|
+
: key === 'spam'
|
|
7382
|
+
? folderCounts.spam || ''
|
|
7383
|
+
: folderCounts.trashed || ''
|
|
7022
7384
|
return (
|
|
7023
7385
|
<button
|
|
7024
7386
|
key={key}
|
|
@@ -7192,6 +7554,11 @@ export default function DevMailPage() {
|
|
|
7192
7554
|
{ICONS.trash}
|
|
7193
7555
|
<span className={styles.bulkBtnLabel}>Delete</span>
|
|
7194
7556
|
</button>
|
|
7557
|
+
) : folder === 'spam' ? (
|
|
7558
|
+
<button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), false)} title="Not spam">
|
|
7559
|
+
{ICONS.inbox}
|
|
7560
|
+
<span className={styles.bulkBtnLabel}>Not spam</span>
|
|
7561
|
+
</button>
|
|
7195
7562
|
) : folder === 'scheduled' ? (
|
|
7196
7563
|
<button className={styles.bulkBtn} onClick={bulkCancelScheduled} title="Cancel sends">
|
|
7197
7564
|
{ICONS.trash}
|
|
@@ -7201,6 +7568,10 @@ export default function DevMailPage() {
|
|
|
7201
7568
|
<>
|
|
7202
7569
|
{folder === 'inbox' && (
|
|
7203
7570
|
<>
|
|
7571
|
+
<button className={styles.bulkBtn} onClick={() => markSpam(Array.from(selectedBulk), true)} title="Report spam">
|
|
7572
|
+
{ICONS.warn}
|
|
7573
|
+
<span className={styles.bulkBtnLabel}>Spam</span>
|
|
7574
|
+
</button>
|
|
7204
7575
|
<button className={styles.bulkBtn} onClick={() => bulkFlag({ read: true })} title="Mark read">
|
|
7205
7576
|
{ICONS.unread}
|
|
7206
7577
|
<span className={styles.bulkBtnLabel}>Read</span>
|
|
@@ -7443,6 +7814,20 @@ export default function DevMailPage() {
|
|
|
7443
7814
|
<Ticker className={styles.itemSubjectText} enabled={tickerOn('listSubject')}>{item.subject}</Ticker>
|
|
7444
7815
|
</p>
|
|
7445
7816
|
{item.snippet && <Ticker className={`${styles.itemSnippet} ${styles.tickerBlock}`} enabled={tickerOn('listSnippet')}>{item.snippet}</Ticker>}
|
|
7817
|
+
{item.kind === 'inbound' && item.risk && item.risk !== 'clean' && (
|
|
7818
|
+
<span className={styles.riskNote}>
|
|
7819
|
+
<span className={styles.riskNoteIcon}>{ICONS.warn}</span>
|
|
7820
|
+
{item.risk === 'virus' ? 'Virus scan failed' : item.risk === 'spam' ? 'Looks like spam' : 'Sender unverified'}
|
|
7821
|
+
</span>
|
|
7822
|
+
)}
|
|
7823
|
+
{item.kind === 'inbound' && item.addressed !== 'direct' && (
|
|
7824
|
+
<span className={styles.copiedNote}>
|
|
7825
|
+
<span className={styles.copiedNoteIcon}>{ICONS.info}</span>
|
|
7826
|
+
{item.addressed === 'copied'
|
|
7827
|
+
? 'You were copied in this mail'
|
|
7828
|
+
: 'You were not addressed directly'}
|
|
7829
|
+
</span>
|
|
7830
|
+
)}
|
|
7446
7831
|
{item.labels.length > 0 && (
|
|
7447
7832
|
<span className={styles.itemLabels}>
|
|
7448
7833
|
{item.labels.map(labelId => {
|
|
@@ -7484,7 +7869,9 @@ export default function DevMailPage() {
|
|
|
7484
7869
|
? styles.chipBounced
|
|
7485
7870
|
: item.chip === 'draft'
|
|
7486
7871
|
? styles.chipDraft
|
|
7487
|
-
: ''
|
|
7872
|
+
: item.chip === 'automated'
|
|
7873
|
+
? styles.chipAuto
|
|
7874
|
+
: ''
|
|
7488
7875
|
}`}
|
|
7489
7876
|
>
|
|
7490
7877
|
{item.chip}
|
|
@@ -7841,7 +8228,7 @@ export default function DevMailPage() {
|
|
|
7841
8228
|
{compose.quoteHtml && !compose.campaign.kind && (
|
|
7842
8229
|
<details className={styles.composeQuote}>
|
|
7843
8230
|
<summary className={styles.composeQuoteLabel}>Quoted message</summary>
|
|
7844
|
-
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
|
|
8231
|
+
<iframe className={styles.composeQuoteFrame} sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" srcDoc={frameHtml(compose.quoteHtml, true, readerSpacing, resolvedTheme === 'dark')} title="Quoted message" />
|
|
7845
8232
|
</details>
|
|
7846
8233
|
)}
|
|
7847
8234
|
{/* The signature is appended on send, so show it here rather than leaving the
|
|
@@ -8256,6 +8643,40 @@ export default function DevMailPage() {
|
|
|
8256
8643
|
<p className={styles.settingsNote}>{account?.address || email || 'Not signed in'}</p>
|
|
8257
8644
|
</div>
|
|
8258
8645
|
|
|
8646
|
+
<div className={styles.settingsField}>
|
|
8647
|
+
<span>Recovery email</span>
|
|
8648
|
+
<p className={styles.settingsProse}>
|
|
8649
|
+
Where a password reset is sent. It has to be an address outside this mailbox, and it only
|
|
8650
|
+
counts once you confirm it — otherwise a locked account has to be reset by an administrator.
|
|
8651
|
+
</p>
|
|
8652
|
+
<div className={styles.pwWrap}>
|
|
8653
|
+
<input
|
|
8654
|
+
type="email"
|
|
8655
|
+
autoComplete="email"
|
|
8656
|
+
className={recoveryBad ? styles.fieldBad : ''}
|
|
8657
|
+
aria-invalid={recoveryBad}
|
|
8658
|
+
placeholder="you@somewhere-else.com"
|
|
8659
|
+
value={recoveryEmail}
|
|
8660
|
+
onChange={event => { setRecoveryEmail(event.target.value); setRecoveryBad(false) }}
|
|
8661
|
+
/>
|
|
8662
|
+
<button type="button" className={styles.pwToggle} disabled={recoveryBusy} onClick={saveRecovery}>
|
|
8663
|
+
{recoveryBusy ? 'Sending…' : recoverySaved.address === recoveryEmail.trim().toLowerCase() && recoverySaved.address ? 'Resend' : 'Save'}
|
|
8664
|
+
</button>
|
|
8665
|
+
</div>
|
|
8666
|
+
{recoverySaved.address && (
|
|
8667
|
+
<p className={styles.settingsNote}>
|
|
8668
|
+
{recoverySaved.verified
|
|
8669
|
+
? `Confirmed — resets go to ${recoverySaved.address}.`
|
|
8670
|
+
: `${recoverySaved.address} is not confirmed yet, so resets cannot be sent there.`}
|
|
8671
|
+
</p>
|
|
8672
|
+
)}
|
|
8673
|
+
{recoveryMsg && (
|
|
8674
|
+
<p className={`${styles.settingsNote} ${recoveryBad ? styles.msgBad : ''}`} role={recoveryBad ? 'alert' : undefined}>
|
|
8675
|
+
{recoveryMsg}
|
|
8676
|
+
</p>
|
|
8677
|
+
)}
|
|
8678
|
+
</div>
|
|
8679
|
+
|
|
8259
8680
|
<div
|
|
8260
8681
|
ref={pwSectionRef}
|
|
8261
8682
|
className={`${styles.pwSection} ${pwHighlight ? styles.pwSectionFocus : ''}`}
|
|
@@ -8268,18 +8689,33 @@ export default function DevMailPage() {
|
|
|
8268
8689
|
</p>
|
|
8269
8690
|
<label className={styles.settingsField}>
|
|
8270
8691
|
<span>Current password</span>
|
|
8271
|
-
<
|
|
8272
|
-
|
|
8692
|
+
<div className={styles.pwWrap}>
|
|
8693
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="current-password" value={pwCurrent}
|
|
8694
|
+
onChange={event => setPwCurrent(event.target.value)} />
|
|
8695
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8696
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8697
|
+
</button>
|
|
8698
|
+
</div>
|
|
8273
8699
|
</label>
|
|
8274
8700
|
<label className={styles.settingsField}>
|
|
8275
8701
|
<span>New password</span>
|
|
8276
|
-
<
|
|
8277
|
-
|
|
8702
|
+
<div className={styles.pwWrap}>
|
|
8703
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwNext}
|
|
8704
|
+
onChange={event => setPwNext(event.target.value)} />
|
|
8705
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8706
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8707
|
+
</button>
|
|
8708
|
+
</div>
|
|
8278
8709
|
</label>
|
|
8279
8710
|
<label className={styles.settingsField}>
|
|
8280
8711
|
<span>Repeat new password</span>
|
|
8281
|
-
<
|
|
8282
|
-
|
|
8712
|
+
<div className={styles.pwWrap}>
|
|
8713
|
+
<input type={pwReveal ? 'text' : 'password'} autoComplete="new-password" value={pwRepeat}
|
|
8714
|
+
onChange={event => setPwRepeat(event.target.value)} />
|
|
8715
|
+
<button type="button" className={styles.pwToggle} onClick={() => setPwReveal(show => !show)}>
|
|
8716
|
+
{pwReveal ? 'Hide' : 'Show'}
|
|
8717
|
+
</button>
|
|
8718
|
+
</div>
|
|
8283
8719
|
</label>
|
|
8284
8720
|
{pwMsg && (
|
|
8285
8721
|
<p className={pwMsg.tone === 'ok' ? styles.pwOk : styles.pwBad} role="status">{pwMsg.text}</p>
|
|
@@ -8716,11 +9152,12 @@ export default function DevMailPage() {
|
|
|
8716
9152
|
<div className={styles.accessorInvite}>
|
|
8717
9153
|
<div className={styles.accessorInviteRow}>
|
|
8718
9154
|
<input
|
|
8719
|
-
className={styles.accessorInput}
|
|
9155
|
+
className={`${styles.accessorInput} ${accessorsBad ? styles.fieldBad : ''}`}
|
|
8720
9156
|
type="email"
|
|
9157
|
+
aria-invalid={accessorsBad}
|
|
8721
9158
|
placeholder="name@email.com"
|
|
8722
9159
|
value={inviteEmail}
|
|
8723
|
-
onChange={event => setInviteEmail(event.target.value)}
|
|
9160
|
+
onChange={event => { setInviteEmail(event.target.value); setAccessorsBad(false) }}
|
|
8724
9161
|
/>
|
|
8725
9162
|
<input
|
|
8726
9163
|
className={styles.accessorInput}
|
|
@@ -8765,7 +9202,11 @@ export default function DevMailPage() {
|
|
|
8765
9202
|
They'll get an email to set their password. Members see only their own mail; admins see every mailbox.
|
|
8766
9203
|
Leave the mailbox blank to derive one from their name.
|
|
8767
9204
|
</p>
|
|
8768
|
-
{accessorsMsg &&
|
|
9205
|
+
{accessorsMsg && (
|
|
9206
|
+
<p className={`${styles.accessorMsg} ${accessorsBad ? styles.msgBad : ''}`} role={accessorsBad ? 'alert' : undefined}>
|
|
9207
|
+
{accessorsMsg}
|
|
9208
|
+
</p>
|
|
9209
|
+
)}
|
|
8769
9210
|
</div>
|
|
8770
9211
|
|
|
8771
9212
|
<ul className={styles.accessorList}>
|
|
@@ -8830,6 +9271,14 @@ export default function DevMailPage() {
|
|
|
8830
9271
|
buttonClassName={styles.accessorRoleBtn}
|
|
8831
9272
|
onChange={value => changeAccessorRole(entry.email, value as 'admin' | 'member')}
|
|
8832
9273
|
/>
|
|
9274
|
+
<button
|
|
9275
|
+
className={styles.mailboxTag}
|
|
9276
|
+
onClick={() => mintResetLink(entry.email)}
|
|
9277
|
+
title={`Copy a one-time password reset link for ${entry.email}`}
|
|
9278
|
+
type="button"
|
|
9279
|
+
>
|
|
9280
|
+
Reset link
|
|
9281
|
+
</button>
|
|
8833
9282
|
<button
|
|
8834
9283
|
className={styles.accessorRemove}
|
|
8835
9284
|
onClick={() => removeAccessor(entry.email)}
|
|
@@ -8853,13 +9302,16 @@ export default function DevMailPage() {
|
|
|
8853
9302
|
? 'Installed. Launch it from your dock, home screen or app list.'
|
|
8854
9303
|
: canInstall
|
|
8855
9304
|
? 'Install it as an app so it opens in its own window and can show notifications.'
|
|
8856
|
-
:
|
|
9305
|
+
: ios
|
|
9306
|
+
? 'Three taps, and it lives on your Home Screen like any other app.'
|
|
9307
|
+
: 'Your browser handles this from its own menu — look for Install or Add to Home Screen.'}
|
|
8857
9308
|
</p>
|
|
8858
9309
|
{!installed && canInstall && (
|
|
8859
9310
|
<button type="button" className={styles.sendBtn} onClick={install}>
|
|
8860
9311
|
{ICONS.install} Install app
|
|
8861
9312
|
</button>
|
|
8862
9313
|
)}
|
|
9314
|
+
{!installed && !canInstall && ios && <InstallGuide browser={ios} appName={CLIENT_BRAND.name} />}
|
|
8863
9315
|
</div>
|
|
8864
9316
|
<div className={styles.settingsField}>
|
|
8865
9317
|
<span>Signed in as</span>
|