@novacraft-engineering/mailbox 0.1.0

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.
Files changed (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Back the mail database up to gzipped JSONL, one chunk at a time.
3
+
4
+ An embedded replica cannot bootstrap here: it wants a single uninterrupted 7.5GB
5
+ HTTP body and this link drops every half hour, so the transfer dies with
6
+ IncompleteBody. Paging by rowid instead means a dropped connection costs one chunk
7
+ rather than the whole run, and an interrupted night resumes where it stopped.
8
+
9
+ The full-text index is skipped. It is derived from mail_inbox and is rebuilt on
10
+ restore, so copying it would double the transfer to no purpose.
11
+ """
12
+ import argparse, gzip, json, os, sys, time, urllib.error, urllib.request
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
15
+ import doh
16
+
17
+ doh.install()
18
+
19
+ DERIVED_PREFIX = 'mail_inbox_fts'
20
+
21
+
22
+ class ChunkTooLarge(Exception):
23
+ """The response body was cut off. Retrying the same size just fails again."""
24
+ CHUNK = 200
25
+ MIN_CHUNK = 5
26
+
27
+
28
+ def load_env(path):
29
+ for line in open(path, encoding='utf-8'):
30
+ match = line.strip().split('=', 1)
31
+ if len(match) == 2 and match[0] and not match[0].startswith('#'):
32
+ os.environ.setdefault(match[0], match[1].strip('"').strip("'"))
33
+
34
+
35
+ def query(sql, args=None, attempts=9):
36
+ host = os.environ['TURSO_DATABASE_URL'].replace('libsql://', 'https://').rstrip('/')
37
+ stmt = {'sql': sql}
38
+ if args:
39
+ stmt['args'] = [{'type': 'text', 'value': str(a)} for a in args]
40
+ body = json.dumps({'requests': [{'type': 'execute', 'stmt': stmt}, {'type': 'close'}]}).encode()
41
+ last = None
42
+ for attempt in range(attempts):
43
+ try:
44
+ request = urllib.request.Request(host + '/v2/pipeline', body, {
45
+ 'Authorization': 'Bearer ' + os.environ['TURSO_AUTH_TOKEN'],
46
+ 'Content-Type': 'application/json'})
47
+ with urllib.request.urlopen(request, timeout=180) as response:
48
+ payload = json.load(response)
49
+ result = payload['results'][0]
50
+ if result.get('type') == 'error':
51
+ raise RuntimeError(result['error']['message'][:200])
52
+ out = result['response']['result']
53
+ return [c['name'] for c in out['cols']], [[cell.get('value') for cell in row] for row in out['rows']]
54
+ except Exception as err:
55
+ last = err
56
+ # A truncated body means the payload is too big for this link to carry in
57
+ # one piece. Waiting does not help; a smaller chunk does.
58
+ if 'IncompleteRead' in type(err).__name__ or 'IncompleteRead' in str(err):
59
+ if attempt >= 1:
60
+ raise ChunkTooLarge(str(err)[:120])
61
+ if attempt == attempts - 1:
62
+ break
63
+ time.sleep(min(60, 4 * (2 ** attempt)))
64
+ raise RuntimeError(f'query failed after {attempts} attempts: {last}')
65
+
66
+
67
+ def table_names():
68
+ _, rows = query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
69
+ return [r[0] for r in rows if not r[0].startswith(DERIVED_PREFIX)]
70
+
71
+
72
+ def dump_schema(out_dir):
73
+ _, rows = query("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name")
74
+ with open(os.path.join(out_dir, 'schema.sql'), 'w', encoding='utf-8') as handle:
75
+ for kind, name, sql in rows:
76
+ handle.write(f'-- {kind} {name}\n{sql};\n\n')
77
+ return len(rows)
78
+
79
+
80
+ def export_table(name, out_dir, state):
81
+ target = os.path.join(out_dir, f'{name}.jsonl.gz')
82
+ done = state.get(name, {})
83
+ if done.get('complete'):
84
+ return done.get('rows', 0), True
85
+
86
+ last_rowid = done.get('rowid', 0)
87
+ written = done.get('rows', 0)
88
+ mode = 'ab' if last_rowid else 'wb'
89
+ # Rows here range from a few hundred bytes to a hundred kilobytes, so a fixed row
90
+ # count produces wildly different payloads. A chunk large enough to be cut off
91
+ # mid-body fails every retry identically, so shrink on failure and creep back up.
92
+ size = CHUNK
93
+ with gzip.open(target, mode) as handle:
94
+ while True:
95
+ try:
96
+ cols, rows = query(
97
+ f'SELECT rowid AS _rowid, * FROM "{name}" WHERE rowid > {int(last_rowid)} ORDER BY rowid LIMIT {size}')
98
+ except (ChunkTooLarge, RuntimeError):
99
+ if size <= MIN_CHUNK:
100
+ raise
101
+ size = max(MIN_CHUNK, size // 4)
102
+ print(f' {name}: chunk too big for the link, retrying at {size} rows', flush=True)
103
+ continue
104
+ if size < CHUNK:
105
+ size = min(CHUNK, size * 2)
106
+ if not rows:
107
+ break
108
+ for row in rows:
109
+ record = dict(zip(cols, row))
110
+ last_rowid = record.pop('_rowid')
111
+ handle.write((json.dumps(record, ensure_ascii=False) + '\n').encode('utf-8'))
112
+ written += 1
113
+ state[name] = {'rowid': last_rowid, 'rows': written, 'complete': False}
114
+ save_state(out_dir, state)
115
+ state[name] = {'rowid': last_rowid, 'rows': written, 'complete': True}
116
+ save_state(out_dir, state)
117
+ return written, False
118
+
119
+
120
+ def save_state(out_dir, state):
121
+ tmp = os.path.join(out_dir, '.state.json.tmp')
122
+ with open(tmp, 'w', encoding='utf-8') as handle:
123
+ json.dump(state, handle)
124
+ os.replace(tmp, os.path.join(out_dir, '.state.json'))
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser()
129
+ parser.add_argument('--out', required=True)
130
+ parser.add_argument('--env', default='.env.local')
131
+ args = parser.parse_args()
132
+ load_env(args.env)
133
+ os.makedirs(args.out, exist_ok=True)
134
+
135
+ state_path = os.path.join(args.out, '.state.json')
136
+ state = json.load(open(state_path)) if os.path.exists(state_path) else {}
137
+
138
+ objects = dump_schema(args.out)
139
+ print(f'schema: {objects} objects', flush=True)
140
+
141
+ total = 0
142
+ for name in table_names():
143
+ started = time.time()
144
+ rows, skipped = export_table(name, args.out, state)
145
+ total += rows
146
+ note = 'already complete' if skipped else f'{time.time() - started:.0f}s'
147
+ print(f' {name:<24}{rows:>9} rows ({note})', flush=True)
148
+
149
+ manifest = {'finished_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
150
+ 'tables': {k: v['rows'] for k, v in state.items()}, 'total_rows': total,
151
+ 'note': 'full-text index omitted; rebuild after restore'}
152
+ with open(os.path.join(args.out, 'manifest.json'), 'w', encoding='utf-8') as handle:
153
+ json.dump(manifest, handle, indent=2)
154
+ print(f'total {total} rows', flush=True)
155
+
156
+
157
+ if __name__ == '__main__':
158
+ main()
package/tools/doh.py ADDED
@@ -0,0 +1,46 @@
1
+ """Keep working when the router's resolver flaps.
2
+
3
+ A home router whose resolver drops takes every request with it while raw IP
4
+ connectivity is fine. Installing this makes any failed lookup fall back to asking
5
+ 1.1.1.1 by address over HTTPS, which needs no working resolver to reach.
6
+
7
+ import-mbox.py carries its own copy of this; it is left alone deliberately while it
8
+ is mid-import, and can be pointed here once it is idle.
9
+ """
10
+ import json
11
+ import socket
12
+ import urllib.parse
13
+ import urllib.request
14
+
15
+ _cache = {}
16
+ _system_getaddrinfo = socket.getaddrinfo
17
+
18
+
19
+ def _lookup(host):
20
+ request = urllib.request.Request(
21
+ f'https://1.1.1.1/dns-query?name={urllib.parse.quote(host)}&type=A',
22
+ headers={'accept': 'application/dns-json'},
23
+ )
24
+ with urllib.request.urlopen(request, timeout=10) as response:
25
+ answers = json.load(response).get('Answer') or []
26
+ addresses = [entry['data'] for entry in answers if entry.get('type') == 1]
27
+ if not addresses:
28
+ raise socket.gaierror(f'no A record for {host} via DoH')
29
+ return addresses
30
+
31
+
32
+ def _resilient(host, port, family=0, kind=0, proto=0, flags=0):
33
+ try:
34
+ return _system_getaddrinfo(host, port, family, kind, proto, flags)
35
+ except socket.gaierror:
36
+ if host == '1.1.1.1' or not isinstance(host, str):
37
+ raise
38
+ if host not in _cache:
39
+ _cache[host] = _lookup(host)
40
+ print(f' ~ system DNS failed for {host}; using DoH answer {_cache[host][0]}', flush=True)
41
+ return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, '', (address, port))
42
+ for address in _cache[host]]
43
+
44
+
45
+ def install():
46
+ socket.getaddrinfo = _resilient