diplomail (Stage E): LibreTranslate client + async translation worker
Synchronous translation on read (Stage D) blocks the HTTP handler on translator I/O. Stage E switches to "send moments-fast, deliver when translated": recipients whose preferred_language differs from the detected body_lang are inserted with available_at=NULL, and an async worker turns them on once a LibreTranslate call materialises the cache row (or fails terminally after 5 retries). Schema delta on diplomail_recipients: available_at, translation_attempts, next_translation_attempt_at, plus a snapshot recipient_preferred_language so the worker queries do not need a join. Read paths (ListInbox, GetMessage, UnreadCount) filter on available_at IS NOT NULL. Push fan-out is moved from Service to the worker so the recipient only sees the toast when the inbox row is actually visible. Translator backend is now a configurable choice: empty BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original); populated → LibreTranslate HTTP client. Per-attempt timeout, max attempts, and worker interval all live in DiplomailConfig. The HTTP client itself is unit-tested via httptest (happy path, BCP47 normalisation, unsupported pair, 5xx, identical src/dst, missing URL); worker delivery + fallback paths are covered by the testcontainers-backed e2e tests in diplomail_e2e_test.go. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,10 @@ const (
|
||||
|
||||
envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES"
|
||||
envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES"
|
||||
envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL"
|
||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||
|
||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||
@@ -168,6 +172,9 @@ const (
|
||||
|
||||
defaultDiplomailMaxBodyBytes = 4096
|
||||
defaultDiplomailMaxSubjectBytes = 256
|
||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||
defaultDiplomailTranslatorMaxAttempts = 5
|
||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||
|
||||
defaultDevSandboxEngineVersion = "0.1.0"
|
||||
defaultDevSandboxPlayerCount = 20
|
||||
@@ -418,6 +425,26 @@ type DiplomailConfig struct {
|
||||
// in bytes. Subjects are optional; the empty-string default
|
||||
// passes the limit trivially.
|
||||
MaxSubjectBytes int
|
||||
|
||||
// TranslatorURL is the base URL of the LibreTranslate-compatible
|
||||
// instance the async translation worker calls. When empty, the
|
||||
// worker still runs but falls through to "deliver original"
|
||||
// (the noop translator returns engine=noop).
|
||||
TranslatorURL string
|
||||
|
||||
// TranslatorTimeout bounds a single HTTP request to the
|
||||
// translator. Worker retries (exponential backoff up to
|
||||
// TranslatorMaxAttempts) layer on top.
|
||||
TranslatorTimeout time.Duration
|
||||
|
||||
// TranslatorMaxAttempts is the number of times the worker tries
|
||||
// to translate one (message, target_lang) pair before falling
|
||||
// back to delivering the original body.
|
||||
TranslatorMaxAttempts int
|
||||
|
||||
// WorkerInterval bounds how often the async translation worker
|
||||
// scans for pending pairs. The worker handles one pair per tick.
|
||||
WorkerInterval time.Duration
|
||||
}
|
||||
|
||||
// NotificationConfig configures the notification fan-out module
|
||||
@@ -520,6 +547,9 @@ func DefaultConfig() Config {
|
||||
Diplomail: DiplomailConfig{
|
||||
MaxBodyBytes: defaultDiplomailMaxBodyBytes,
|
||||
MaxSubjectBytes: defaultDiplomailMaxSubjectBytes,
|
||||
TranslatorTimeout: defaultDiplomailTranslatorTimeout,
|
||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||
},
|
||||
DevSandbox: DevSandboxConfig{
|
||||
EngineVersion: defaultDevSandboxEngineVersion,
|
||||
@@ -690,6 +720,16 @@ func LoadFromEnv() (Config, error) {
|
||||
if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL)
|
||||
if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||
@@ -894,6 +934,15 @@ func (c Config) Validate() error {
|
||||
if c.Diplomail.MaxSubjectBytes < 0 {
|
||||
return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes)
|
||||
}
|
||||
if c.Diplomail.TranslatorTimeout <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout)
|
||||
}
|
||||
if c.Diplomail.TranslatorMaxAttempts <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts)
|
||||
}
|
||||
if c.Diplomail.WorkerInterval <= 0 {
|
||||
return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval)
|
||||
}
|
||||
if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" {
|
||||
if _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err)
|
||||
|
||||
Reference in New Issue
Block a user