@novacraft-engineering/mailbox 0.4.25 → 0.4.27

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.
@@ -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, '&amp;').replace(/</g, '&lt;') : 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
+ }
@@ -174,7 +174,7 @@ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
174
174
  * A mailbox this deployment owns that keeps a copy of everything the app sends on its own
175
175
  * behalf. Product mail is invisible otherwise: it leaves for the recipient and no account
176
176
  * here ever sees it. Skipped when the archive is already a recipient, so a message never
177
- * arrives twice.
177
+ * arrives twice, and skipped for anything a person wrote — their Sent folder already has it.
178
178
  */
179
179
  function withArchive(payload: SendPayload): SendPayload {
180
180
  const archive = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
package/lib/scheduled.ts CHANGED
@@ -145,9 +145,10 @@ async function dispatchRow(row: Record<string, unknown>): Promise<'sent' | 'fail
145
145
  try {
146
146
  // scheduledAt is deliberately dropped: the wait already happened here.
147
147
  const { scheduledAt: _ignored, ...payload } = send.payload
148
- const result = await sendMail(
149
- payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
150
- )
148
+ const result = await sendMail({
149
+ ...(payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload),
150
+ skipArchive: true,
151
+ })
151
152
  await recordSend(send, result?.id ?? null)
152
153
  await sql`
153
154
  UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`