@novacraft-engineering/mailbox 0.4.24 → 0.4.26
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/.env.example +8 -2
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/mail/RichEditor.tsx +450 -5
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +115 -12
- package/app/mail/tabKey.ts +52 -0
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +7 -2
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +67 -33
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Extension, type Editor } from '@tiptap/react'
|
|
2
|
+
import { WRITING_DEFAULTS, type WritingSettings } from './settings'
|
|
3
|
+
import type { MailTemplate, TemplateContext } from './templates'
|
|
4
|
+
|
|
5
|
+
/** What the writing tools need from the page, written after each render and read on keypress. */
|
|
6
|
+
export type Live = {
|
|
7
|
+
tools: boolean
|
|
8
|
+
settings: WritingSettings
|
|
9
|
+
templates: MailTemplate[]
|
|
10
|
+
context: TemplateContext
|
|
11
|
+
words: string[]
|
|
12
|
+
spellOn: boolean
|
|
13
|
+
grammarOn: boolean
|
|
14
|
+
headers: () => HeadersInit
|
|
15
|
+
submit?: () => void
|
|
16
|
+
openLink?: () => void
|
|
17
|
+
openFind?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const LiveStore = Extension.create<Record<string, never>, Live>({
|
|
21
|
+
name: 'writingLive',
|
|
22
|
+
addStorage: () => ({
|
|
23
|
+
tools: false,
|
|
24
|
+
settings: WRITING_DEFAULTS,
|
|
25
|
+
templates: [],
|
|
26
|
+
context: {},
|
|
27
|
+
words: [],
|
|
28
|
+
spellOn: false,
|
|
29
|
+
grammarOn: false,
|
|
30
|
+
headers: () => ({}),
|
|
31
|
+
}),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const live = (editor: Editor): Live => (editor.storage as unknown as { writingLive: Live }).writingLive
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { cleanPastedHtml } from './paste.ts'
|
|
4
|
+
|
|
5
|
+
test('Word styling is stripped and meaning is kept', () => {
|
|
6
|
+
const word = '<!--[if gte mso 9]><xml><o:x/></xml><![endif]--><p class="MsoNormal" style="margin:0;font-family:Calibri;mso-bidi-font-weight:bold"><b>Premium</b> due<o:p></o:p></p>'
|
|
7
|
+
assert.equal(cleanPastedHtml(word), '<p><b>Premium</b> due</p>')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('Excel tables survive with their structure', () => {
|
|
11
|
+
const excel = '<style>td{mso-number-format:General}</style><table class="x"><tr><td class="xl65" style="font-family:Calibri">Vehicle</td><td style="text-align:right;font-size:11pt">N150,000</td></tr></table>'
|
|
12
|
+
assert.equal(cleanPastedHtml(excel), '<table><tr><td>Vehicle</td><td style="text-align:right">N150,000</td></tr></table>')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('meaningful inline colour survives, bare font wrappers do not', () => {
|
|
16
|
+
assert.equal(cleanPastedHtml('<font face="Arial">plain</font> <span style="color:red;font-family:Arial">red</span>'), 'plain <span style="color:red">red</span>')
|
|
17
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML pasted from Word, Outlook or Excel, without the styling that came with it: Office
|
|
3
|
+
* namespaces, conditional comments, class names and fonts go; bold, lists, links and tables
|
|
4
|
+
* stay.
|
|
5
|
+
*/
|
|
6
|
+
export function cleanPastedHtml(html: string): string {
|
|
7
|
+
let cleaned = html
|
|
8
|
+
.replace(/<!--\[if[\s\S]*?<!\[endif\]-->/gi, '')
|
|
9
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
10
|
+
.replace(/<(style|script|xml|title)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
|
11
|
+
.replace(/<(meta|link)\b[^>]*>/gi, '')
|
|
12
|
+
.replace(/<\/?o:p[^>]*>/gi, '')
|
|
13
|
+
.replace(/\s(class|lang|face|size)="[^"]*"/gi, '')
|
|
14
|
+
.replace(/\sstyle="([^"]*)"/gi, (_whole, css: string) => {
|
|
15
|
+
const kept = css
|
|
16
|
+
.split(';')
|
|
17
|
+
.map(rule => rule.trim())
|
|
18
|
+
.filter(rule => /^(font-weight|font-style|text-decoration|color|background(-color)?|text-align)\s*:/i.test(rule))
|
|
19
|
+
.filter(rule => !/mso-|windowtext/i.test(rule))
|
|
20
|
+
.join(';')
|
|
21
|
+
return kept ? ` style="${kept}"` : ''
|
|
22
|
+
})
|
|
23
|
+
for (let previous = ''; previous !== cleaned; ) {
|
|
24
|
+
previous = cleaned
|
|
25
|
+
cleaned = cleaned
|
|
26
|
+
.replace(/<span>((?:(?!<\/?span\b)[\s\S])*)<\/span>/gi, '$1')
|
|
27
|
+
.replace(/<font\b[^>]*>((?:(?!<\/?font\b)[\s\S])*)<\/font>/gi, '$1')
|
|
28
|
+
}
|
|
29
|
+
return cleaned
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type SpellLanguage = 'en-GB' | 'en-US'
|
|
2
|
+
|
|
3
|
+
export type WritingSettings = {
|
|
4
|
+
autoCapitalize: boolean
|
|
5
|
+
spaceAfterStop: boolean
|
|
6
|
+
capitalizeI: boolean
|
|
7
|
+
spellcheck: boolean
|
|
8
|
+
spellLanguage: SpellLanguage
|
|
9
|
+
ignoreCapitals: boolean
|
|
10
|
+
ignoreWithNumbers: boolean
|
|
11
|
+
repeatedWords: boolean
|
|
12
|
+
autocorrect: boolean
|
|
13
|
+
personalWords: string[]
|
|
14
|
+
grammar: boolean
|
|
15
|
+
tabIndent: boolean
|
|
16
|
+
doubleTabMs: number
|
|
17
|
+
templateShortcuts: boolean
|
|
18
|
+
cleanPaste: boolean
|
|
19
|
+
amountFormat: boolean
|
|
20
|
+
nairaLetter: boolean
|
|
21
|
+
numberFormat: boolean
|
|
22
|
+
wordCount: boolean
|
|
23
|
+
checkAttachment: boolean
|
|
24
|
+
checkEmptyBody: boolean
|
|
25
|
+
checkPlaceholders: boolean
|
|
26
|
+
checkGreeting: boolean
|
|
27
|
+
checkExternal: boolean
|
|
28
|
+
externalThreshold: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const WRITING_DEFAULTS: WritingSettings = {
|
|
32
|
+
autoCapitalize: true,
|
|
33
|
+
spaceAfterStop: true,
|
|
34
|
+
capitalizeI: true,
|
|
35
|
+
spellcheck: true,
|
|
36
|
+
spellLanguage: 'en-GB',
|
|
37
|
+
ignoreCapitals: true,
|
|
38
|
+
ignoreWithNumbers: true,
|
|
39
|
+
repeatedWords: true,
|
|
40
|
+
autocorrect: true,
|
|
41
|
+
personalWords: [],
|
|
42
|
+
grammar: false,
|
|
43
|
+
tabIndent: true,
|
|
44
|
+
doubleTabMs: 400,
|
|
45
|
+
templateShortcuts: true,
|
|
46
|
+
cleanPaste: true,
|
|
47
|
+
amountFormat: false,
|
|
48
|
+
nairaLetter: true,
|
|
49
|
+
numberFormat: false,
|
|
50
|
+
wordCount: true,
|
|
51
|
+
checkAttachment: true,
|
|
52
|
+
checkEmptyBody: true,
|
|
53
|
+
checkPlaceholders: true,
|
|
54
|
+
checkGreeting: true,
|
|
55
|
+
checkExternal: true,
|
|
56
|
+
externalThreshold: 5,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const clamp = (value: unknown, low: number, high: number, fallback: number) => {
|
|
60
|
+
const number = Number(value)
|
|
61
|
+
return Number.isFinite(number) ? Math.min(high, Math.max(low, Math.round(number))) : fallback
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whatever was stored, read back as a complete set: a missing or malformed key takes its default. */
|
|
65
|
+
export function writingSettingsFrom(raw: unknown): WritingSettings {
|
|
66
|
+
const stored = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
|
67
|
+
const flag = (key: keyof WritingSettings) =>
|
|
68
|
+
typeof stored[key] === 'boolean' ? (stored[key] as boolean) : (WRITING_DEFAULTS[key] as boolean)
|
|
69
|
+
return {
|
|
70
|
+
autoCapitalize: flag('autoCapitalize'),
|
|
71
|
+
spaceAfterStop: flag('spaceAfterStop'),
|
|
72
|
+
capitalizeI: flag('capitalizeI'),
|
|
73
|
+
spellcheck: flag('spellcheck'),
|
|
74
|
+
spellLanguage: stored.spellLanguage === 'en-US' ? 'en-US' : 'en-GB',
|
|
75
|
+
ignoreCapitals: flag('ignoreCapitals'),
|
|
76
|
+
ignoreWithNumbers: flag('ignoreWithNumbers'),
|
|
77
|
+
repeatedWords: flag('repeatedWords'),
|
|
78
|
+
autocorrect: flag('autocorrect'),
|
|
79
|
+
personalWords: Array.isArray(stored.personalWords)
|
|
80
|
+
? [...new Set(stored.personalWords.filter((word): word is string => typeof word === 'string' && word.trim().length > 0).map(word => word.trim()))].slice(0, 5000)
|
|
81
|
+
: [],
|
|
82
|
+
grammar: flag('grammar'),
|
|
83
|
+
tabIndent: flag('tabIndent'),
|
|
84
|
+
doubleTabMs: clamp(stored.doubleTabMs, 200, 1000, WRITING_DEFAULTS.doubleTabMs),
|
|
85
|
+
templateShortcuts: flag('templateShortcuts'),
|
|
86
|
+
cleanPaste: flag('cleanPaste'),
|
|
87
|
+
amountFormat: typeof stored.amountFormat === 'boolean' ? stored.amountFormat : stored.nairaFormat === true,
|
|
88
|
+
nairaLetter: flag('nairaLetter'),
|
|
89
|
+
numberFormat: flag('numberFormat'),
|
|
90
|
+
wordCount: flag('wordCount'),
|
|
91
|
+
checkAttachment: flag('checkAttachment'),
|
|
92
|
+
checkEmptyBody: flag('checkEmptyBody'),
|
|
93
|
+
checkPlaceholders: flag('checkPlaceholders'),
|
|
94
|
+
checkGreeting: flag('checkGreeting'),
|
|
95
|
+
checkExternal: flag('checkExternal'),
|
|
96
|
+
externalThreshold: clamp(stored.externalThreshold, 1, 100, WRITING_DEFAULTS.externalThreshold),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import nspell from 'nspell'
|
|
2
|
+
|
|
3
|
+
type Request =
|
|
4
|
+
| { id: number; type: 'check'; language: string; words: string[] }
|
|
5
|
+
| { id: number; type: 'suggest'; language: string; word: string }
|
|
6
|
+
|
|
7
|
+
let checker: ReturnType<typeof nspell> | null = null
|
|
8
|
+
let loaded = ''
|
|
9
|
+
let loading: Promise<void> | null = null
|
|
10
|
+
|
|
11
|
+
async function ready(language: string) {
|
|
12
|
+
if (checker && loaded === language) return
|
|
13
|
+
if (!loading || loaded !== language) {
|
|
14
|
+
loaded = language
|
|
15
|
+
loading = Promise.all([
|
|
16
|
+
fetch(`/dictionaries/${language}.aff`).then(response => response.text()),
|
|
17
|
+
fetch(`/dictionaries/${language}.dic`).then(response => response.text()),
|
|
18
|
+
]).then(([aff, dic]) => {
|
|
19
|
+
checker = nspell(aff, dic)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
await loading
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const plain = (word: string) => word.replace(/’/g, "'")
|
|
26
|
+
|
|
27
|
+
self.onmessage = async (event: MessageEvent<Request>) => {
|
|
28
|
+
const request = event.data
|
|
29
|
+
try {
|
|
30
|
+
await ready(request.language)
|
|
31
|
+
if (request.type === 'check') {
|
|
32
|
+
const wrong = request.words.filter(word => !checker!.correct(plain(word)))
|
|
33
|
+
postMessage({ id: request.id, wrong })
|
|
34
|
+
} else {
|
|
35
|
+
postMessage({ id: request.id, suggestions: checker!.suggest(plain(request.word)).slice(0, 6) })
|
|
36
|
+
}
|
|
37
|
+
} catch (err) {
|
|
38
|
+
postMessage({ id: request.id, error: String(err) })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Extension } from '@tiptap/react'
|
|
2
|
+
import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
|
|
3
|
+
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
|
|
4
|
+
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
5
|
+
import type { WritingSettings } from './settings'
|
|
6
|
+
import { live } from './live'
|
|
7
|
+
import { checkableWord, wordsOf } from './text'
|
|
8
|
+
|
|
9
|
+
let worker: Worker | null = null
|
|
10
|
+
let nextId = 1
|
|
11
|
+
const waiting = new Map<number, (reply: { wrong?: string[]; suggestions?: string[] }) => void>()
|
|
12
|
+
|
|
13
|
+
function ask<T>(message: Record<string, unknown>): Promise<T> {
|
|
14
|
+
if (!worker) {
|
|
15
|
+
worker = new Worker(new URL('./spell.worker.ts', import.meta.url), { type: 'module' })
|
|
16
|
+
worker.onmessage = event => {
|
|
17
|
+
const resolve = waiting.get(event.data.id)
|
|
18
|
+
waiting.delete(event.data.id)
|
|
19
|
+
resolve?.(event.data)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const id = nextId++
|
|
23
|
+
return new Promise(resolve => {
|
|
24
|
+
waiting.set(id, resolve as (reply: { wrong?: string[]; suggestions?: string[] }) => void)
|
|
25
|
+
worker!.postMessage({ id, ...message })
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const suggestSpelling = (word: string, language: string) =>
|
|
30
|
+
ask<{ suggestions?: string[] }>({ type: 'suggest', language, word }).then(reply => reply.suggestions ?? [])
|
|
31
|
+
|
|
32
|
+
type SpellState = { set: DecorationSet; version: number }
|
|
33
|
+
|
|
34
|
+
export const spellingKey = new PluginKey<SpellState>('spelling')
|
|
35
|
+
|
|
36
|
+
type Found = { from: number; to: number; word: string; kind: 'spelling' | 'repeat' }
|
|
37
|
+
|
|
38
|
+
function collect(doc: ProseMirrorNode, settings: WritingSettings): Found[] {
|
|
39
|
+
const found: Found[] = []
|
|
40
|
+
doc.descendants((node, pos) => {
|
|
41
|
+
if (node.type.name === 'codeBlock') return false
|
|
42
|
+
if (!node.isText || !node.text) return true
|
|
43
|
+
if (node.marks.some(mark => mark.type.name === 'code' || mark.type.name === 'link')) return false
|
|
44
|
+
const words = wordsOf(node.text)
|
|
45
|
+
words.forEach((entry, index) => {
|
|
46
|
+
const previous = words[index - 1]
|
|
47
|
+
if (
|
|
48
|
+
settings.repeatedWords &&
|
|
49
|
+
previous &&
|
|
50
|
+
previous.word.toLowerCase() === entry.word.toLowerCase() &&
|
|
51
|
+
/^\s+$/.test(node.text!.slice(previous.end, entry.start)) &&
|
|
52
|
+
!/^\d+$/.test(entry.word)
|
|
53
|
+
) {
|
|
54
|
+
found.push({ from: pos + entry.start, to: pos + entry.end, word: entry.word, kind: 'repeat' })
|
|
55
|
+
}
|
|
56
|
+
found.push({ from: pos + entry.start, to: pos + entry.end, word: entry.word, kind: 'spelling' })
|
|
57
|
+
})
|
|
58
|
+
return false
|
|
59
|
+
})
|
|
60
|
+
return found
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Red underlines under words the dictionary does not know, and a softer one under a word
|
|
65
|
+
* repeated by accident. Drawn as decorations, so nothing is added to the message itself.
|
|
66
|
+
*/
|
|
67
|
+
export const Spelling = Extension.create<Record<string, never>, { verdicts: Map<string, boolean>; ignored: Set<string> }>({
|
|
68
|
+
name: 'spelling',
|
|
69
|
+
addStorage() {
|
|
70
|
+
return { verdicts: new Map(), ignored: new Set() }
|
|
71
|
+
},
|
|
72
|
+
addProseMirrorPlugins() {
|
|
73
|
+
const storage = this.storage
|
|
74
|
+
const editor = this.editor
|
|
75
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
76
|
+
let language = ''
|
|
77
|
+
|
|
78
|
+
const build = (state: EditorState): DecorationSet => {
|
|
79
|
+
const current = live(editor)
|
|
80
|
+
if (!current.tools || !current.spellOn) return DecorationSet.empty
|
|
81
|
+
const settings = current.settings
|
|
82
|
+
const known = new Set(current.words.map(word => word.toLowerCase()))
|
|
83
|
+
const head = state.selection.empty ? state.selection.head : -1
|
|
84
|
+
const decorations: Decoration[] = []
|
|
85
|
+
for (const entry of collect(state.doc, settings)) {
|
|
86
|
+
if (entry.kind === 'repeat') {
|
|
87
|
+
decorations.push(Decoration.inline(entry.from, entry.to, { class: 'rteRepeat', 'data-word': entry.word, 'data-kind': 'repeat' }))
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (entry.to === head) continue
|
|
91
|
+
if (!checkableWord(entry.word, settings)) continue
|
|
92
|
+
const key = entry.word.toLowerCase()
|
|
93
|
+
if (known.has(key) || storage.ignored.has(key)) continue
|
|
94
|
+
if (storage.verdicts.get(entry.word) === false) {
|
|
95
|
+
decorations.push(Decoration.inline(entry.from, entry.to, { class: 'rteMisspelt', 'data-word': entry.word, 'data-kind': 'spelling' }))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return DecorationSet.create(state.doc, decorations)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const refresh = (view: EditorView) => {
|
|
102
|
+
const { settings, tools, spellOn } = live(editor)
|
|
103
|
+
if (!tools || !spellOn) {
|
|
104
|
+
view.dispatch(view.state.tr.setMeta(spellingKey, DecorationSet.empty))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (language !== settings.spellLanguage) {
|
|
108
|
+
storage.verdicts.clear()
|
|
109
|
+
language = settings.spellLanguage
|
|
110
|
+
}
|
|
111
|
+
const unknown = [
|
|
112
|
+
...new Set(
|
|
113
|
+
collect(view.state.doc, settings)
|
|
114
|
+
.filter(entry => entry.kind === 'spelling' && checkableWord(entry.word, settings) && !storage.verdicts.has(entry.word))
|
|
115
|
+
.map(entry => entry.word),
|
|
116
|
+
),
|
|
117
|
+
]
|
|
118
|
+
const draw = () => {
|
|
119
|
+
if (!view.isDestroyed) view.dispatch(view.state.tr.setMeta(spellingKey, build(view.state)))
|
|
120
|
+
}
|
|
121
|
+
if (!unknown.length) return draw()
|
|
122
|
+
void ask<{ wrong?: string[] }>({ type: 'check', language, words: unknown }).then(reply => {
|
|
123
|
+
const wrong = new Set(reply.wrong ?? [])
|
|
124
|
+
for (const word of unknown) storage.verdicts.set(word, !wrong.has(word))
|
|
125
|
+
draw()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const schedule = (view: EditorView, delay: number) => {
|
|
130
|
+
if (timer) clearTimeout(timer)
|
|
131
|
+
timer = setTimeout(() => refresh(view), delay)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return [
|
|
135
|
+
new Plugin<SpellState>({
|
|
136
|
+
key: spellingKey,
|
|
137
|
+
state: {
|
|
138
|
+
init: () => ({ set: DecorationSet.empty, version: 0 }),
|
|
139
|
+
apply(tr, value) {
|
|
140
|
+
const meta = tr.getMeta(spellingKey) as DecorationSet | 'refresh' | undefined
|
|
141
|
+
if (meta instanceof DecorationSet) return { set: meta, version: value.version }
|
|
142
|
+
if (meta === 'refresh') return { set: value.set, version: value.version + 1 }
|
|
143
|
+
return { set: value.set.map(tr.mapping, tr.doc), version: value.version }
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
props: {
|
|
147
|
+
decorations: state => spellingKey.getState(state)?.set,
|
|
148
|
+
},
|
|
149
|
+
view: view => {
|
|
150
|
+
schedule(view, 50)
|
|
151
|
+
return {
|
|
152
|
+
update: (next, previous) => {
|
|
153
|
+
const refreshed = spellingKey.getState(next.state)?.version !== spellingKey.getState(previous)?.version
|
|
154
|
+
if (refreshed || !next.state.doc.eq(previous.doc)) schedule(next, refreshed ? 0 : 450)
|
|
155
|
+
},
|
|
156
|
+
destroy: () => {
|
|
157
|
+
if (timer) clearTimeout(timer)
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
]
|
|
163
|
+
},
|
|
164
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type MailTemplate = { id: string; name: string; shortcut: string; html: string }
|
|
2
|
+
export type TemplateContext = { firstName?: string; name?: string; email?: string; senderName?: string }
|
|
3
|
+
|
|
4
|
+
export const TEMPLATE_FIELD = /\{[A-Za-z][A-Za-z_ ]{0,29}\}/g
|
|
5
|
+
|
|
6
|
+
/** A shortcut as the sender types it: ";renewal". Letters, digits, dashes only. */
|
|
7
|
+
export const normaliseShortcut = (raw: string) => raw.trim().toLowerCase().replace(/^;+/, '').replace(/[^a-z0-9-]/g, '')
|
|
8
|
+
|
|
9
|
+
/** Fills the fields a template can know about; the rest stay as {field} for the sender to Tab through. */
|
|
10
|
+
export function fillTemplate(html: string, context: TemplateContext, today = new Date()): string {
|
|
11
|
+
const known: Record<string, string | undefined> = {
|
|
12
|
+
first_name: context.firstName,
|
|
13
|
+
firstname: context.firstName,
|
|
14
|
+
name: context.name ?? context.firstName,
|
|
15
|
+
email: context.email,
|
|
16
|
+
sender_name: context.senderName,
|
|
17
|
+
my_name: context.senderName,
|
|
18
|
+
date: today.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }),
|
|
19
|
+
today: today.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }),
|
|
20
|
+
}
|
|
21
|
+
return html.replace(TEMPLATE_FIELD, field => {
|
|
22
|
+
const value = known[field.slice(1, -1).trim().toLowerCase().replace(/\s+/g, '_')]
|
|
23
|
+
return value ? value.replace(/&/g, '&').replace(/</g, '<') : field
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SHARED_MAILBOXES = new Set([
|
|
28
|
+
'info', 'admin', 'support', 'claims', 'accounts', 'account', 'underwriting', 'sales', 'hello', 'contact', 'enquiries',
|
|
29
|
+
'enquiry', 'noreply', 'no-reply', 'mail', 'office', 'team', 'service', 'services', 'customerservice', 'customercare',
|
|
30
|
+
'care', 'help', 'hr', 'finance', 'billing', 'operations', 'ops', 'reception', 'marketing', 'news', 'notifications',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/** A first name from an address like eric.samuel@…; nothing for a shared mailbox like info@. */
|
|
34
|
+
export function firstNameFromAddress(address: string | undefined): string | undefined {
|
|
35
|
+
const local = (address ?? '').split('@')[0]?.toLowerCase() ?? ''
|
|
36
|
+
if (!local || SHARED_MAILBOXES.has(local)) return undefined
|
|
37
|
+
const first = local.split(/[._-]/)[0]
|
|
38
|
+
if (!/^[a-z]{2,20}$/.test(first) || SHARED_MAILBOXES.has(first)) return undefined
|
|
39
|
+
return first[0].toUpperCase() + first.slice(1)
|
|
40
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { checkableWord, correctWord, formatAmount, startsSentence, wordsOf } from './text.ts'
|
|
4
|
+
|
|
5
|
+
test('a sentence starts after a full stop, question or exclamation, and at a paragraph start', () => {
|
|
6
|
+
assert.equal(startsSentence(''), true)
|
|
7
|
+
assert.equal(startsSentence(' '), true)
|
|
8
|
+
assert.equal(startsSentence('Thank you. '), true)
|
|
9
|
+
assert.equal(startsSentence('Is it ready? '), true)
|
|
10
|
+
assert.equal(startsSentence('Done! '), true)
|
|
11
|
+
assert.equal(startsSentence('He said "yes." '), true)
|
|
12
|
+
assert.equal(startsSentence('Thank you.'), false, 'no space yet')
|
|
13
|
+
assert.equal(startsSentence('Thank you '), false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('abbreviations, initials and ellipses do not start a sentence', () => {
|
|
17
|
+
assert.equal(startsSentence('for e.g. '), false)
|
|
18
|
+
assert.equal(startsSentence('i.e. '), false)
|
|
19
|
+
assert.equal(startsSentence('Metroperil Ltd. '), false)
|
|
20
|
+
assert.equal(startsSentence('Policy No. '), false)
|
|
21
|
+
assert.equal(startsSentence('Dear Mr. '), false)
|
|
22
|
+
assert.equal(startsSentence('from J. '), false)
|
|
23
|
+
assert.equal(startsSentence('Well... '), false)
|
|
24
|
+
assert.equal(startsSentence('see info@x.com. '), false)
|
|
25
|
+
assert.equal(startsSentence('visit www. '), false, 'www. is an address, not a sentence end')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('typos are corrected keeping their capitals', () => {
|
|
29
|
+
assert.equal(correctWord('teh'), 'the')
|
|
30
|
+
assert.equal(correctWord('Teh'), 'The')
|
|
31
|
+
assert.equal(correctWord('TEH'), 'THE')
|
|
32
|
+
assert.equal(correctWord('dont'), "don't")
|
|
33
|
+
assert.equal(correctWord('recieved'), 'received')
|
|
34
|
+
assert.equal(correctWord('the'), null)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('amounts in any currency are grouped', () => {
|
|
38
|
+
const on = { nairaLetter: true, plainNumbers: false }
|
|
39
|
+
assert.equal(formatAmount('N1500000', on), '\u20a61,500,000')
|
|
40
|
+
assert.equal(formatAmount('NGN2500.50', on), 'NGN2,500.50')
|
|
41
|
+
assert.equal(formatAmount('$2500', on), '$2,500')
|
|
42
|
+
assert.equal(formatAmount('\u00a31250000', on), '\u00a31,250,000')
|
|
43
|
+
assert.equal(formatAmount('USD150000', on), 'USD150,000')
|
|
44
|
+
assert.equal(formatAmount('2500', on, 'USD'), '2,500', 'a code written apart still makes it money')
|
|
45
|
+
assert.equal(formatAmount('N1500000', { ...on, nairaLetter: false }), null)
|
|
46
|
+
assert.equal(formatAmount('N123', on), null, 'too short to be an amount')
|
|
47
|
+
assert.equal(formatAmount('NIGERIA', on), null)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('plain numbers are grouped only when asked, and never years, phones or account numbers', () => {
|
|
51
|
+
const plain = { nairaLetter: true, plainNumbers: true }
|
|
52
|
+
assert.equal(formatAmount('1500000', plain), '1,500,000')
|
|
53
|
+
assert.equal(formatAmount('15000.75', plain), '15,000.75')
|
|
54
|
+
assert.equal(formatAmount('1500000', { ...plain, plainNumbers: false }), null)
|
|
55
|
+
assert.equal(formatAmount('2026', plain), null, 'a year')
|
|
56
|
+
assert.equal(formatAmount('08061397800', plain), null, 'a phone number')
|
|
57
|
+
assert.equal(formatAmount('2034567890', plain), null, 'a ten-digit account number')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('only real words are checked', () => {
|
|
61
|
+
const options = { ignoreCapitals: true, ignoreWithNumbers: true }
|
|
62
|
+
assert.equal(checkableWord('insurance', options), true)
|
|
63
|
+
assert.equal(checkableWord('LASACO', options), false)
|
|
64
|
+
assert.equal(checkableWord('LASACO', { ...options, ignoreCapitals: false }), true)
|
|
65
|
+
assert.equal(checkableWord('AKD792', options), false)
|
|
66
|
+
assert.equal(checkableWord('a', options), false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('addresses are not split into words', () => {
|
|
70
|
+
assert.deepEqual(wordsOf('Mail info@metroperil.com today').map(entry => entry.word), ['Mail', 'today'])
|
|
71
|
+
assert.deepEqual(wordsOf("don't stop").map(entry => entry.word), ["don't", 'stop'])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('templates fill what they can and leave the rest to Tab through', async () => {
|
|
75
|
+
const { fillTemplate, normaliseShortcut } = await import('./templates.ts')
|
|
76
|
+
const html = fillTemplate('<p>Dear {first_name}, as at {date} policy {policy_no} is due. {sender_name}</p>', { firstName: 'Eric', senderName: 'Richard' }, new Date('2026-09-23T10:00:00Z'))
|
|
77
|
+
assert.equal(html, '<p>Dear Eric, as at 23 September 2026 policy {policy_no} is due. Richard</p>')
|
|
78
|
+
assert.equal(normaliseShortcut(';Renewal Notice'), 'renewalnotice')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a first name is read from a personal address, never from a shared one', async () => {
|
|
82
|
+
const { firstNameFromAddress } = await import('./templates.ts')
|
|
83
|
+
assert.equal(firstNameFromAddress('eric.samuel@coronation.ng'), 'Eric')
|
|
84
|
+
assert.equal(firstNameFromAddress('sijuatobatele@lasaco.com'), 'Sijuatobatele')
|
|
85
|
+
assert.equal(firstNameFromAddress('info@metroperil.com'), undefined)
|
|
86
|
+
assert.equal(firstNameFromAddress('claims@insurer.com'), undefined)
|
|
87
|
+
assert.equal(firstNameFromAddress('a2b@x.com'), undefined)
|
|
88
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const ABBREVIATIONS = new Set([
|
|
2
|
+
'e.g', 'i.e', 'etc', 'eg', 'ie', 'vs', 'viz', 'cf', 'al', 'approx', 'ref', 'no', 'nos', 'tel', 'fig', 'p', 'pp', 'pg',
|
|
3
|
+
'mr', 'mrs', 'ms', 'dr', 'prof', 'sr', 'jr', 'st', 'hon', 'rev', 'engr', 'arc', 'barr', 'chief', 'alh', 'gen', 'capt',
|
|
4
|
+
'ltd', 'plc', 'inc', 'co', 'corp', 'dept', 'est', 'attn', 'encl', 'pls', 'www',
|
|
5
|
+
'jan', 'feb', 'mar', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec',
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether a letter typed after `before` begins a sentence. `before` is the paragraph's text
|
|
10
|
+
* up to the caret. "e.g. the" and "Ltd. has" do not; "1.5" and "metroperil.com" never
|
|
11
|
+
* reach here because nothing but a space follows a full stop that ends a sentence.
|
|
12
|
+
*/
|
|
13
|
+
export function startsSentence(before: string): boolean {
|
|
14
|
+
if (!before.trim()) return true
|
|
15
|
+
const match = before.match(/(\S*?)([.!?])["'”’)\]]*\s+$/)
|
|
16
|
+
if (!match) return false
|
|
17
|
+
if (match[2] !== '.') return true
|
|
18
|
+
if (/\.\.$/.test(before.trimEnd().replace(/["'”’)\]]+$/, ''))) return false
|
|
19
|
+
const word = match[1].replace(/^["'“‘(\[]+/, '').toLowerCase()
|
|
20
|
+
if (ABBREVIATIONS.has(word)) return false
|
|
21
|
+
if (/^[a-z]$/.test(word)) return false
|
|
22
|
+
return !/@|\//.test(word)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TYPOS: Record<string, string> = {
|
|
26
|
+
teh: 'the', hte: 'the', adn: 'and', nad: 'and', taht: 'that', thta: 'that', wiht: 'with', whit: 'with',
|
|
27
|
+
recieve: 'receive', recieved: 'received', reciept: 'receipt', beleive: 'believe', acheive: 'achieve',
|
|
28
|
+
seperate: 'separate', seperately: 'separately', definately: 'definitely', occured: 'occurred',
|
|
29
|
+
occurence: 'occurrence', untill: 'until', wich: 'which', becuase: 'because', becasue: 'because',
|
|
30
|
+
thier: 'their', freind: 'friend', calender: 'calendar', accomodate: 'accommodate', acommodate: 'accommodate',
|
|
31
|
+
adress: 'address', buisness: 'business', comittee: 'committee', commited: 'committed', enviroment: 'environment',
|
|
32
|
+
existance: 'existence', foward: 'forward', goverment: 'government', gaurantee: 'guarantee', garantee: 'guarantee',
|
|
33
|
+
immediatly: 'immediately', independant: 'independent', neccessary: 'necessary', necesary: 'necessary',
|
|
34
|
+
noticable: 'noticeable', occassion: 'occasion', persue: 'pursue', posession: 'possession', prefered: 'preferred',
|
|
35
|
+
premuim: 'premium', premiun: 'premium', polcy: 'policy', ploicy: 'policy', insurace: 'insurance', insurnace: 'insurance',
|
|
36
|
+
renewel: 'renewal', reneval: 'renewal', recomend: 'recommend', refered: 'referred', responsability: 'responsibility',
|
|
37
|
+
sincerly: 'sincerely', succesful: 'successful', sucessful: 'successful', tommorow: 'tomorrow', tomorow: 'tomorrow',
|
|
38
|
+
truely: 'truly', wierd: 'weird', writting: 'writing', thanx: 'thanks', pls: 'please', plz: 'please',
|
|
39
|
+
dont: "don't", doesnt: "doesn't", didnt: "didn't", cant: "can't", couldnt: "couldn't", wouldnt: "wouldn't",
|
|
40
|
+
shouldnt: "shouldn't", isnt: "isn't", wasnt: "wasn't", arent: "aren't", werent: "weren't", havent: "haven't",
|
|
41
|
+
hasnt: "hasn't", wont: "won't", im: "I'm", ive: "I've", youre: "you're", theyre: "they're", thats: "that's",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The correction for a mistyped word, keeping its capitals; null when the word is fine. */
|
|
45
|
+
export function correctWord(word: string): string | null {
|
|
46
|
+
const fixed = TYPOS[word.toLowerCase()]
|
|
47
|
+
if (!fixed) return null
|
|
48
|
+
if (word === word.toUpperCase() && word.length > 1) return fixed.toUpperCase()
|
|
49
|
+
if (word[0] === word[0].toUpperCase()) return fixed[0].toUpperCase() + fixed.slice(1)
|
|
50
|
+
return fixed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CURRENCY_CODES = 'NGN|USD|GBP|EUR|GHS|KES|ZAR|XOF|XAF|CAD|AUD|JPY|CNY|INR|AED|CHF|EGP|RWF|UGX|TZS'
|
|
54
|
+
const CURRENCY_SYMBOLS = '\u20a6$\u00a3\u20ac\u00a5\u20b9\u20b5'
|
|
55
|
+
const MONEY = new RegExp(`^(${CURRENCY_CODES}|[${CURRENCY_SYMBOLS}]|N)(\\d{4,})(\\.\\d{1,2})?$`)
|
|
56
|
+
const CURRENCY_ALONE = new RegExp(`^(?:${CURRENCY_CODES}|[${CURRENCY_SYMBOLS}]|N)$`)
|
|
57
|
+
|
|
58
|
+
const group = (digits: string) => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
59
|
+
|
|
60
|
+
export type AmountOptions = { nairaLetter: boolean; plainNumbers: boolean }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An amount with its thousands grouped: "$2500" → "$2,500", "USD 1500000" → "USD 1,500,000",
|
|
64
|
+
* and, when asked for, "N1500000" → "₦1,500,000" and a bare "150000" → "150,000".
|
|
65
|
+
*
|
|
66
|
+
* A bare number is only grouped at five to nine digits and never with a leading zero, so a
|
|
67
|
+
* year, a phone number and a ten-digit account number are left as typed. `previous` is the
|
|
68
|
+
* word before, so "USD 2500" is money even though the amount stands alone.
|
|
69
|
+
*/
|
|
70
|
+
export function formatAmount(token: string, options: AmountOptions, previous = ''): string | null {
|
|
71
|
+
const money = token.match(MONEY)
|
|
72
|
+
if (money) {
|
|
73
|
+
if (money[1] === 'N' && !options.nairaLetter) return null
|
|
74
|
+
return `${money[1] === 'N' ? '\u20a6' : money[1]}${group(money[2])}${money[3] ?? ''}`
|
|
75
|
+
}
|
|
76
|
+
const bare = token.match(/^(\d{4,})(\.\d{1,2})?$/)
|
|
77
|
+
if (!bare) return null
|
|
78
|
+
if (CURRENCY_ALONE.test(previous) && !(previous === 'N' && !options.nairaLetter)) return `${group(bare[1])}${bare[2] ?? ''}`
|
|
79
|
+
if (options.plainNumbers && /^[1-9]\d{4,8}$/.test(bare[1])) return `${group(bare[1])}${bare[2] ?? ''}`
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether a word should be put to the dictionary at all. */
|
|
84
|
+
export function checkableWord(word: string, options: { ignoreCapitals: boolean; ignoreWithNumbers: boolean }): boolean {
|
|
85
|
+
if (word.length < 2) return false
|
|
86
|
+
if (/\d/.test(word)) return !options.ignoreWithNumbers
|
|
87
|
+
if (!/^[A-Za-zÀ-ɏ'’-]+$/.test(word)) return false
|
|
88
|
+
if (options.ignoreCapitals && word === word.toUpperCase()) return false
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The words of a passage with their offsets, ignoring web and email addresses. */
|
|
93
|
+
export function wordsOf(text: string): Array<{ word: string; start: number; end: number }> {
|
|
94
|
+
const out: Array<{ word: string; start: number; end: number }> = []
|
|
95
|
+
const skip: Array<[number, number]> = []
|
|
96
|
+
for (const match of text.matchAll(/\S+@\S+|https?:\/\/\S+|www\.\S+|\b\S+\.(?:com|ng|org|net|io|africa|co|uk)\b\S*/gi)) {
|
|
97
|
+
skip.push([match.index ?? 0, (match.index ?? 0) + match[0].length])
|
|
98
|
+
}
|
|
99
|
+
for (const match of text.matchAll(/[A-Za-zÀ-ɏ0-9]+(?:['’-][A-Za-zÀ-ɏ0-9]+)*/g)) {
|
|
100
|
+
const start = match.index ?? 0
|
|
101
|
+
const end = start + match[0].length
|
|
102
|
+
if (skip.some(([from, to]) => start >= from && end <= to)) continue
|
|
103
|
+
out.push({ word: match[0], start, end })
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
package/lib/dev-auth.ts
CHANGED
|
@@ -21,10 +21,12 @@ import { passwordFingerprint, readSession } from '@/lib/session'
|
|
|
21
21
|
export const MAIL_ACCOUNTS = MAIL_SEATS.filter(seat => seat.role === 'admin').map(seat => seat.email)
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* personal inbox is not a decision a deployment should make quietly.
|
|
24
|
+
* Inbound mail is copied to the owning account's login address when that address lives
|
|
25
|
+
* somewhere else. MAIL_FORWARDING=off stops it for the whole deployment; nothing else does.
|
|
27
26
|
*/
|
|
27
|
+
export const FORWARDING_ENABLED = (process.env.MAIL_FORWARDING ?? 'on').trim().toLowerCase() !== 'off'
|
|
28
|
+
|
|
29
|
+
/** Who gets the copy when the mail belongs to no account. */
|
|
28
30
|
export const FORWARD_RECIPIENTS = (process.env.MAIL_FORWARD_TO ?? '')
|
|
29
31
|
.split(',')
|
|
30
32
|
.map(entry => entry.trim().toLowerCase())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
test('aggregate reports are recognised by their subject, and nothing else is', async () => {
|
|
5
|
+
const { isDmarcAggregateReport } = await import('./mailbox.ts')
|
|
6
|
+
|
|
7
|
+
assert.equal(isDmarcAggregateReport('Report domain: contact.novacraft.africa Submitter: google.com Report-ID: 2705410220068441608'), true)
|
|
8
|
+
assert.equal(isDmarcAggregateReport('Report domain: contact.novacraft.africa Submitter: google.com\n Report-ID: 12382647491638298144'), true, 'Google folds the header')
|
|
9
|
+
assert.equal(isDmarcAggregateReport('Report Domain: metroperilinsbrokers.com Submitter: yahoo.com Report-ID: <1790142578.13334>'), true)
|
|
10
|
+
assert.equal(isDmarcAggregateReport('Report Domain: contact.novacraft.africa Submitter: protection.outlook.com Report-ID: 9115e5ed'), true)
|
|
11
|
+
assert.equal(isDmarcAggregateReport('Dmarc Aggregate Report Domain: {metroperilinsbrokers.com} Submitter: {Amazon SES} Date: {2026-09-22}'), true)
|
|
12
|
+
|
|
13
|
+
assert.equal(isDmarcAggregateReport('Re: Report domain: contact.novacraft.africa Submitter: google.com'), false, 'a reply is a person talking')
|
|
14
|
+
assert.equal(isDmarcAggregateReport('Report domain migration plan'), false, 'no Submitter, no report')
|
|
15
|
+
assert.equal(isDmarcAggregateReport('Quarterly report: domain renewals and the submitter list'), false)
|
|
16
|
+
assert.equal(isDmarcAggregateReport(''), false)
|
|
17
|
+
assert.equal(isDmarcAggregateReport(null), false)
|
|
18
|
+
})
|