Repository navigation
Expand file tree
/
Copy pathmox.just
More file actions
376 lines (342 loc) · 16.3 KB
/
Copy pathmox.just
File metadata and controls
376 lines (342 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# Mox mail server recipes for local development of the "Start workflow by email"
# automation trigger. Delegated to from the root justfile as `just mox <recipe>`.
#
# Typical flow (backend running natively with `just dev`):
# just mox up # run the receiver in Docker (foreground, Ctrl+C to stop)
# just mox address # print the trigger's inbound address (copy from here)
# just mox send <address> # send a test email through mox
#
# `just mox up` runs the same `email-receiver` role as production from the
# backend dev image (compose profile "inbound-email"), so nothing has to be installed on
# the host. It posts webhooks to the natively running backend through
# host.docker.internal. Once BASEROW_INBOUND_EMAIL_DOMAIN and
# BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET are set in .env.local, `just dev up`
# starts the receiver automatically (`just mox up -d`) and `just dev stop`
# stops it. The fully dockerised stack does the same from .env.docker-dev.
#
# Native alternative, if you have the `mox` binary installed (brew install mox):
# just mox init # one-time (or after a reset): generate + validate config
# just mox up-native # run `mox localserve` (foreground, Ctrl+C to stop)
set unstable := true
# Where the throwaway mox config + message store live. A short path is required:
# mox's unix control socket hits the macOS ~104 char sun_path limit otherwise.
MOX_DIR := "/tmp/baserow-mox"
# Host port the receiver's SMTP listener is published on. Deliberately 2025, not
# localserve's default 1025, which collides with MailHog in the standard Baserow
# dev stack. Override with BASEROW_INBOUND_EMAIL_HOST_SMTP_PORT if 2025 is taken.
MOX_SMTP_PORT := env_var_or_default("BASEROW_INBOUND_EMAIL_HOST_SMTP_PORT", "2025")
# Load the repo-root .env.local so the mox config uses the SAME inbound domain
# and webhook secret the backend reads, keeping the two sides in sync.
_load_env := 'set -a; [ -f .env.local ] && . ./.env.local; set +a'
# The dev compose stack; the receiver is defined in docker-compose.yml behind the
# "inbound-email" profile and built from the dev image by docker-compose.dev.yml.
_dc := 'docker compose --env-file .env.docker-dev -f docker-compose.yml -f docker-compose.dev.yml'
# Show the available mox recipes.
[doc("List mox recipes")]
default:
@just --justfile mox.just --list
# Check the mox binary is installed, with install hints if not.
_check-binary:
#!/usr/bin/env bash
set -euo pipefail
if ! command -v mox >/dev/null 2>&1; then
echo "error: 'mox' is not installed or not on PATH." >&2
echo " macOS: brew install mox" >&2
echo " other: go install github.com/mjl-/mox@latest (needs Go)" >&2
echo " docs: https://github.com/mjl-/mox" >&2
exit 1
fi
# Ensure .env.local carries the two variables both the backend and the receiver
# need, generating a secret if absent. Appended only when the key is missing.
_ensure-env:
#!/usr/bin/env bash
set -euo pipefail
{{ _load_env }}
if [ -z "${BASEROW_INBOUND_EMAIL_DOMAIN:-}" ]; then
echo "BASEROW_INBOUND_EMAIL_DOMAIN=inbound.baserow.local" >> .env.local
echo " + added BASEROW_INBOUND_EMAIL_DOMAIN=inbound.baserow.local to .env.local"
fi
if [ -z "${BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET:-}" ]; then
echo "BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET=$(openssl rand -hex 16)" >> .env.local
echo " + added BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET (generated) to .env.local"
fi
# The backend deletes handed-over messages through the receiver's web API,
# published on loopback by the dev compose override.
if [ -z "${BASEROW_INBOUND_EMAIL_RECEIVER_URL:-}" ]; then
echo "BASEROW_INBOUND_EMAIL_RECEIVER_URL=http://localhost:${BASEROW_INBOUND_EMAIL_WEBAPI_HOST_PORT:-8880}" >> .env.local
echo " + added BASEROW_INBOUND_EMAIL_RECEIVER_URL to .env.local"
fi
# Run the receiver in Docker from the backend dev image, next to a backend that
# runs natively on the host. Builds the dev image on first use. Pass -d to run
# detached (used by `just dev up`).
[doc("Run the mox receiver in Docker (foreground, Ctrl+C to stop; -d to detach)")]
up *FLAGS:
#!/usr/bin/env bash
set -euo pipefail
DETACHED=false
for flag in {{ FLAGS }}; do
[ "$flag" = "-d" ] && DETACHED=true
done
just --justfile mox.just _ensure-env
{{ _load_env }}
if [ ! -f .env.docker-dev ] && [ -f .env.docker-dev.example ]; then
cp .env.docker-dev.example .env.docker-dev
fi
# Build args for the dev image, as `just dc-dev` sets them.
if [[ -z "${UID:-}" ]]; then UID=$(id -u); fi; export UID
if [[ -z "${GID:-}" ]]; then GID=$(id -g); fi; export GID
# The container reaches the host's backend through Docker's host gateway.
BACKEND_URL="${PUBLIC_BACKEND_URL:-http://localhost:8000}"
BACKEND_URL="${BACKEND_URL/localhost/host.docker.internal}"
BACKEND_URL="${BACKEND_URL/127.0.0.1/host.docker.internal}"
export BASEROW_INBOUND_EMAIL_WEBHOOK_URL="${BASEROW_INBOUND_EMAIL_WEBHOOK_URL:-${BACKEND_URL%/}/api/inbound-email/}"
export BASEROW_INBOUND_EMAIL_HOST_SMTP_PORT="{{ MOX_SMTP_PORT }}"
export BASEROW_INBOUND_EMAIL_HOST_PUBLISH_IP="${BASEROW_HOST_PUBLISH_IP:-127.0.0.1}"
export BASEROW_INBOUND_EMAIL_DOMAIN BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET
export BASEROW_INBOUND_EMAIL_WEBAPI_HOST_PORT="${BASEROW_INBOUND_EMAIL_WEBAPI_HOST_PORT:-8880}"
if ! docker image inspect baserow_backend:dev >/dev/null 2>&1; then
echo "Building the backend dev image (first run only, takes a few minutes)..."
{{ _dc }} build email-receiver
fi
if [ "$DETACHED" = true ]; then
{{ _dc }} --profile inbound-email up -d --no-deps email-receiver
echo "Mox receiver started for '$BASEROW_INBOUND_EMAIL_DOMAIN' (SMTP on localhost:{{ MOX_SMTP_PORT }}, webhooks to $BASEROW_INBOUND_EMAIL_WEBHOOK_URL). Logs: docker logs -f baserow-email-receiver-1"
else
echo "Starting the mox receiver for '$BASEROW_INBOUND_EMAIL_DOMAIN' (SMTP on localhost:{{ MOX_SMTP_PORT }}, webhooks to $BASEROW_INBOUND_EMAIL_WEBHOOK_URL). Press Ctrl+C to stop."
exec {{ _dc }} --profile inbound-email up --no-deps email-receiver
fi
# Wipe {{ MOX_DIR }} and generate a fresh, validated config for the native
# `mox localserve` flow. Safe to re-run: it always starts from a clean directory.
[doc("Native flow: wipe, regenerate and validate the local mox config")]
init: _check-binary
#!/usr/bin/env bash
set -euo pipefail
just --justfile mox.just _ensure-env
{{ _load_env }}
DOMAIN="$BASEROW_INBOUND_EMAIL_DOMAIN"
SECRET="$BASEROW_INBOUND_EMAIL_WEBHOOK_SECRET"
WEBHOOK_URL="${BASEROW_INBOUND_EMAIL_WEBHOOK_URL:-}"
if [ -z "$WEBHOOK_URL" ]; then
: "${PUBLIC_BACKEND_URL:?Set PUBLIC_BACKEND_URL in .env.local}"
WEBHOOK_URL="${PUBLIC_BACKEND_URL%/}/api/inbound-email/"
fi
echo "Generating mox config in {{ MOX_DIR }} for domain '$DOMAIN'..."
rm -rf "{{ MOX_DIR }}"
mkdir -p "{{ MOX_DIR }}"
mox localserve -dir "{{ MOX_DIR }}" -initonly >/dev/null
# Patch the generated config: SMTP port, inbound domain, catch-all
# destination, and the incoming webhook pointing at the backend.
MOX_DIR="{{ MOX_DIR }}" \
MOX_SMTP_PORT="{{ MOX_SMTP_PORT }}" \
INBOUND_DOMAIN="$DOMAIN" \
WEBHOOK_URL="$WEBHOOK_URL" \
WEBHOOK_SECRET="$SECRET" \
python3 <<'PY'
import os
mox_dir = os.environ["MOX_DIR"]
smtp_port = os.environ["MOX_SMTP_PORT"]
domain = os.environ["INBOUND_DOMAIN"]
url = os.environ["WEBHOOK_URL"]
secret = os.environ["WEBHOOK_SECRET"]
# mox.conf: move the SMTP listener off localserve's default 1025.
conf_path = os.path.join(mox_dir, "mox.conf")
conf = open(conf_path).read()
old_port = "Port: 1025\n"
assert conf.count(old_port) == 1, f"expected one SMTP Port: 1025, found {conf.count(old_port)}"
conf = conf.replace(old_port, f"Port: {smtp_port}\n")
open(conf_path, "w").write(conf)
# domains.conf: add the inbound domain, a catch-all destination, and the
# incoming webhook. sconf is tab-indented and orders IncomingWebhook before
# the KeepRetired* account fields, so it goes in as the first account field.
dom_path = os.path.join(mox_dir, "domains.conf")
dom = open(dom_path).read()
old = "Domains:\n\tlocalhost:\n"
new = (
"Domains:\n"
f"\t{domain}:\n"
"\t\tLocalpartCatchallSeparator: +\n"
"\tlocalhost:\n"
)
assert dom.count(old) == 1, "domains anchor not found"
dom = dom.replace(old, new)
old = "Accounts:\n\tmox:\n\n"
new = (
"Accounts:\n\tmox:\n\n"
"\t\tIncomingWebhook:\n"
f"\t\t\tURL: {url}\n"
f"\t\t\tAuthorization: {secret}\n\n"
)
assert dom.count(old) == 1, "account anchor not found"
dom = dom.replace(old, new)
old = "\t\tDestinations:\n\t\t\tmox@localhost: nil\n"
new = (
"\t\tDestinations:\n\t\t\tmox@localhost: nil\n"
f"\t\t\t@{domain}: nil\n"
)
assert dom.count(old) == 1, "destinations anchor not found"
dom = dom.replace(old, new)
# Remove the account's JunkFilter block (optional in sconf) so localserve
# stops rejecting small test emails as "junk-content-strict" (a fresh
# account has an untrained filter and rejects almost everything).
import re
dom, removed = re.subn(
r"\t\tJunkFilter:\n(?:.*\n)*?(?=\t\t# Do not apply a delay)", "", dom
)
assert removed == 1, "JunkFilter block not found"
open(dom_path, "w").write(dom)
print(" + patched mox.conf and domains.conf")
PY
just --justfile mox.just test
echo ""
echo "Mox is configured. Next:"
echo " just mox up-native # start the native mail server"
echo " just mox address # copy the trigger's inbound address"
echo " just mox send <address> # send a test email"
# Run the native mox mail server in the foreground (Ctrl+C to stop).
# Auto-initialises the config if it does not exist yet.
[doc("Native flow: run `mox localserve` (foreground, Ctrl+C to stop)")]
up-native: _check-binary
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ MOX_DIR }}/mox.conf" ]; then
echo "No mox config found, running 'just mox init' first..."
just --justfile mox.just init
fi
echo "Starting mox (SMTP on localhost:{{ MOX_SMTP_PORT }}). Press Ctrl+C to stop."
exec mox localserve -dir "{{ MOX_DIR }}"
# Stop and remove the Docker receiver only (used by `just dev stop`). Prints
# nothing when it is not running.
_down-docker:
#!/usr/bin/env bash
set -euo pipefail
if [ -f .env.docker-dev ] && {{ _dc }} --profile inbound-email ps -q email-receiver 2>/dev/null | grep -q .; then
{{ _dc }} --profile inbound-email rm -sf email-receiver >/dev/null 2>&1
echo "Stopped the mox receiver container."
fi
# Stop the receiver, whether it runs as the Docker service or as a
# (backgrounded) native `mox localserve`.
[doc("Stop the mox receiver (Docker or native)")]
down:
#!/usr/bin/env bash
set -euo pipefail
stopped=0
if [ -n "$(just --justfile mox.just _down-docker)" ]; then
echo "Stopped the mox receiver container."
stopped=1
fi
if pkill -f "mox localserve -dir {{ MOX_DIR }}"; then
echo "Stopped native mox localserve."
stopped=1
fi
[ "$stopped" = 1 ] || echo "No running mox receiver found."
# Validate the generated mox config.
[doc("Native flow: validate the generated mox config")]
test:
#!/usr/bin/env bash
set -euo pipefail
if [ ! -f "{{ MOX_DIR }}/mox.conf" ]; then
echo "No mox config found. Run 'just mox init' first." >&2
exit 1
fi
MOXCONF="{{ MOX_DIR }}/mox.conf" mox config test
# Remove the mox config and message store entirely.
[doc("Native flow: remove the mox config and message store")]
clean:
#!/usr/bin/env bash
set -euo pipefail
rm -rf "{{ MOX_DIR }}"
echo "Removed {{ MOX_DIR }}."
# Print the inbound email addresses of all configured trigger services, so you
# can copy one instead of hunting for it in the UI.
[doc("Print configured inbound trigger addresses")]
address:
#!/usr/bin/env bash
set -euo pipefail
cd backend
just manage shell <<'PY' 2>/dev/null | grep '@' || echo "No configured inbound email trigger services found (create one in the UI, or check BASEROW_INBOUND_EMAIL_DOMAIN)."
from baserow.contrib.integrations.core.models import CoreInboundEmailTriggerService
for s in CoreInboundEmailTriggerService.objects.all():
# The draft is reached through the `test-` prefixed address.
addr = s.email_address if s.is_public else s.test_email_address
if addr:
print(f"{addr} (service {s.id}, {'published' if s.is_public else 'draft'})")
PY
# Send a test email through mox to the given trigger address. Uses a fresh
# Message-ID each time so it never trips the duplicate-delivery guard. The
# subject is variadic, so trailing words are used as-is without quoting.
# `--body FILE` / `--html FILE` replace the default plain-text / HTML part with
# the contents of a file (relative to where you ran just; no spaces in paths),
# e.g. to send a message close to the receiver's size limit
# (BASEROW_INBOUND_EMAIL_MAX_MESSAGE_SIZE_MB). Whatever the message size, mox
# forwards at most 1 MiB of each part to the backend.
# Usage: just mox send <address> [--body FILE] [--html FILE] [subject words...]
[doc("Send a test email: just mox send <address> [--body FILE] [--html FILE] [subject words...]")]
send ADDRESS *ARGS:
#!/usr/bin/env bash
set -euo pipefail
if ! (exec 3<>"/dev/tcp/127.0.0.1/{{ MOX_SMTP_PORT }}") 2>/dev/null; then
echo "error: nothing is listening on localhost:{{ MOX_SMTP_PORT }}. Is mox running? Start it with: just mox up" >&2
exit 1
fi
MOX_SMTP_PORT="{{ MOX_SMTP_PORT }}" \
TO_ADDRESS="{{ ADDRESS }}" \
ARGS="{{ ARGS }}" \
INVOCATION_DIR="{{ invocation_directory() }}" \
python3 <<'PY'
import os, smtplib, sys, time
from email.message import EmailMessage
# Pull `--body FILE` / `--html FILE` out of the trailing words; the rest is
# the subject.
body_file = html_file = None
subject_words = []
args = os.environ["ARGS"].split()
while args:
arg = args.pop(0)
if arg in ("--body", "--html"):
if not args:
sys.exit(f"error: {arg} needs a file path")
path = os.path.join(os.environ["INVOCATION_DIR"], args.pop(0))
if arg == "--body":
body_file = path
else:
html_file = path
else:
subject_words.append(arg)
def read_part(path, default):
if path is None:
return default
try:
with open(path, encoding="utf-8") as f:
return f.read()
except OSError as e:
sys.exit(f"error: cannot read {path}: {e.strerror}")
text = read_part(body_file, "This is the plain text body sent through local mox.")
html = read_part(
html_file,
"<p>This is the <strong>HTML body</strong> sent through local mox.</p>",
)
to_address = os.environ["TO_ADDRESS"]
msg = EmailMessage()
msg["From"] = "Ada Lovelace <mox@localhost>" # must be @localhost for mox
msg["To"] = to_address
msg["Subject"] = " ".join(subject_words) or "Test email from 'just mox send'"
msg["Message-ID"] = f"<{time.time_ns()}.{os.getpid()}@localhost>"
msg.set_content(text)
msg.add_alternative(html, subtype="html")
# Flatten once with SMTP line endings, so the size printed is the size sent
# (and the one mox checks against SMTPMaxMessageSize).
raw = msg.as_bytes(policy=msg.policy.clone(linesep="\r\n"))
port = int(os.environ["MOX_SMTP_PORT"])
try:
with smtplib.SMTP("localhost", port) as smtp:
smtp.sendmail("mox@localhost", [to_address], raw)
except smtplib.SMTPResponseException as e:
detail = e.smtp_error
detail = detail.decode(errors="replace") if isinstance(detail, bytes) else detail
sys.exit(f"error: mox refused the message: {e.smtp_code} {detail}")
except smtplib.SMTPRecipientsRefused as e:
sys.exit(f"error: mox refused the recipient: {e.recipients}")
print(f"Sent to {to_address} via localhost:{port} ({len(raw) / 1024 / 1024:.2f} MiB)")
if max(len(text), len(html)) > 1024 * 1024:
print("Note: mox forwards at most 1 MiB of each of the text and HTML parts to the backend.")
PY