mirror of https://github.com/docusealco/docuseal
Runbook, docker-compose overlay, bootstrap/backup/restore scripts, deliverability helpers, proxy snippets, and a sample template fixture for first-run smoke testing. Ignore local pg_data/ volume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>pull/674/head
parent
744d45d2c5
commit
b1b4248720
@ -0,0 +1,35 @@
|
||||
# Copy to deploy/.env on the VPS and fill in. Never commit deploy/.env.
|
||||
# Used by: docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose.prod.yml up -d
|
||||
|
||||
# --- Required ---
|
||||
HOST=e-sign.360dmmc.com
|
||||
SECRET_KEY_BASE= # generate: openssl rand -hex 64
|
||||
|
||||
# --- Database (postgres service in compose) ---
|
||||
POSTGRES_USER=docuseal
|
||||
POSTGRES_PASSWORD= # generate: openssl rand -hex 24
|
||||
POSTGRES_DB=docuseal
|
||||
DATABASE_URL=postgres://docuseal:REPLACE_ME@postgres:5432/docuseal
|
||||
|
||||
# --- SMTP (Microsoft Exchange / Office 365) ---
|
||||
# DocuSeal also lets you set SMTP per-account via the admin UI; these env vars
|
||||
# act as a baseline. Production "from" address and display name are configured
|
||||
# in the admin UI after first login.
|
||||
SMTP_ADDRESS=smtp.office365.com
|
||||
SMTP_PORT=587
|
||||
SMTP_DOMAIN=360dmmc.com
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTHENTICATION=login
|
||||
SMTP_ENABLE_STARTTLS=true
|
||||
|
||||
# --- Optional: S3 for attachments (recommended once HIPAA-relevant data lands) ---
|
||||
# Leave blank for MVP; DocuSeal will store attachments on the local volume.
|
||||
# S3_ATTACHMENTS_BUCKET=
|
||||
# AWS_REGION=us-east-1
|
||||
# AWS_ACCESS_KEY_ID=
|
||||
# AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
# --- Optional: Sidekiq web UI auth (admin-only background job dashboard) ---
|
||||
# SIDEKIQ_BASIC_AUTH_USERNAME=admin
|
||||
# SIDEKIQ_BASIC_AUTH_PASSWORD=
|
||||
@ -0,0 +1,4 @@
|
||||
.env
|
||||
backups/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -0,0 +1,96 @@
|
||||
# DocuSeal VPS Deploy Runbook (KVM1 → e-sign.360dmmc.com)
|
||||
|
||||
## Prerequisites (from Lohith)
|
||||
- SSH access to KVM1 (sudo-capable user)
|
||||
- DNS A-record: `e-sign.360dmmc.com` → KVM1 public IPv4
|
||||
- Firewall: 80/tcp, 443/tcp inbound (world); 22/tcp inbound (admin); 587/tcp outbound to `smtp.office365.com`
|
||||
- Ubuntu 22.04, ≥2 GB RAM, ≥20 GB disk
|
||||
|
||||
## 1. Install Docker (if absent)
|
||||
```sh
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
# log out/in for group change to take effect
|
||||
```
|
||||
|
||||
## 2. Clone repo
|
||||
```sh
|
||||
sudo mkdir -p /opt/docuseal && sudo chown $USER:$USER /opt/docuseal
|
||||
git clone https://github.com/Dhia-mastouri/360-e-sign.git /opt/docuseal
|
||||
cd /opt/docuseal
|
||||
```
|
||||
|
||||
## 3. Configure environment
|
||||
```sh
|
||||
cp deploy/.env.example deploy/.env
|
||||
# Generate secrets:
|
||||
echo "SECRET_KEY_BASE=$(openssl rand -hex 64)" >> deploy/.env # then dedupe
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" >> deploy/.env
|
||||
# Edit deploy/.env: set HOST, SMTP_USERNAME, SMTP_PASSWORD, DATABASE_URL (use the same POSTGRES_PASSWORD)
|
||||
chmod 600 deploy/.env
|
||||
```
|
||||
|
||||
## 4. Verify DNS before bringing Caddy up
|
||||
```sh
|
||||
dig +short e-sign.360dmmc.com
|
||||
# Must return KVM1 public IP. If not, wait or fix with Lohith before next step.
|
||||
```
|
||||
|
||||
## 5. Bring stack up
|
||||
```sh
|
||||
docker compose --env-file deploy/.env \
|
||||
-f docker-compose.yml -f deploy/docker-compose.prod.yml \
|
||||
up -d
|
||||
docker compose logs -f app
|
||||
# Wait for "Listening on http://0.0.0.0:3000"
|
||||
```
|
||||
|
||||
Caddy will obtain a Let's Encrypt cert automatically on first request (~30 s).
|
||||
|
||||
## 6. First-run admin setup
|
||||
- Open `https://e-sign.360dmmc.com`
|
||||
- Complete admin onboarding (email, password, company)
|
||||
- Settings → Email → SMTP: confirm Exchange creds, send a test mail to yourself
|
||||
|
||||
## 7. Schedule backups
|
||||
```sh
|
||||
chmod +x deploy/backup.sh deploy/restore.sh
|
||||
sudo crontab -e
|
||||
# Add: 0 2 * * * /opt/docuseal/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1
|
||||
```
|
||||
|
||||
## 8. Smoke test
|
||||
- Upload AI-generated service-agreement PDF as a template
|
||||
- Drop Signature + Date + Name fields
|
||||
- Send to a real recipient
|
||||
- Verify completed PDF + audit log download
|
||||
|
||||
## Upstream sync
|
||||
```sh
|
||||
git fetch upstream
|
||||
git checkout dev
|
||||
git merge upstream/master # or upstream/main
|
||||
# Resolve conflicts in deploy/ should never happen; they will only ever appear
|
||||
# in upstream-tracked files. If they do, resolve in favor of upstream and
|
||||
# re-apply our 360DMMC overlay separately.
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
## Rollback
|
||||
```sh
|
||||
docker compose down
|
||||
./deploy/restore.sh /opt/docuseal/backups/<timestamp>
|
||||
docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Health checks
|
||||
- `curl -I https://e-sign.360dmmc.com` → expect `200` or `302`
|
||||
- `docker compose ps` → all services `Up`
|
||||
- `docker compose logs --tail 50 app` → no `ERROR` lines
|
||||
|
||||
## HIPAA pre-flight (before real PHI)
|
||||
- [ ] Microsoft 365 BAA signed (covers Exchange SMTP)
|
||||
- [ ] Postgres volume on encrypted disk (`cryptsetup` or cloud-provider encrypted disk)
|
||||
- [ ] Off-site backup target (encrypted) configured in `deploy/backup.sh`
|
||||
- [ ] Audit log retention policy documented
|
||||
- [ ] Access list reviewed (who has KVM1 sudo, who has DocuSeal admin)
|
||||
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Daily backup of Postgres + attachments. Run from /opt/docuseal repo root.
|
||||
# Suggested cron: 0 2 * * * /opt/docuseal/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-/opt/docuseal/backups}"
|
||||
RETAIN_DAYS="${RETAIN_DAYS:-14}"
|
||||
STAMP=$(date -u +%Y%m%d-%H%M%S)
|
||||
DEST="$BACKUP_ROOT/$STAMP"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
docker compose exec -T postgres \
|
||||
pg_dump -U "${POSTGRES_USER:-docuseal}" -d "${POSTGRES_DB:-docuseal}" -Fc \
|
||||
> "$DEST/postgres.dump"
|
||||
|
||||
tar czf "$DEST/attachments.tgz" -C /opt/docuseal data 2>/dev/null || true
|
||||
|
||||
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETAIN_DAYS" -exec rm -rf {} +
|
||||
|
||||
echo "[$(date -u +%FT%TZ)] backup ok -> $DEST ($(du -sh "$DEST" | cut -f1))"
|
||||
@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-shot DocuSeal bootstrap for KVM1.
|
||||
# Run as a sudo-capable user. Idempotent: safe to re-run.
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/Dhia-mastouri/360-e-sign/dev/deploy/bootstrap.sh | bash -s -- [proxy|caddy]
|
||||
#
|
||||
# Modes:
|
||||
# caddy - DocuSeal manages SSL via bundled Caddy. Requires ports 80+443 free.
|
||||
# proxy - DocuSeal binds to 127.0.0.1:3000, an existing reverse proxy fronts it.
|
||||
#
|
||||
# After running, edit /opt/docuseal/deploy/.env and fill SMTP_USERNAME, SMTP_PASSWORD,
|
||||
# then run: cd /opt/docuseal && docker compose --env-file deploy/.env -f docker-compose.yml -f deploy/docker-compose.<mode>.yml up -d
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MODE="${1:-caddy}"
|
||||
case "$MODE" in
|
||||
caddy) OVERLAY="docker-compose.prod.yml" ;;
|
||||
proxy) OVERLAY="docker-compose.behind-proxy.yml" ;;
|
||||
*) echo "usage: $0 [caddy|proxy]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
REPO_URL="https://github.com/Dhia-mastouri/360-e-sign.git"
|
||||
INSTALL_DIR="/opt/docuseal"
|
||||
BRANCH="dev"
|
||||
HOST_DEFAULT="e-sign.360dmmc.com"
|
||||
|
||||
echo "==> mode: $MODE (overlay: $OVERLAY)"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "==> installing Docker"
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker "$USER"
|
||||
echo " Docker installed. Log out and back in for group membership, then re-run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "$INSTALL_DIR" ]; then
|
||||
echo "==> cloning repo to $INSTALL_DIR"
|
||||
sudo mkdir -p "$INSTALL_DIR"
|
||||
sudo chown "$USER:$USER" "$INSTALL_DIR"
|
||||
git clone -b "$BRANCH" "$REPO_URL" "$INSTALL_DIR"
|
||||
else
|
||||
echo "==> repo exists, pulling latest"
|
||||
git -C "$INSTALL_DIR" fetch origin "$BRANCH"
|
||||
git -C "$INSTALL_DIR" checkout "$BRANCH"
|
||||
git -C "$INSTALL_DIR" pull --ff-only origin "$BRANCH"
|
||||
fi
|
||||
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
mkdir -p /opt/docuseal/data /opt/docuseal/pg_data /opt/docuseal/caddy /opt/docuseal/backups
|
||||
|
||||
if [ ! -f deploy/.env ]; then
|
||||
echo "==> generating deploy/.env from template"
|
||||
cp deploy/.env.example deploy/.env
|
||||
PG_PASS=$(openssl rand -hex 24)
|
||||
SECRET=$(openssl rand -hex 64)
|
||||
sed -i "s|^SECRET_KEY_BASE=.*|SECRET_KEY_BASE=$SECRET|" deploy/.env
|
||||
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" deploy/.env
|
||||
sed -i "s|REPLACE_ME|$PG_PASS|" deploy/.env
|
||||
sed -i "s|^HOST=.*|HOST=$HOST_DEFAULT|" deploy/.env
|
||||
chmod 600 deploy/.env
|
||||
echo " .env generated. EDIT IT NOW to set SMTP_USERNAME / SMTP_PASSWORD before continuing."
|
||||
echo " Edit: nano $INSTALL_DIR/deploy/.env"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if grep -q "^SMTP_USERNAME=$" deploy/.env || grep -q "^SMTP_PASSWORD=$" deploy/.env; then
|
||||
echo "!! SMTP_USERNAME and/or SMTP_PASSWORD are still empty in deploy/.env."
|
||||
echo " Edit deploy/.env and re-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> bringing stack up"
|
||||
docker compose --env-file deploy/.env -f docker-compose.yml -f "deploy/$OVERLAY" pull
|
||||
docker compose --env-file deploy/.env -f docker-compose.yml -f "deploy/$OVERLAY" up -d
|
||||
|
||||
echo "==> waiting for app to be ready"
|
||||
for i in $(seq 1 60); do
|
||||
if docker compose logs app 2>/dev/null | grep -q "Listening on http://0.0.0.0:3000"; then
|
||||
echo " app ready"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "==> done"
|
||||
docker compose ps
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Next steps:
|
||||
1. Open https://$(grep ^HOST= deploy/.env | cut -d= -f2)
|
||||
2. Complete admin onboarding (email + password + company)
|
||||
3. Settings -> Email -> SMTP: confirm Exchange creds, send test mail
|
||||
4. Generate an API token (Settings -> API)
|
||||
5. Run health check: deploy/health_check.sh
|
||||
6. Schedule backups:
|
||||
sudo crontab -l 2>/dev/null | { cat; echo '0 2 * * * $INSTALL_DIR/deploy/backup.sh >> /var/log/docuseal-backup.log 2>&1'; } | sudo crontab -
|
||||
EOF
|
||||
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validate upload_template.py + send_batch.py against the LOCAL docker stack.
|
||||
# Catches script bugs before we point them at prod.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Local stack running: docker compose ps shows app + postgres Up
|
||||
# 2. Admin user created (you said you did this earlier)
|
||||
# 3. API token generated in the local UI:
|
||||
# Settings -> API -> copy the token
|
||||
# 4. Export it:
|
||||
# export DOCUSEAL_API_KEY=<paste-token>
|
||||
# 5. (Optional) export TEST_RECIPIENT=<your-real-email> to receive the test invite.
|
||||
# Without SMTP configured locally, the email won't send -- but the API call
|
||||
# still creates the submission, which exercises send_batch.py end-to-end.
|
||||
#
|
||||
# Usage: deploy/deliverability/local_validate.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${DOCUSEAL_API_KEY:?Set DOCUSEAL_API_KEY (Settings -> API in the local UI)}"
|
||||
|
||||
export DOCUSEAL_URL="http://localhost:3000"
|
||||
export DOCUSEAL_API_KEY
|
||||
TEST_RECIPIENT="${TEST_RECIPIENT:-validate@example.invalid}"
|
||||
|
||||
echo "==> 1. Health probe"
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" "$DOCUSEAL_URL/api/templates?limit=1" \
|
||||
-H "X-Auth-Token: $DOCUSEAL_API_KEY")
|
||||
[ "$code" = "200" ] || { echo "API auth failed (HTTP $code) -- check token"; exit 1; }
|
||||
echo " API auth OK"
|
||||
|
||||
echo "==> 2. Upload test template"
|
||||
TPL_OUT=$(python deploy/deliverability/upload_template.py)
|
||||
echo "$TPL_OUT"
|
||||
TEMPLATE_ID=$(echo "$TPL_OUT" | grep -oP 'template_id=\K\d+')
|
||||
[ -n "$TEMPLATE_ID" ] || { echo "could not parse template_id"; exit 1; }
|
||||
export TEMPLATE_ID
|
||||
echo " template_id=$TEMPLATE_ID"
|
||||
|
||||
echo "==> 3. Tiny send batch (1 recipient)"
|
||||
TMP_CSV=$(mktemp --suffix=.csv)
|
||||
trap 'rm -f "$TMP_CSV"' EXIT
|
||||
cat > "$TMP_CSV" <<EOF
|
||||
email,name,provider
|
||||
$TEST_RECIPIENT,Local Validate,test
|
||||
EOF
|
||||
|
||||
POLL_SECS=20 python deploy/deliverability/send_batch.py "$TMP_CSV" || rc=$?
|
||||
rc=${rc:-0}
|
||||
|
||||
echo
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "LOCAL VALIDATION PASSED. Scripts work against a live DocuSeal stack."
|
||||
else
|
||||
echo "LOCAL VALIDATION FAILED (rc=$rc). Fix script issues before pointing at prod."
|
||||
fi
|
||||
exit "$rc"
|
||||
|
@ -0,0 +1,129 @@
|
||||
"""DocuSeal deliverability batch sender.
|
||||
|
||||
Reads a CSV of test recipients, sends one signing-request submission per row
|
||||
through the deployed DocuSeal API, then polls for SMTP send confirmation and
|
||||
open events. Reports per-provider results.
|
||||
|
||||
Usage:
|
||||
DOCUSEAL_URL=https://e-sign.360dmmc.com \\
|
||||
DOCUSEAL_API_KEY=xxxxx \\
|
||||
TEMPLATE_ID=42 \\
|
||||
python deploy/deliverability/send_batch.py recipients.csv
|
||||
|
||||
What this DOES verify:
|
||||
- SMTP accepted the message (DocuSeal records send result per submitter)
|
||||
- Recipient opened the signing link within the polling window
|
||||
|
||||
What this does NOT verify (requires manual inspection of mailboxes you own):
|
||||
- Inbox vs Spam folder placement
|
||||
- DKIM/SPF/DMARC header alignment
|
||||
- Mail-tester.com style content scoring (use that separately for one address)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
import requests
|
||||
|
||||
URL = os.environ["DOCUSEAL_URL"].rstrip("/")
|
||||
KEY = os.environ["DOCUSEAL_API_KEY"]
|
||||
TEMPLATE_ID = int(os.environ["TEMPLATE_ID"])
|
||||
POLL_SECS = int(os.environ.get("POLL_SECS", "180"))
|
||||
|
||||
S = requests.Session()
|
||||
S.headers.update({"X-Auth-Token": KEY, "Content-Type": "application/json"})
|
||||
|
||||
|
||||
def create_submission(email: str, name: str) -> int:
|
||||
r = S.post(
|
||||
f"{URL}/api/submissions",
|
||||
json={
|
||||
"template_id": TEMPLATE_ID,
|
||||
"send_email": True,
|
||||
"submitters": [{"email": email, "name": name, "role": "Client"}],
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
sub = payload[0] if isinstance(payload, list) else payload
|
||||
return sub["submission_id"] if "submission_id" in sub else sub["id"]
|
||||
|
||||
|
||||
def fetch_submission(sid: int) -> dict:
|
||||
r = S.get(f"{URL}/api/submissions/{sid}", timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def main(csv_path: str) -> int:
|
||||
rows: list[dict] = []
|
||||
with open(csv_path, newline="", encoding="utf-8") as f:
|
||||
for row in csv.DictReader(f):
|
||||
rows.append(row)
|
||||
|
||||
print(f"sending {len(rows)} submissions to {URL} (template {TEMPLATE_ID})")
|
||||
sent: list[tuple[dict, int]] = []
|
||||
send_errors: list[tuple[dict, str]] = []
|
||||
for row in rows:
|
||||
try:
|
||||
sid = create_submission(row["email"], row["name"])
|
||||
sent.append((row, sid))
|
||||
print(f" ✓ {row['email']:40s} -> submission {sid}")
|
||||
except requests.HTTPError as e:
|
||||
send_errors.append((row, f"{e.response.status_code} {e.response.text[:120]}"))
|
||||
print(f" ✗ {row['email']:40s} -> {e}")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"\npolling for {POLL_SECS}s to capture sent/opened events...")
|
||||
deadline = time.time() + POLL_SECS
|
||||
final: dict[int, dict] = {}
|
||||
while time.time() < deadline and len(final) < len(sent):
|
||||
for row, sid in sent:
|
||||
if sid in final:
|
||||
continue
|
||||
try:
|
||||
sub = fetch_submission(sid)
|
||||
submitter = sub["submitters"][0] if sub.get("submitters") else {}
|
||||
if submitter.get("sent_at") or submitter.get("opened_at"):
|
||||
final[sid] = {"row": row, "submitter": submitter}
|
||||
except requests.HTTPError:
|
||||
pass
|
||||
time.sleep(10)
|
||||
|
||||
by_provider: dict[str, Counter] = defaultdict(Counter)
|
||||
for row, sid in sent:
|
||||
provider = row.get("provider", "unknown")
|
||||
by_provider[provider]["total"] += 1
|
||||
st = final.get(sid, {}).get("submitter", {})
|
||||
if st.get("sent_at"):
|
||||
by_provider[provider]["sent"] += 1
|
||||
if st.get("opened_at"):
|
||||
by_provider[provider]["opened"] += 1
|
||||
|
||||
print("\n=== Results ===")
|
||||
print(f"API submission errors: {len(send_errors)}")
|
||||
for row, err in send_errors:
|
||||
print(f" - {row['email']}: {err}")
|
||||
print()
|
||||
print(f"{'provider':<12}{'total':>8}{'sent':>8}{'opened':>8}{'sent%':>8}{'open%':>8}")
|
||||
for prov, c in sorted(by_provider.items()):
|
||||
total = c["total"] or 1
|
||||
print(
|
||||
f"{prov:<12}{c['total']:>8}{c['sent']:>8}{c['opened']:>8}"
|
||||
f"{100 * c['sent'] / total:>7.0f}%{100 * c['opened'] / total:>7.0f}%"
|
||||
)
|
||||
print(
|
||||
"\nNote: 'opened' captures only recipients who clicked the link or "
|
||||
"whose mail client loaded the tracking pixel within the poll window. "
|
||||
"It is NOT a spam-folder check. Inspect test mailboxes you own to "
|
||||
"verify inbox placement."
|
||||
)
|
||||
return 0 if not send_errors else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "recipients.csv"))
|
||||
@ -0,0 +1,60 @@
|
||||
"""Upload the test service-agreement PDF to DocuSeal as a template.
|
||||
|
||||
Prints the resulting template_id so you can plug it straight into send_batch.py.
|
||||
|
||||
Usage:
|
||||
DOCUSEAL_URL=https://e-sign.360dmmc.com \\
|
||||
DOCUSEAL_API_KEY=xxxxx \\
|
||||
python deploy/deliverability/upload_template.py [path-to-pdf]
|
||||
"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
URL = os.environ["DOCUSEAL_URL"].rstrip("/")
|
||||
KEY = os.environ["DOCUSEAL_API_KEY"]
|
||||
|
||||
DEFAULT_PDF = Path(__file__).parent.parent / "test-fixtures" / "service_agreement.pdf"
|
||||
pdf_path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PDF
|
||||
if not pdf_path.exists():
|
||||
sys.exit(f"PDF not found: {pdf_path}")
|
||||
|
||||
with pdf_path.open("rb") as f:
|
||||
pdf_b64 = base64.b64encode(f.read()).decode("ascii")
|
||||
|
||||
# DocuSeal POST /api/templates/pdf:
|
||||
# https://www.docuseal.com/docs/api#create-a-template-from-pdf
|
||||
payload = {
|
||||
"name": "Smoke Test Service Agreement",
|
||||
"documents": [
|
||||
{
|
||||
"name": pdf_path.stem,
|
||||
"file": pdf_b64,
|
||||
"fields": [
|
||||
{"name": "Client Signature", "type": "signature", "role": "Client",
|
||||
"areas": [{"x": 0.15, "y": 0.78, "w": 0.35, "h": 0.04, "page": 0}]},
|
||||
{"name": "Client Name", "type": "text", "role": "Client",
|
||||
"areas": [{"x": 0.15, "y": 0.74, "w": 0.35, "h": 0.03, "page": 0}]},
|
||||
{"name": "Client Date", "type": "date", "role": "Client",
|
||||
"areas": [{"x": 0.15, "y": 0.82, "w": 0.20, "h": 0.03, "page": 0}]},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
r = requests.post(
|
||||
f"{URL}/api/templates/pdf",
|
||||
json=payload,
|
||||
headers={"X-Auth-Token": KEY, "Content-Type": "application/json"},
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
tpl = r.json()
|
||||
print(f"template_id={tpl['id']}")
|
||||
print(f"name={tpl.get('name')}")
|
||||
print(f"slug={tpl.get('slug')}")
|
||||
print(f"\nNext: TEMPLATE_ID={tpl['id']} python deploy/deliverability/send_batch.py recipients.csv")
|
||||
@ -0,0 +1,34 @@
|
||||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FORCE_SSL=${HOST}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
|
||||
- SMTP_ADDRESS=${SMTP_ADDRESS}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_DOMAIN=${SMTP_DOMAIN}
|
||||
- SMTP_USERNAME=${SMTP_USERNAME}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
|
||||
- SMTP_ENABLE_STARTTLS=${SMTP_ENABLE_STARTTLS}
|
||||
volumes:
|
||||
- /opt/docuseal/data:/data/docuseal
|
||||
ports: !override
|
||||
- 127.0.0.1:3000:3000
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- /opt/docuseal/pg_data:/var/lib/postgresql/18/docker
|
||||
|
||||
caddy:
|
||||
profiles: ["disabled"]
|
||||
@ -0,0 +1,34 @@
|
||||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FORCE_SSL=${HOST}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- SECRET_KEY_BASE=${SECRET_KEY_BASE}
|
||||
- SMTP_ADDRESS=${SMTP_ADDRESS}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_DOMAIN=${SMTP_DOMAIN}
|
||||
- SMTP_USERNAME=${SMTP_USERNAME}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_AUTHENTICATION=${SMTP_AUTHENTICATION}
|
||||
- SMTP_ENABLE_STARTTLS=${SMTP_ENABLE_STARTTLS}
|
||||
volumes:
|
||||
- /opt/docuseal/data:/data/docuseal
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- /opt/docuseal/pg_data:/var/lib/postgresql/18/docker
|
||||
|
||||
caddy:
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /opt/docuseal/caddy:/data/caddy
|
||||
@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Post-deploy smoke check. Returns non-zero if anything looks wrong.
|
||||
# Usage: deploy/health_check.sh [host]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HOST="${1:-$(grep ^HOST= deploy/.env 2>/dev/null | cut -d= -f2)}"
|
||||
HOST="${HOST:-e-sign.360dmmc.com}"
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local label="$1"; shift
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo " ✓ $label"
|
||||
else
|
||||
echo " ✗ $label"
|
||||
FAIL=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== containers =="
|
||||
check "app running" bash -c "docker compose ps app | grep -q ' Up'"
|
||||
check "postgres healthy" bash -c "docker compose ps postgres | grep -q 'healthy'"
|
||||
|
||||
echo "== database =="
|
||||
check "pg_isready" docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-docuseal}"
|
||||
|
||||
echo "== app =="
|
||||
check "puma listening on 3000" docker compose exec -T app bash -c "ss -tln 2>/dev/null | grep -q ':3000' || netstat -tln 2>/dev/null | grep -q ':3000'"
|
||||
|
||||
echo "== HTTP =="
|
||||
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://$HOST/" --max-time 10 || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "301" ]; then
|
||||
echo " ✓ https://$HOST/ -> $code"
|
||||
else
|
||||
echo " ✗ https://$HOST/ -> $code"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "== TLS =="
|
||||
if echo | openssl s_client -connect "$HOST:443" -servername "$HOST" -verify_return_error 2>/dev/null | grep -q "Verify return code: 0"; then
|
||||
echo " ✓ TLS cert valid"
|
||||
else
|
||||
echo " ✗ TLS cert invalid or unreachable"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
if [ "${DOCUSEAL_API_KEY:-}" != "" ]; then
|
||||
echo "== API =="
|
||||
api_code=$(curl -sk -o /dev/null -w "%{http_code}" \
|
||||
-H "X-Auth-Token: $DOCUSEAL_API_KEY" \
|
||||
"https://$HOST/api/templates?limit=1" --max-time 10 || echo "000")
|
||||
if [ "$api_code" = "200" ]; then
|
||||
echo " ✓ API token valid"
|
||||
else
|
||||
echo " ✗ API check returned $api_code"
|
||||
FAIL=1
|
||||
fi
|
||||
else
|
||||
echo " - skipped API check (set DOCUSEAL_API_KEY to enable)"
|
||||
fi
|
||||
|
||||
echo
|
||||
[ $FAIL -eq 0 ] && echo "ALL OK" || echo "FAILURES PRESENT"
|
||||
exit $FAIL
|
||||
@ -0,0 +1,11 @@
|
||||
# Drop into the existing Caddyfile.
|
||||
|
||||
e-sign.360dmmc.com {
|
||||
reverse_proxy 127.0.0.1:3000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
header_up Host {host}
|
||||
}
|
||||
request_body {
|
||||
max_size 50MB
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
# Drop into /etc/nginx/sites-available/e-sign.360dmmc.com and symlink to sites-enabled.
|
||||
# Assumes Let's Encrypt cert obtained separately (e.g. certbot --nginx -d e-sign.360dmmc.com)
|
||||
# or terminate TLS at an upstream load balancer and run this server block on :80.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name e-sign.360dmmc.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name e-sign.360dmmc.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/e-sign.360dmmc.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/e-sign.360dmmc.com/privkey.pem;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
# If KVM1's existing reverse proxy is Traefik, ADD these labels to the `app`
|
||||
# service in docker-compose.behind-proxy.yml (and remove the `ports:` mapping
|
||||
# so the app stays on the docker network only).
|
||||
#
|
||||
# Adjust `traefik-public` to match the existing Traefik network name.
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
- "traefik.http.routers.docuseal.rule=Host(`e-sign.360dmmc.com`)"
|
||||
- "traefik.http.routers.docuseal.entrypoints=websecure"
|
||||
- "traefik.http.routers.docuseal.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.docuseal.loadbalancer.server.port=3000"
|
||||
|
||||
networks:
|
||||
- traefik-public
|
||||
- default
|
||||
@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Restore a backup directory produced by backup.sh.
|
||||
# Usage: ./deploy/restore.sh /opt/docuseal/backups/YYYYMMDD-HHMMSS
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:?usage: restore.sh <backup-dir>}"
|
||||
[ -f "$SRC/postgres.dump" ] || { echo "missing $SRC/postgres.dump"; exit 1; }
|
||||
|
||||
read -r -p "Restore $SRC into running stack? Postgres data will be REPLACED. [yes/N] " ok
|
||||
[ "$ok" = "yes" ] || exit 1
|
||||
|
||||
docker compose exec -T postgres \
|
||||
psql -U "${POSTGRES_USER:-docuseal}" -d postgres -c \
|
||||
"DROP DATABASE IF EXISTS ${POSTGRES_DB:-docuseal}; CREATE DATABASE ${POSTGRES_DB:-docuseal};"
|
||||
|
||||
docker compose exec -T postgres \
|
||||
pg_restore -U "${POSTGRES_USER:-docuseal}" -d "${POSTGRES_DB:-docuseal}" --clean --if-exists \
|
||||
< "$SRC/postgres.dump"
|
||||
|
||||
if [ -f "$SRC/attachments.tgz" ]; then
|
||||
tar xzf "$SRC/attachments.tgz" -C /opt/docuseal
|
||||
fi
|
||||
|
||||
docker compose restart app
|
||||
echo "restore complete from $SRC"
|
||||
@ -0,0 +1,46 @@
|
||||
"""Generate a placeholder service-agreement PDF for DocuSeal smoke testing.
|
||||
|
||||
This is fictional sample text only. Not legal advice, not a real contract.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
OUT = Path(__file__).parent / "service_agreement.pdf"
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
h1, body = styles["Title"], styles["BodyText"]
|
||||
|
||||
story = [
|
||||
Paragraph("PROFESSIONAL SERVICES AGREEMENT", h1),
|
||||
Spacer(1, 18),
|
||||
Paragraph(
|
||||
"This Agreement (\"Agreement\") is entered into as of the date last "
|
||||
"signed below (\"Effective Date\") between <strong>360DMMC LLC</strong> "
|
||||
"(\"Provider\") and the undersigned <strong>Client</strong>.",
|
||||
body,
|
||||
),
|
||||
Spacer(1, 12),
|
||||
Paragraph("<strong>1. Services.</strong> Provider agrees to deliver the services described in any mutually executed statement of work.", body),
|
||||
Paragraph("<strong>2. Fees.</strong> Client shall pay Provider the fees set forth in each statement of work, net 30 days from invoice.", body),
|
||||
Paragraph("<strong>3. Term.</strong> This Agreement begins on the Effective Date and continues until terminated by either party with 30 days written notice.", body),
|
||||
Paragraph("<strong>4. Confidentiality.</strong> Each party agrees to protect the other's confidential information using reasonable care for two (2) years following disclosure.", body),
|
||||
Paragraph("<strong>5. Limitation of Liability.</strong> Neither party shall be liable for indirect, incidental, or consequential damages arising out of this Agreement.", body),
|
||||
Paragraph("<strong>6. Governing Law.</strong> This Agreement is governed by the laws of the jurisdiction in which Provider is incorporated.", body),
|
||||
Spacer(1, 24),
|
||||
Paragraph("<strong>SIGNATURES</strong>", body),
|
||||
Spacer(1, 24),
|
||||
Paragraph("Provider name: ____________________________", body),
|
||||
Paragraph("Provider signature: ________________________", body),
|
||||
Paragraph("Date: ____________________________________", body),
|
||||
Spacer(1, 18),
|
||||
Paragraph("Client name: ______________________________", body),
|
||||
Paragraph("Client signature: __________________________", body),
|
||||
Paragraph("Date: ____________________________________", body),
|
||||
]
|
||||
|
||||
SimpleDocTemplate(str(OUT), pagesize=LETTER).build(story)
|
||||
print(f"wrote {OUT} ({OUT.stat().st_size} bytes)")
|
||||
@ -0,0 +1,74 @@
|
||||
%PDF-1.4
|
||||
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||
1 0 obj
|
||||
<<
|
||||
/F1 2 0 R /F2 3 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
|
||||
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||
>> /Rotate 0 /Trans <<
|
||||
|
||||
>>
|
||||
/Type /Page
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<<
|
||||
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||
>>
|
||||
endobj
|
||||
6 0 obj
|
||||
<<
|
||||
/Author (\(anonymous\)) /CreationDate (D:20260507163553+01'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260507163553+01'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1105
|
||||
>>
|
||||
stream
|
||||
GatUr>Ar7U&;B$?.h;-Uc6?77=h\G(dSpMS3,'=Iqda)#9-\ep&t+iG^&Ia6b*U$_aK=b5!?R^dk;tIF'u5TcTRNaan0u2o])Y-Q,Y&Dk&X"<sV"NGFr?jj[JDPF)Zu!Fa>f;Dh[QZ#'*:=[f.Ilp\6niR__U.D##2XJeKT@BGqs3j?]6H/Yq!rM_`$g*6rbrg;E-)%!a,fahEf&i^P!AKqrLu8cI;=8c'@IgZ+oBY^*IZaD("BN/fPlQ.4)l2@[$)@Hge9S&8^_)'^=[nF(ghk(86XH+JYc?X"tGD!&Mdc$KeO9sViif/W)*kg+O5%#/^F`qKr'^tjT_eL+hJk[VusG-[@29:kC7r.]Z"i7]DcLh[-\KTeS<AIBdZHmX=dl%N:a^PI_+9c8_X,:hF>L^9>fTu9=rfhPEpq.Q3JC"V!"82nIBLr/.g*="07M\_/n$^Q<>k/7OHYJWI9P`99b^D6r>L$s%us&b$_<TX0/FLC[jaUIiCtkrF9=M3i!;#(\gkBdmp]Wi!V^'d4r(pmk?Op'R1552ctj$4'Y,g\^GZ`YXkRr*==e!=--t?nWA[)6*=2oQ?1"oD7P$An0hO`H4LtsFZ^qFb<qr9bqYn@9*6JUT%u-)#;]B.ZE2-?P*QJXdNqQFRPe6"fZm%J7;0;\lPJ=/(3ZD)>*=ui^;iB/)Os/5H+6j0kDZ3J;6X=I6H?5Z[E*PJqP'7OeCNR#@JjORm5>V[k":uWZBD-pb:,7SUSO7k["V5.-J*_!Ku1>?N\@X_Tac$B'PcT-ASfd]R7]Eu7tYmP],l]3Z<bRO3JK(3I,'ISp[)/1oRp&F,H;nhAumGh=(%HI7Yp%28ipMk]iHXr[d]\5`KUdak1UnWeK]!VAull]HOb/D_nDM&QEKFJ/5D3c)bfQ$X0He!0>q=HnGfFDRuGhM6^Ciu31[a"FZ*H5QDr:c9"PPZ%+^8qSA%kICV"HM,O5e`AO(V7E;t$Dr;@UrRL44==8@<P+8nUD@(5&0>`+^D_ESL%7Rc.iDq&qHTs0f"&C=O,oBPfq`SINFbkk>86h^e.&G<qJ(UUo*:A:Gb;)gOF$8tuCkrtUchE,3UcL(g=rrW=CpcS~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 9
|
||||
0000000000 65535 f
|
||||
0000000061 00000 n
|
||||
0000000102 00000 n
|
||||
0000000209 00000 n
|
||||
0000000321 00000 n
|
||||
0000000514 00000 n
|
||||
0000000582 00000 n
|
||||
0000000862 00000 n
|
||||
0000000921 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<502a944d1bd3e3265447450ba1cedc47><502a944d1bd3e3265447450ba1cedc47>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 6 0 R
|
||||
/Root 5 0 R
|
||||
/Size 9
|
||||
>>
|
||||
startxref
|
||||
2117
|
||||
%%EOF
|
||||
Loading…
Reference in new issue