Files

107 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# App feedback → Feishu SMTP
For SafeLine owning public ports 80/443 and OpenResty in host mode on 10080/12443, follow [the Chinese SafeLine deployment guide](deployment-safeline-1panel.md) and `openresty-safeline-http.conf.example`. TLS terminates at SafeLine; its origin is OpenResty's HTTP port 10080.
Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.glowtps.cn/v1/feedback`. Server sender: `authors@glowtps.cn`; fixed receiver: `tps-david@glowtps.cn`. This directory contains deployment source; no connection to the server or live SMTP test has been performed.
## Contract
`POST /v1/feedback` uses `multipart/form-data` only. Each text field occurs once; repeated attachments use the field name `attachments`.
| Field | Problem feedback | Development request |
|---|---|---|
| `submission_id` | Required random identifier, 16–128 characters `[A-Za-z0-9_-]` (UUID v4 works). Keep unchanged for retries. | Same |
| `type` | `problem` | `development` |
| `message` | Required, nonblank, max 5000 Unicode characters | Must be omitted, even if empty |
| `contact` | Optional, max 200 characters | Required, nonblank, max 200 characters |
| `school` | Must be omitted | Required, nonblank, max 200 characters |
| `attachments` | Optional, max 3 files, 8 MiB each, 20 MiB aggregate | Must be omitted |
| `diagnostics` | Optional single UTF-8 text file, max 256 KiB, independent of media count; starts with `TPSchedule diagnostic log` followed by a newline | Must be omitted |
| `diagnostic_html` | Optional single UTF-8 inert HTML file for undergraduate or graduate imports, max 2 MiB, independent of media count; starts with `<!doctype html><!-- TPSchedule school diagnostic -->` | Must be omitted |
The app defaults diagnostics to off. When selected it snapshots the preceding ten minutes of structured app/login/import/reminder events, at most 500 entries. It excludes credentials, cookies, raw URLs, HTML, timetable contents and feedback text. The server attaches this file as `TPSchedule-diagnostics.txt`; retries must use the identical snapshot and submission ID. The server hashes it with the other attachments for idempotency, and does not log its contents.
To enable this field on an existing deployment, replace the service source and rebuild the same Compose service, keeping `.env`, the idempotency secret and database volume. Existing clients without diagnostics remain compatible. Do not alter the SafeLine/OpenResty routing for this update. See [日志附件更新说明](diagnostic-log-update.md).
Accepted MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`, `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/webm`. Send an explicit matching MIME type; `application/octet-stream` is rejected. Container signatures must match. MOV/MP4/M4V/HEIC require an initial valid `ftyp` box with a recognized compatible brand; legacy MOV without `ftyp` is rejected. Checks identify file formats; they do not certify an attachment is harmless or a complete playable file.
Filenames lose directory components, control characters and unsupported characters, and are bounded. Nothing is served as public media. All text goes into a plain-text mail body; sender/recipient and mail headers never come from submitted text.
Success is **HTTP 200** with exactly:
```json
{"success":true,"submission_id":"client-generated-id","status":"sent"}
```
This means the configured SMTP server acknowledged message acceptance with `250`, and that status was committed locally. It does not guarantee inbox delivery or reading. Same-ID/same-content retries return the recorded success without sending again. Fingerprints include exact field values, sanitized filenames, MIME types and attachment content in the original order. Retry with the same payload and ID; generate a new ID when editing a previously submitted payload.
All failures use non-2xx responses, with `success:false`, `error`, `detail` and (where applicable) `submission_id`/`status`:
| HTTP | Error/status | Action |
|---|---|---|
| 400 / 408 | Malformed body / upload timeout | Fix request or retry the same ID |
| 413 / 415 / 422 | Upload limits / unsupported media / invalid fields | Fix payload; no mail was attempted |
| 409 | `id_conflict` | This ID already belongs to other content; do not blindly resend |
| 409 | `in_progress` | Wait, then retry the identical ID and payload |
| 429 | `rate_limited` | Respect `Retry-After` |
| 503 | `server_busy` | Respect `Retry-After`; retain the ID |
| 503 | `smtp_unavailable`, status `retryable` | SMTP definitely did not accept mail; retry the same ID |
| 503 | `delivery_uncertain` or `storage_unavailable`, status `uncertain` | Stop automatic resubmission and ask the operator to check the mail by submission ID |
Proxy-generated errors can be HTML rather than this JSON; clients must still treat them as failures. No `202` or queue acceptance counts as success.
## Ubuntu / 1Panel deployment
1. Point the DNS A record for `app.glowtps.cn` to `43.138.57.216`. In Tencent Cloud's security group allow web ports 80/443; keep 18080 closed to the public. Use the existing 1Panel/OpenResty installation.
2. Upload **this directory's deployable files**, without `.deps`, test caches, local database files or any developer environment file, to a dedicated server directory such as `/opt/app-feedback`. In 1Panel's file manager or server terminal, copy `.env.example` to `.env`; set its permissions to `600`.
3. In Feishu Mail, enable third-party mail-client access for `authors@glowtps.cn` and generate the client authorization password. Put it only in `SMTP_PASSWORD` on the server. Use `SMTP_USERNAME=authors@glowtps.cn`, `MAIL_FROM=authors@glowtps.cn` and `MAIL_TO=tps-david@glowtps.cn`. Single-quote secret values in `.env` so `$` is literal in Compose. Do not paste passwords into chat, Flutter code, Dockerfiles, terminal command history or screenshots.
4. Generate `IDEMPOTENCY_SECRET` once on the server (for example `openssl rand -hex 32`), insert it into `.env` and keep it stable across updates. Changing this key while retaining the database makes existing fingerprints conflict. Replace both placeholder secrets before starting.
5. From the uploaded directory run `docker compose up -d --build` (or create a 1Panel Compose project from `docker-compose.yml` with this directory as its build context). The image runs as an unprivileged user, the filesystem is read-only except the named SQLite volume and temporary upload space, and the host publishes only `127.0.0.1:18080`.
6. Verify liveness locally with `curl --fail http://127.0.0.1:18080/healthz` and check `docker compose ps`. Health checks do **not** send mail or validate SMTP credentials. If startup fails, inspect container logs; the app validates required configuration without printing values. Do not share `docker compose config` or `docker inspect` output, because they can expose credentials.
7. In 1Panel, create/configure the `app.glowtps.cn` reverse-proxy website, obtain/select its HTTPS certificate and enable HTTPS redirection. Configure the upstream as `http://127.0.0.1:18080`. Add the location settings from `reverse-proxy.conf.example` inside the site's generated HTTPS server block, preserving its certificate paths. If the site already exists, add only the feedback and health locations rather than replacing other routes. In the generated config, avoid duplicate `location = /v1/feedback` blocks. Test OpenResty's configuration through 1Panel before reload.
8. Check that 1Panel's OpenResty has **host network access**. A proxy container on a bridge network has its own `127.0.0.1`; it cannot reach a localhost-only host publish. With that setup, configure a shared private Docker network and proxy to `feedback:8000`, remove the host port publish, and restrict proxy trust to that network instead of exposing 18080 publicly. The supplied example targets the host-access configuration; inspect the existing network mode before use.
9. Verify `https://app.glowtps.cn/healthz`. When explicitly ready to send a real message, submit one controlled feedback, confirm the response and receipt at `tps-david@glowtps.cn`, then retry the **same ID and payload** to verify only one mail arrives. That real-mail check remains to be done by the operator.
Feishu's official documentation specifies `smtp.feishu.cn` with SSL on 465, and its business-system guide documents STARTTLS on 587. The service supports only verified TLS: `SMTP_PORT=465` + `SMTP_TLS=ssl` (default), or `SMTP_PORT=587` + `SMTP_TLS=starttls`. It never falls back to plaintext or disables certificate checks. Outbound 465/587 access must be permitted by the server/network.
## Durable retries and retention
SQLite in the named `feedback-data` volume stores only submission ID, a keyed content fingerprint, state and timestamps. It stores no form text, contact address, school, filename or raw attachment. Uploaded data is spooled to bounded temporary files and deleted on request completion; in Docker, the temporary area is a tmpfs. The resulting email and attachments are retained by Feishu/recipient mailbox policies, independently of this database.
There is no atomic transaction across SMTP and SQLite. A lost `DATA` acknowledgement or crash after acceptance may mean mail arrived even though the HTTP request failed. Such records are quarantined as `uncertain`, preventing automatic resend. A `sending` record stays `in_progress` for five minutes; afterwards it becomes `uncertain`. The stable email Message-ID includes the submission ID, but recipient deduplication is not assumed.
Metadata expires after seven days since the last state change (`RETENTION_DAYS`). Cleanup runs at startup, on submissions, and hourly while the process runs. The retry deduplication guarantee is bounded by this retention window; reusing an ID after expiry can send again. Client automatic retries must stop within the retention window. Keep the volume and key across deployments, and apply an appropriate retention policy to any backups. Use `docker compose down` for a stop; **do not use `down -v`** when preserving retry records. Back up SQLite consistently while the service is stopped or using SQLite's backup API, including its WAL requirements.
To resolve an uncertain record, first check the recipient mailbox and provider delivery records for its submission ID. During a maintenance stop, use a trusted SQLite tool to inspect `submissions`. Change only that record's `status` to `sent` after verified acceptance, or to `retryable` only after verified non-acceptance, updating `updated` to the current Unix timestamp. Never automatically clear uncertain records, delete the volume or issue a new submission ID to force a retry.
## Limits and operations
The service counts raw HTTP bytes, including chunked input, with a 20 MiB + 256 KiB + 2 MiB + 64 KiB request ceiling; the proxy allows 24 MiB to leave the final precise check to the service. Text parts, field count and file count are bounded. Four active feedback requests are allowed by default, with a 60-second upload deadline. Default limits are ten requests/IP/hour and 100 requests globally/hour. These include invalid requests and retries. The in-memory rate counters reset after restart; SQLite deduplication survives. Proxy IP headers are overwritten; the private upstream must stay private.
The school HTML attachment is a separate explicit opt-in with a privacy dialog, not part of structured logs. Only the latest undergraduate import evidence is retained in memory for ten minutes and cleared on logout. Its known course statements are inert, attributes and unrelated scripts/forms are removed, and it carries a restrictive CSP. Failed delivery retries freeze the same bytes and submission ID. See [0.7.5 HTML附件更新](html-diagnostic-update.md) for existing 1Panel/SafeLine deployments.
Run exactly **one worker and one service replica** with this configuration: rate/concurrency counters are per-process. SQLite claims serialize concurrent IDs, but scaling needs a shared rate limiter and an appropriate storage design. The default 512 MiB memory and 256 MiB tmpfs budget account for multipart and MIME encoding of several uploads; if reducing these, also reduce concurrency. Large attachments can grow by roughly a third in email MIME encoding and may be rejected by provider message-size limits; a definite rejection returns a failure.
The app logs neither user fields nor SMTP responses/secrets; Uvicorn access logs are disabled. Feedback-path proxy access logs are also disabled in the example. Do not enable SMTP debug logging, request-body/WAF capture or form-content tracing on this endpoint. Allowlisted media are attached to plain-text mail only and never executed by the service. For higher abuse exposure, add server-side account verification/CAPTCHA through the app's existing identity system; no static secret embedded in an APK provides meaningful access control.
## Local verification (no real SMTP)
Requires Python 3.11+; the supplied image uses 3.12. Install dependencies into a virtual environment in this directory:
```sh
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements-dev.txt
python -m pytest -q
```
Tests inject a recording sender or scripted SMTP protocol object. They cover type-specific validation, file MIME/signatures, size/count and chunked-body limits, definite/uncertain SMTP outcomes, verified SSL/STARTTLS order, recipient fixation, persistence/restarts, conflicting IDs, concurrent retries, storage-commit failure, rate/concurrency limits, and metadata privacy. No SMTP connection, credentials or live mail is needed.
## References
- [Feishu: third-party mail client login](https://www.feishu.cn/hc/en-US/articles/902478147400-log-in-to-feishu-mail-through-a-third-party-email-client)
- [Feishu: SMTP migration / 465 SSL and 587 STARTTLS](https://www.feishu.cn/hc/en-US/articles/221905160046-update-the-smtp-server-settings-in-business-systems-during-the-email-migration-process)
- [1Panel: create websites and reverse proxies](https://docs.fit2cloud.com/1panel/user_manual/websites/website-create/)
- [Starlette: bounded multipart form parsing](https://starlette.dev/requests/)
- [Docker Compose: quoted environment values](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)