@novacraft-engineering/mailbox 0.4.5 → 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.
@@ -1,13 +1,25 @@
1
1
  import assert from 'node:assert/strict'
2
+
3
+ process.env.MAIL_ADDRESS_DOMAIN = 'metroperilinsbrokers.com'
4
+ process.env.MAIL_ADDRESS_DOMAINS = 'metroperilinsbrokers.com'
5
+
2
6
  import test from 'node:test'
3
7
 
4
- process.env.MAIL_ADDRESS_DOMAIN = 'contact.example.com'
5
- process.env.MAIL_ADDRESS_DOMAINS = 'example.com,contact.example.com'
8
+ test('only mail addressed to a hosted domain is ingested', async () => {
9
+ const { addressedToUs } = await import('./receive.ts')
10
+
11
+ assert.equal(addressedToUs(['info@metroperilinsbrokers.com']), true)
12
+ assert.equal(addressedToUs(['O. Komolafe <okomolafe@metroperilinsbrokers.com>']), true, 'a display name does not hide the domain')
13
+ assert.equal(addressedToUs(['someone@example.com', 'info@metroperilinsbrokers.com']), true, 'one of ours is enough')
14
+
15
+ // The leak: one provider account held several tenants' domains and posted all of their
16
+ // mail to this one webhook, which filed it to the shared inbox because no local account
17
+ // matched. 360 of another tenant's messages became readable here.
18
+ assert.equal(addressedToUs(['hello@contact.novacraft.africa']), false)
19
+ assert.equal(addressedToUs(['admin@jakstoc.com', 'info@podzyme.com']), false)
20
+ assert.equal(addressedToUs([]), false, 'no recipient is not our recipient')
21
+ // A domain that merely ends with ours is a different domain.
22
+ assert.equal(addressedToUs(['x@evil-metroperilinsbrokers.com']), false)
23
+ assert.equal(addressedToUs(['x@metroperilinsbrokers.com.evil.net']), false)
6
24
 
7
- test('every domain we receive on is refused as a forward target, not just the primary one', async () => {
8
- const { ADDRESS_DOMAINS } = await import('./brand.ts')
9
- const loops = (address: string) => ADDRESS_DOMAINS.includes(address.split('@')[1] ?? '')
10
- assert.equal(loops('hosting@example.com'), true, 'a secondary domain we receive on would loop')
11
- assert.equal(loops('hello@contact.example.com'), true, 'the primary domain would loop')
12
- assert.equal(loops('someone@gmail.com'), false, 'an outside address is a real destination')
13
25
  })
package/lib/receive.ts CHANGED
@@ -2,9 +2,20 @@ import { BRAND, ADDRESS_DOMAINS } from '@/lib/brand'
2
2
  import { sendPush } from '@/lib/push'
3
3
  import { stripOwnPixel } from '@/lib/email-html'
4
4
  import { FORWARD_RECIPIENTS, MAIL_DOMAIN } from '@/lib/dev-auth'
5
- import { ADDRESS_ALIASES, appendInbound, getAccountByAddress, inboundExists, recordContact, recordSentMeta } from '@/lib/mailbox'
5
+ import { ADDRESS_ALIASES, appendInbound, judgeMessage, noteSender, senderStanding, senderDomainOf, getAccountByAddress, inboundExists, recordContact, recordSentMeta, repairInbound } from '@/lib/mailbox'
6
6
  import { sendMail } from '@/lib/mail-provider'
7
7
 
8
+ /**
9
+ * Where the receiving API lives. The Resend SDK reads RESEND_BASE_URL for sending, so a
10
+ * deployment pointed at another Resend-compatible host sends there — these calls are hand
11
+ * rolled and have to honour the same variable, or receiving silently talks to a host that
12
+ * has never seen the message and every body arrives empty.
13
+ */
14
+ function receivingBase(): string {
15
+ const configured = (process.env.RESEND_BASE_URL ?? '').trim().replace(/\/+$/, '')
16
+ return configured || 'https://api.resend.com'
17
+ }
18
+
8
19
  // The address we send from, and the inbox that owns mail addressed to nobody specific.
9
20
  const MAIL_FROM = (process.env.MAIL_FROM ?? process.env.RESEND_FROM ?? BRAND.supportEmail).replace(/^.*<|>$/g, '').trim()
10
21
  const SHARED_ADDRESS = MAIL_FROM.toLowerCase()
@@ -24,6 +35,22 @@ export async function attributeOwner(recipients: string[]): Promise<string> {
24
35
  return SHARED_ADDRESS
25
36
  }
26
37
 
38
+ /**
39
+ * Whether any recipient is on a domain this deployment hosts. One provider account can
40
+ * hold several tenants' domains and posts every message it receives to the one webhook it
41
+ * is configured with, so a deployment is offered other tenants' mail as a matter of course
42
+ * — and a signature check proves only that the provider sent it, not that it is ours.
43
+ */
44
+ export function addressedToUs(recipients: string[]): boolean {
45
+ const hosted = new Set([...ADDRESS_DOMAINS, MAIL_DOMAIN].map(domain => domain.trim().toLowerCase()).filter(Boolean))
46
+ return recipients.some(raw => {
47
+ const match = raw?.match(/<([^>]+)>/)
48
+ const address = (match ? match[1] : raw ?? '').trim().toLowerCase()
49
+ const domain = address.split('@')[1] ?? ''
50
+ return domain !== '' && hosted.has(domain)
51
+ })
52
+ }
53
+
27
54
  export function parseSender(raw: string): { email: string; name: string | null } {
28
55
  const match = raw.match(/^\s*(.*?)\s*<([^>]+)>\s*$/)
29
56
  if (match) return { name: match[1].replace(/^"|"$/g, '') || null, email: match[2].trim() }
@@ -57,7 +84,7 @@ export type ReceivedEmail = {
57
84
  // lives on Resend and must be pulled from the receiving endpoint, or the inbox stores blanks.
58
85
  export async function fetchReceivedEmail(emailId: string, apiKey: string): Promise<ReceivedEmail | null> {
59
86
  try {
60
- const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(emailId)}`, {
87
+ const response = await fetch(`${receivingBase()}/emails/receiving/${encodeURIComponent(emailId)}`, {
61
88
  headers: { Authorization: `Bearer ${apiKey}` },
62
89
  })
63
90
  if (!response.ok) return null
@@ -118,7 +145,7 @@ async function rehostAttachments(
118
145
 
119
146
  async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<SendAttachment[]> {
120
147
  try {
121
- const response = await fetch(`https://api.resend.com/emails/receiving/${encodeURIComponent(emailId)}/attachments`, {
148
+ const response = await fetch(`${receivingBase()}/emails/receiving/${encodeURIComponent(emailId)}/attachments`, {
122
149
  headers: { Authorization: `Bearer ${apiKey}` },
123
150
  })
124
151
  if (!response.ok) return []
@@ -135,8 +162,13 @@ async function fetchAttachmentBytes(emailId: string, apiKey: string): Promise<Se
135
162
  size: Number(entry.size ?? 0) || undefined,
136
163
  }
137
164
  try {
138
- const binary = url ? await fetch(url) : null
139
- if (binary?.ok) file.content = Buffer.from(await binary.arrayBuffer()).toString('base64')
165
+ // Inline base64 when the host sends the bytes with the listing; a download_url is
166
+ // fetched only when it doesn't.
167
+ if (entry.content) file.content = String(entry.content)
168
+ else {
169
+ const binary = url ? await fetch(url) : null
170
+ if (binary?.ok) file.content = Buffer.from(await binary.arrayBuffer()).toString('base64')
171
+ }
140
172
  } catch (err) {
141
173
  console.warn(`[mail] attachment pull failed for ${emailId}/${file.filename}:`, err)
142
174
  }
@@ -180,6 +212,8 @@ export async function forwardToAccounts(
180
212
  to: [recipient],
181
213
  replyTo: inbound.from || undefined,
182
214
  subject: inbound.subject.startsWith('Fwd:') ? inbound.subject : `Fwd: ${inbound.subject}`,
215
+ // The archive already holds the message this forwards; a second copy is noise.
216
+ skipArchive: true,
183
217
  text: inbound.text?.trim() || snippet(inbound.html, inbound.text) || ' ',
184
218
  ...(inbound.html ? { html: `${header}${stripOwnPixel(inbound.html)}` } : {}),
185
219
  ...(attachments.length ? { attachments } : {}),
@@ -199,14 +233,65 @@ export async function forwardToAccounts(
199
233
  export async function ingestReceived(
200
234
  emailId: string,
201
235
  data: Record<string, unknown> = {},
236
+ /**
237
+ * Repairing a message already in the mailbox: fill in the body and attachments it was
238
+ * stored without, and stop there. The owner was notified and the copy forwarded when it
239
+ * first arrived, so doing either again would be a second delivery of old mail.
240
+ */
241
+ mode: 'store' | 'repair' = 'store',
202
242
  ): Promise<{ owner: string; subject: string; from: string }> {
203
243
  const apiKey = process.env.RESEND_API_KEY
204
244
  // The webhook payload has no body — pull the full email (html/text/attachments) from Resend.
205
245
  const full = apiKey ? await fetchReceivedEmail(emailId, apiKey) : null
206
246
 
207
247
  const payloadAttachments = Array.isArray(data.attachments) ? (data.attachments as Array<Record<string, unknown>>) : []
248
+ // The sender's own domain answers these, and the provider scans for spam and viruses
249
+ // before we ever see the message. Where the webhook does not carry a verdict, the
250
+ // Authentication-Results header the receiving server wrote does.
251
+ const authHeader = String(
252
+ (full?.headers as Record<string, unknown> | undefined)?.['authentication-results'] ?? '',
253
+ ).toLowerCase()
254
+ const fromHeader = (mech: string): string | null => authHeader.match(new RegExp(`${mech}=(\\w+)`))?.[1] ?? null
255
+
256
+ // Who this is for has to be settled before the message can be judged: standing is what
257
+ // this mailbox has done with this sender, and a different mailbox may have done the
258
+ // opposite. Resolved once here and reused below.
259
+ const recipients = [
260
+ ...(Array.isArray(full?.to) ? full!.to : []),
261
+ ...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
262
+ ...(Array.isArray(full?.cc) ? full!.cc : []),
263
+ ...(Array.isArray(data.cc) ? (data.cc as string[]) : []),
264
+ ...(Array.isArray(full?.bcc) ? full!.bcc : []),
265
+ ...(Array.isArray(data.bcc) ? (data.bcc as string[]) : []),
266
+ ].filter(Boolean)
267
+ const owner = await attributeOwner(recipients)
268
+ const fromAddress = full?.from || String(data.from ?? '')
269
+ const senderDomain = senderDomainOf(fromAddress)
270
+ const standing = await senderStanding(owner, senderDomain)
271
+
272
+ const verdicts = judgeMessage({
273
+ spam: data.spam == null ? null : String(data.spam),
274
+ virus: data.virus == null ? null : String(data.virus),
275
+ // Where no provider hands us a verdict — a deployment reading its mail through Resend,
276
+ // say — the receiving server's own Authentication-Results header carries the same answer.
277
+ spf: data.spf == null ? fromHeader('spf') : String(data.spf),
278
+ dkim: data.dkim == null ? fromHeader('dkim') : String(data.dkim),
279
+ dmarc: data.dmarc == null ? fromHeader('dmarc') : String(data.dmarc),
280
+ from: full?.from || String(data.from ?? ''),
281
+ replyTo: full?.replyTo ?? (Array.isArray(data.reply_to) ? (data.reply_to as string[]) : []),
282
+ subject: full?.subject || String(data.subject ?? ''),
283
+ text: full?.text ?? (data.text ? String(data.text) : null),
284
+ }, standing)
285
+ if (verdicts.risk !== 'clean') {
286
+ console.warn(`[mail] ${verdicts.risk} inbound ${emailId}:`, verdicts.reasons.join('; '))
287
+ }
288
+
208
289
  const inbound = {
209
290
  id: emailId,
291
+ risk: verdicts.risk,
292
+ riskReasons: verdicts.reasons,
293
+ // Held out of the inbox, not merely labelled, once the weight passes the threshold.
294
+ spam: verdicts.quarantine,
210
295
  from: full?.from || String(data.from ?? ''),
211
296
  to: full?.to ?? (Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
212
297
  cc: full?.cc ?? (Array.isArray(data.cc) ? (data.cc as string[]) : []),
@@ -229,19 +314,31 @@ export async function ingestReceived(
229
314
  // Attribute (and therefore forward) to the accessor actually addressed — to, cc, OR bcc.
230
315
  // Anything not matching a member's personal address stays in the shared (admin) inbox,
231
316
  // so a member never receives mail they aren't a party to.
232
- owner: await attributeOwner([
233
- ...(Array.isArray(full?.to) ? full!.to : []),
234
- ...(Array.isArray(data.to) ? (data.to as string[]) : [String(data.to ?? '')]),
235
- ...(full?.cc ?? []),
236
- ...(full?.bcc ?? []),
237
- ]),
317
+ owner,
238
318
  }
319
+ // Refuse mail that was never addressed to this mailbox, before a copy is stored, pushed
320
+ // or forwarded. Without this, every message the provider account receives for any tenant
321
+ // lands in whichever deployment holds the webhook, filed to its shared inbox because no
322
+ // local account matches — one tenant reading another's mail.
323
+ const envelope = [...inbound.to, ...inbound.cc, ...inbound.bcc]
324
+ if (!addressedToUs(envelope)) {
325
+ throw new Error(`refusing ${emailId}: addressed to ${envelope.join(', ') || 'nobody'}, which is not a domain this mailbox hosts`)
326
+ }
327
+
239
328
  // Pull the bytes once: they become our stored copy and the forward's attachments.
240
329
  const files = apiKey ? await fetchAttachmentBytes(emailId, apiKey) : []
241
330
  if (files.length) inbound.attachments = await rehostAttachments(emailId, files)
242
331
  const forwardable = files.filter(file => file.content)
243
332
 
333
+ if (mode === 'repair') {
334
+ if (!full) throw new Error('provider returned no body; nothing to repair with')
335
+ const filled = await repairInbound(inbound)
336
+ if (!filled) throw new Error('no such message in this mailbox')
337
+ return { owner: inbound.owner, subject: inbound.subject, from: inbound.from }
338
+ }
339
+
244
340
  await appendInbound(inbound)
341
+ await noteSender(owner, senderDomain, 'received').catch(() => {})
245
342
  const sender = parseSender(inbound.from)
246
343
  await recordContact(sender.email, sender.name)
247
344
  await sendPush(inbound.owner, {
@@ -283,7 +380,7 @@ export async function listReceived(apiKey: string, since: Date): Promise<Receive
283
380
  for (let page = 0; page < 200; page += 1) {
284
381
  const params = new URLSearchParams({ limit: '50' })
285
382
  if (after) params.set('after', after)
286
- const response = await fetch(`https://api.resend.com/emails/receiving?${params}`, {
383
+ const response = await fetch(`${receivingBase()}/emails/receiving?${params}`, {
287
384
  headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
288
385
  })
289
386
  if (!response.ok) throw new Error(`provider list failed (${response.status})`)
@@ -0,0 +1,168 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { db, ensureMailSchema, recordContact, recordPixel, recordSentMeta, recordSentMessage } from '@/lib/mailbox'
3
+ import { sendMail, type SendPayload } from '@/lib/mail-provider'
4
+
5
+ /**
6
+ * A message written now and carried later. Everything the send needs is prepared before it
7
+ * is parked, so dispatch is only ever "hand this to the provider" — the tracking pixel, the
8
+ * threading headers and the recipient list cannot drift between writing and sending.
9
+ */
10
+ export type ScheduledSend = {
11
+ payload: SendPayload
12
+ owner: string
13
+ pixelId: string | null
14
+ subject: string
15
+ from: string
16
+ recipients: string[]
17
+ cc: string[]
18
+ bcc: string[]
19
+ replyTo: string[]
20
+ inReplyTo: string | null
21
+ storedAttachments: Array<{ filename: string; key: string }>
22
+ }
23
+
24
+ export type ScheduledRow = {
25
+ id: string
26
+ owner: string
27
+ sendAfter: string
28
+ status: string
29
+ subject: string
30
+ to: string[]
31
+ createdAt: string
32
+ lastError: string | null
33
+ }
34
+
35
+ const parse = (value: unknown): ScheduledSend | null => {
36
+ try {
37
+ return JSON.parse(String(value)) as ScheduledSend
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ export async function scheduleSend(owner: string, sendAfter: string, send: ScheduledSend): Promise<string> {
44
+ await ensureMailSchema()
45
+ const id = randomUUID()
46
+ await db()`
47
+ INSERT INTO mail_scheduled (id, owner, send_after, status, payload, created_at)
48
+ VALUES (${id}, ${owner.toLowerCase()}, ${sendAfter}, 'pending', ${JSON.stringify(send)}, ${new Date().toISOString()})`
49
+ return id
50
+ }
51
+
52
+ export async function listScheduled(owner: string | null): Promise<ScheduledRow[]> {
53
+ await ensureMailSchema()
54
+ const sql = db()
55
+ const rows = owner
56
+ ? await sql`SELECT * FROM mail_scheduled WHERE status = 'pending' AND lower(owner) = ${owner.toLowerCase()} ORDER BY send_after`
57
+ : await sql`SELECT * FROM mail_scheduled WHERE status = 'pending' ORDER BY send_after`
58
+ return rows.map(row => {
59
+ const send = parse(row.payload)
60
+ return {
61
+ id: String(row.id),
62
+ owner: String(row.owner),
63
+ sendAfter: String(row.send_after),
64
+ status: String(row.status),
65
+ subject: send?.subject ?? '(no subject)',
66
+ to: send?.recipients ?? [],
67
+ createdAt: String(row.created_at),
68
+ lastError: row.last_error == null ? null : String(row.last_error),
69
+ }
70
+ })
71
+ }
72
+
73
+ /** Cancelling is only ever the owner's to do, and only while it is still waiting. */
74
+ export async function cancelScheduled(id: string, owner: string): Promise<boolean> {
75
+ await ensureMailSchema()
76
+ const rows = await db()`
77
+ UPDATE mail_scheduled SET status = 'cancelled'
78
+ WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()} AND status = 'pending'
79
+ RETURNING id`
80
+ return rows.length > 0
81
+ }
82
+
83
+ /** The bookkeeping an immediate send does, so a delayed one lands in the same places. */
84
+ export async function recordSend(send: ScheduledSend, id: string | null): Promise<void> {
85
+ await Promise.all([
86
+ ...[...send.recipients, ...send.cc, ...send.bcc].map(address => recordContact(address, null).catch(() => {})),
87
+ send.pixelId && id
88
+ ? recordPixel(send.pixelId, id, send.recipients[0] ?? '', send.subject).catch(() => {})
89
+ : Promise.resolve(),
90
+ id ? recordSentMeta(id, send.owner, false, send.inReplyTo).catch(() => {}) : Promise.resolve(),
91
+ id
92
+ ? recordSentMessage({
93
+ id,
94
+ from: send.from,
95
+ to: send.recipients,
96
+ cc: send.cc,
97
+ bcc: send.bcc,
98
+ replyTo: send.replyTo,
99
+ subject: send.subject,
100
+ html: send.payload.html ?? null,
101
+ text: send.payload.text ?? null,
102
+ createdAt: new Date().toISOString(),
103
+ attachments: send.storedAttachments,
104
+ }).catch(() => {})
105
+ : Promise.resolve(),
106
+ ])
107
+ }
108
+
109
+ /** Moving the time is only the owner's to do, and only while it is still waiting. */
110
+ export async function rescheduleSend(id: string, owner: string, sendAfter: string): Promise<boolean> {
111
+ await ensureMailSchema()
112
+ const rows = await db()`
113
+ UPDATE mail_scheduled SET send_after = ${sendAfter}
114
+ WHERE id = ${id} AND lower(owner) = ${owner.toLowerCase()} AND status = 'pending'
115
+ RETURNING id`
116
+ return rows.length > 0
117
+ }
118
+
119
+ const MAX_ATTEMPTS = 5
120
+
121
+ /**
122
+ * Hands over every message whose time has come. Each row is claimed with a conditional
123
+ * update before it is sent, so two runners overlapping cannot send the same mail twice.
124
+ */
125
+ export async function dispatchDue(limit = 25): Promise<{ due: number; sent: number; failed: number }> {
126
+ await ensureMailSchema()
127
+ const sql = db()
128
+ const now = new Date().toISOString()
129
+ const due = await sql`
130
+ SELECT id, payload, attempts FROM mail_scheduled
131
+ WHERE status = 'pending' AND send_after <= ${now}
132
+ ORDER BY send_after LIMIT ${limit}`
133
+
134
+ let sent = 0
135
+ let failed = 0
136
+ for (const row of due) {
137
+ const id = String(row.id)
138
+ const claimed = await sql`
139
+ UPDATE mail_scheduled SET status = 'sending' WHERE id = ${id} AND status = 'pending' RETURNING id`
140
+ if (!claimed.length) continue
141
+
142
+ const send = parse(row.payload)
143
+ if (!send) {
144
+ await sql`UPDATE mail_scheduled SET status = 'failed', last_error = 'unreadable payload' WHERE id = ${id}`
145
+ failed += 1
146
+ continue
147
+ }
148
+
149
+ try {
150
+ // scheduledAt is deliberately dropped: the wait already happened here.
151
+ const { scheduledAt: _ignored, ...payload } = send.payload
152
+ const result = await sendMail(payload)
153
+ await recordSend(send, result?.id ?? null)
154
+ await sql`
155
+ UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
156
+ sent += 1
157
+ } catch (err) {
158
+ const attempts = Number(row.attempts ?? 0) + 1
159
+ const reason = err instanceof Error ? err.message : String(err)
160
+ await sql`
161
+ UPDATE mail_scheduled
162
+ SET status = ${attempts >= MAX_ATTEMPTS ? 'failed' : 'pending'}, attempts = ${attempts}, last_error = ${reason}
163
+ WHERE id = ${id}`
164
+ failed += 1
165
+ }
166
+ }
167
+ return { due: due.length, sent, failed }
168
+ }
package/lib/ses-send.ts CHANGED
@@ -7,14 +7,26 @@ export function sesConfigured(): boolean {
7
7
  return Boolean(process.env.SES_ACCESS_KEY_ID && process.env.SES_SECRET_ACCESS_KEY)
8
8
  }
9
9
 
10
- export async function sesSendRaw(raw: string, region = process.env.AWS_SES_REGION ?? 'eu-north-1'): Promise<string | null> {
10
+ /**
11
+ * `blindCopies` are envelope recipients only. A Bcc header inside the MIME would be handed
12
+ * to everyone the message reaches, so the address is named to SES instead of written down
13
+ * where the recipient can read it.
14
+ */
15
+ export async function sesSendRaw(
16
+ raw: string,
17
+ region = process.env.AWS_SES_REGION ?? 'eu-north-1',
18
+ blindCopies: string[] = [],
19
+ ): Promise<string | null> {
11
20
  const id = process.env.SES_ACCESS_KEY_ID
12
21
  const secret = process.env.SES_SECRET_ACCESS_KEY
13
22
  if (!id || !secret) throw new Error('SES_ACCESS_KEY_ID and SES_SECRET_ACCESS_KEY must be configured')
14
23
 
15
24
  const host = `email.${region}.amazonaws.com`
16
25
  const path = '/v2/email/outbound-emails'
17
- const body = JSON.stringify({ Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } } })
26
+ const body = JSON.stringify({
27
+ Content: { Raw: { Data: Buffer.from(raw, 'utf8').toString('base64') } },
28
+ ...(blindCopies.length ? { Destination: { BccAddresses: blindCopies } } : {}),
29
+ })
18
30
  const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
19
31
  const stamp = amzDate.slice(0, 8)
20
32
  const payloadHash = sha256(body)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.4.5",
3
+ "version": "0.4.17",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",