@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- 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
|