From 38639d251bd21050b7b57925c62a8bf16c226abc Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Sun, 12 Jul 2026 12:42:22 +0200 Subject: [PATCH 1/3] feat(email): add JMAP delivery transport --- .../content/docs/guides/deployment/binary.mdx | 2 +- .../docs/guides/deployment/docker-compose.mdx | 10 +- .../guides/deployment/read-this-first.mdx | 2 +- .../guides/operations/community-structure.mdx | 2 +- .../docs/guides/operations/identity-login.mdx | 37 +- .../docs/guides/operations/security.mdx | 1 + .../docs/reference/environment-variables.mdx | 36 +- cli/cmd/init.go | 3 + cli/cmd/init_test.go | 6 + cli/internal/config/config.go | 89 +++- cli/internal/config/config_test.go | 94 ++++ cli/internal/email/email.go | 16 +- cli/internal/email/jmap.go | 482 ++++++++++++++++++ cli/internal/email/jmap_test.go | 201 ++++++++ cli/internal/email/mock.go | 8 +- cli/internal/http_server/server.go | 4 +- cli/internal/http_server/test_endpoints.go | 4 +- .../http_server/test_endpoints_stub.go | 6 +- .../FDR-029-transactional-email-delivery.md | 40 ++ docs/fdr/INDEX.md | 1 + examples/dockercompose/.env.example | 11 +- examples/dockercompose/README.md | 6 +- 22 files changed, 1014 insertions(+), 47 deletions(-) create mode 100644 cli/internal/email/jmap.go create mode 100644 cli/internal/email/jmap_test.go create mode 100644 docs/fdr/FDR-029-transactional-email-delivery.md diff --git a/apps/docs-website/src/content/docs/guides/deployment/binary.mdx b/apps/docs-website/src/content/docs/guides/deployment/binary.mdx index 14a954fb6..7c627713d 100644 --- a/apps/docs-website/src/content/docs/guides/deployment/binary.mdx +++ b/apps/docs-website/src/content/docs/guides/deployment/binary.mdx @@ -40,7 +40,7 @@ Please refer to the [Quick Start](/getting-started/quick-start/) guide for detai For production use, please review the file and **at least**: -- configure SMTP credentials +- configure transactional email credentials (SMTP by default, or JMAP) - set `webserver.url` to your public URL - review `webserver.tls` if you want Chatto to terminate TLS diff --git a/apps/docs-website/src/content/docs/guides/deployment/docker-compose.mdx b/apps/docs-website/src/content/docs/guides/deployment/docker-compose.mdx index 72f5db1d6..c6b9bd67d 100644 --- a/apps/docs-website/src/content/docs/guides/deployment/docker-compose.mdx +++ b/apps/docs-website/src/content/docs/guides/deployment/docker-compose.mdx @@ -49,9 +49,9 @@ hostname. Replace `chat.example.com` with your Chatto domain and `admin@example.com` with the email address you will use for the first account. The script generates `.env` and `livekit.generated.yaml` with matching NATS, Chatto, and LiveKit secrets. -4. **Configure SMTP** +4. **Configure transactional email** - Edit `.env` and replace the `CHATTO_SMTP_*` placeholders with your provider settings. Direct email/password registration, email verification, and password reset require SMTP. + Edit `.env` and replace the `CHATTO_SMTP_*` placeholders with your provider settings. SMTP is the default transport. Direct email/password registration, email verification, and password reset require transactional email; you can instead configure JMAP as described in [Identity & Login](/guides/operations/identity-login/#transactional-email). The SMTP example uses STARTTLS submission on port `587`. For providers that require SMTPS on port `465`, set `CHATTO_SMTP_TLS=implicit`. @@ -218,9 +218,9 @@ rules, and do not expose NATS port 4222 publicly. ## Optional Features -### Direct Registration and SMTP +### Direct Registration and Transactional Email -The setup above enables direct email/password registration, which requires SMTP because Chatto sends registration codes by email. SMTP is also used for email verification and password reset. +The setup above enables direct email/password registration, which requires transactional email because Chatto sends registration codes by email. The provided `.env` uses SMTP, the default transport. You can instead configure JMAP as described in [Identity & Login](/guides/operations/identity-login/#transactional-email). Transactional email is also used for email verification and password reset. If you do not want public email/password registration after the first owner exists, set `CHATTO_AUTH_DIRECT_REGISTRATION=false`. Users can still sign in through configured SSO providers. @@ -306,7 +306,7 @@ See [Voice & Video Calls](/guides/infrastructure/voice-calls/#turn-for-restricti **Chatto can't connect to NATS** — Ensure `NATS_TOKEN` and `CHATTO_NATS_CLIENT_TOKEN` match in `.env`. -**Registration says email delivery is not configured** — Direct email/password registration requires SMTP. Set `CHATTO_SMTP_ENABLED=true` and configure `CHATTO_SMTP_HOST`, `CHATTO_SMTP_PORT`, `CHATTO_SMTP_TLS`, and `CHATTO_SMTP_FROM`. Use `CHATTO_SMTP_TLS=implicit` for SMTPS providers on port `465`. Add username and password when your SMTP server requires authentication. +**Registration says email delivery is not configured** — Direct email/password registration requires transactional email. For SMTP, set `CHATTO_SMTP_ENABLED=true` and configure `CHATTO_SMTP_HOST`, `CHATTO_SMTP_PORT`, `CHATTO_SMTP_TLS`, and `CHATTO_SMTP_FROM`. Use `CHATTO_SMTP_TLS=implicit` for SMTPS providers on port `465`. For JMAP, set `CHATTO_EMAIL_TRANSPORT=jmap`, `CHATTO_EMAIL_JMAP_SESSION_URL`, `CHATTO_EMAIL_JMAP_ACCESS_TOKEN`, and `CHATTO_EMAIL_JMAP_FROM`. **The first account is not an owner** — Make sure `CHATTO_OWNERS_EMAILS` contains the verified email address for that account. For an already-verified account, restart Chatto after updating the variable; Chatto applies matching owner roles on boot. diff --git a/apps/docs-website/src/content/docs/guides/deployment/read-this-first.mdx b/apps/docs-website/src/content/docs/guides/deployment/read-this-first.mdx index 9dedcd9bf..8262326f6 100644 --- a/apps/docs-website/src/content/docs/guides/deployment/read-this-first.mdx +++ b/apps/docs-website/src/content/docs/guides/deployment/read-this-first.mdx @@ -43,7 +43,7 @@ Chatto ships with full support for voice and video calls, including screen shari - **Data Storage**: make a decision on where NATS should store its data. Outside of uploaded file blobs, you're not going to need huge amounts of space. - **File Storage**: decide on whether to use NATS for storing file uploads, or wire up an S3 compatible object storage. - **Admin Bootstrap**: Chatto's configuration allows you to specify the email addresses of users who should _always_ be server owners. This is a failsafe to prevent you from accidentally locking yourself out of your own server. Please be aware that the user is not automatically created for you; you still have to create your account either through the usual signup flow, or using the `chatto operator` command. -- **SMTP**: Chatto will need to send emails for verifying email addresses and other planned features like email-based notification reminders. Please prepare a set of SMTP credentials to allow Chatto to send emails. (If you never want Chatto to verify emails or send reminders, you can skip this, but we strongly recommend that you don't.) +- **Transactional email**: Chatto needs to send email for address verification and other planned features such as email-based notification reminders. Prepare SMTP credentials (the default transport) or JMAP bearer-token credentials. If you never want Chatto to verify email addresses or send reminders, you can skip this, but we strongly recommend that you do not. ## Pick a Deployment Path diff --git a/apps/docs-website/src/content/docs/guides/operations/community-structure.mdx b/apps/docs-website/src/content/docs/guides/operations/community-structure.mdx index c825ca55c..6df0065b5 100644 --- a/apps/docs-website/src/content/docs/guides/operations/community-structure.mdx +++ b/apps/docs-website/src/content/docs/guides/operations/community-structure.mdx @@ -104,7 +104,7 @@ Use these fields deliberately: - + diff --git a/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx b/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx index d2bf9dd2c..08f475686 100644 --- a/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx +++ b/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx @@ -1,6 +1,6 @@ --- title: Identity & Login -description: Configure local accounts, SMTP email flows, external login providers, account linking, and owner recovery. +description: Configure local accounts, transactional email flows, external login providers, account linking, and owner recovery. --- import { Aside, CardGrid, LinkCard, Tabs, TabItem } from "@astrojs/starlight/components"; @@ -11,18 +11,18 @@ Chatto supports local password accounts, email verification and reset flows, ext | Model | Operator setup | | ----- | -------------- | -| Local accounts only | Configure SMTP if you want email verification and password reset. | +| Local accounts only | Configure transactional email if you want email verification and password reset. | | Invite or admin-managed server | Use local accounts plus operator-created users or restricted signup policy. | | Organization SSO | Configure one or more `[[auth.providers]]` entries. | | Mixed community | Allow local accounts and selected external providers. | | Passwordless external login | Ensure users keep at least one linked provider and understand recovery paths. | -## SMTP Email +## Transactional Email -SMTP is needed for verification and password reset email flows. Configure it before inviting members if you expect self-service account recovery. +Transactional email is needed for verification and password reset flows. SMTP is the default transport; use JMAP only when your provider offers JMAP submission with bearer-token authentication. Configure a transport before inviting members if you expect self-service account recovery. - + ```toml [smtp] @@ -36,7 +36,7 @@ from = "Chatto " ``` - + ```bash CHATTO_SMTP_ENABLED=true @@ -48,11 +48,36 @@ CHATTO_SMTP_PASSWORD= CHATTO_SMTP_FROM="Chatto " ``` + + + +```toml +[email] +transport = "jmap" + +[email.jmap] +session_url = "https://mail.example.com/.well-known/jmap" +access_token = "" +from = "Chatto " +``` + + + + +```bash +CHATTO_EMAIL_TRANSPORT=jmap +CHATTO_EMAIL_JMAP_SESSION_URL=https://mail.example.com/.well-known/jmap +CHATTO_EMAIL_JMAP_ACCESS_TOKEN= +CHATTO_EMAIL_JMAP_FROM="Chatto " +``` + Use `smtp.tls_server_name` only when the SMTP host you connect to differs from the certificate name. Use `smtp.tls_skip_verify` only for a trusted internal mail server where you accept that verification tradeoff. +With JMAP, Chatto discovers the primary submission account, a matching sender identity, and the Drafts mailbox. Set `email.jmap.account_id`, `email.jmap.identity_id`, or `email.jmap.draft_mailbox_id` only when automatic discovery is unsuitable. A successful submission means the JMAP server accepted the message; it does not confirm final recipient delivery. + ## External Providers External login providers are configured as repeated `[[auth.providers]]` entries. Supported provider types are `oidc`, `github`, `gitlab`, `google`, and `discord`. diff --git a/apps/docs-website/src/content/docs/guides/operations/security.mdx b/apps/docs-website/src/content/docs/guides/operations/security.mdx index 634261847..eef535568 100644 --- a/apps/docs-website/src/content/docs/guides/operations/security.mdx +++ b/apps/docs-website/src/content/docs/guides/operations/security.mdx @@ -211,6 +211,7 @@ For HSTS, CSP, and other policy headers, add them at your reverse proxy. | NATS embedded/client credentials | `chatto.toml` / `CHATTO_NATS_EMBEDDED_AUTH_TOKEN`, `CHATTO_NATS_CLIENT_TOKEN`, `CHATTO_NATS_CLIENT_PASSWORD`, `CHATTO_NATS_CLIENT_CREDENTIALS_FILE`, or `CHATTO_NATS_CLIENT_NKEY_SEED` | n/a | | OAuth/OIDC provider client secrets | `chatto.toml` `[[auth.providers]]` / `CHATTO_AUTH_PROVIDERS__CLIENT_SECRET` or legacy `CHATTO_AUTH_OIDC_CLIENT_SECRET` | n/a | | SMTP password | `chatto.toml` / `CHATTO_SMTP_PASSWORD` | n/a | +| JMAP access token | `chatto.toml` / `CHATTO_EMAIL_JMAP_ACCESS_TOKEN` | n/a | | Web Push VAPID key pair | `chatto.toml` / `CHATTO_PUSH_VAPID_PUBLIC_KEY`, `CHATTO_PUSH_VAPID_PRIVATE_KEY` | n/a | | LiveKit API and webhook secrets | `chatto.toml` / `CHATTO_LIVEKIT_API_KEY`, `CHATTO_LIVEKIT_API_SECRET`, `CHATTO_LIVEKIT_WEBHOOK_API_KEY`, `CHATTO_LIVEKIT_WEBHOOK_API_SECRET` | n/a | | TLS certificates | Disk cache directory (`autocert`) | n/a | diff --git a/apps/docs-website/src/content/docs/reference/environment-variables.mdx b/apps/docs-website/src/content/docs/reference/environment-variables.mdx index ec6b20f7a..51b9d930b 100644 --- a/apps/docs-website/src/content/docs/reference/environment-variables.mdx +++ b/apps/docs-website/src/content/docs/reference/environment-variables.mdx @@ -329,7 +329,41 @@ Set `auto_provision = true` only for providers whose users should be allowed to See [External Login Providers](/guides/integrations/external-login-providers/) for callback URLs, provider-specific examples, and discovery behavior. -## SMTP +## Transactional Email + +SMTP is the default transactional email transport. Set `email.transport = "jmap"` when your provider exposes JMAP submission and you want Chatto to authenticate with a bearer token instead. + + + Transactional email transport. Supported values are `smtp` and `jmap`. + + +### JMAP + + + JMAP session resource URL. Required when `email.transport` is `jmap`. + + + + Bearer access token for the JMAP account. Required when `email.transport` is `jmap`. + + + + From address for outgoing email. It must match a sending identity in the JMAP account. Required when `email.transport` is `jmap`. + + + + Optional JMAP account ID. By default, Chatto uses the session's primary submission account. + + + + Optional JMAP identity ID. By default, Chatto uses the identity matching `email.jmap.from`. + + + + Optional JMAP Drafts mailbox ID. By default, Chatto uses the mailbox with the `drafts` role. + + +### SMTP Enable SMTP email sending. diff --git a/cli/cmd/init.go b/cli/cmd/init.go index d6a40b7ac..2db359ee8 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -109,6 +109,9 @@ var initCmd = &cobra.Command{ Port: 587, TLS: config.SMTPTLSMandatory, }, + Email: config.EmailConfig{ + Transport: config.EmailTransportSMTP, + }, NATS: config.NATSConfig{ Replicas: 1, Client: config.NATSClientConfig{ diff --git a/cli/cmd/init_test.go b/cli/cmd/init_test.go index f95bfa060..5af5f85ed 100644 --- a/cli/cmd/init_test.go +++ b/cli/cmd/init_test.go @@ -61,6 +61,9 @@ func TestInitGeneratesCoreSecret(t *testing.T) { if cfg.SMTP.TLS != config.SMTPTLSMandatory { t.Fatalf("generated SMTP TLS policy = %q, want %q", cfg.SMTP.TLS, config.SMTPTLSMandatory) } + if cfg.Email.Transport != config.EmailTransportSMTP { + t.Fatalf("generated email transport = %q, want %q", cfg.Email.Transport, config.EmailTransportSMTP) + } raw, err := os.ReadFile(filepath.Join(tmpDir, "chatto.toml")) if err != nil { t.Fatalf("read generated raw config: %v", err) @@ -123,6 +126,9 @@ func TestInitGeneratesCoreSecret(t *testing.T) { if !strings.Contains(rawText, "\n[smtp]\n") { t.Fatal("generated config should include SMTP defaults") } + if !strings.Contains(rawText, "transport = 'smtp'") && !strings.Contains(rawText, "transport = \"smtp\"") { + t.Fatal("generated config should include the SMTP email transport default") + } if !strings.Contains(rawText, "\nport = 587\n") { t.Fatal("generated SMTP config should default to STARTTLS submission port 587") } diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 148cb9dce..5878fc70c 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "net" + "net/mail" "net/url" "os" "sort" @@ -733,6 +734,43 @@ type SMTPConfig struct { From string `toml:"from" env:"CHATTO_SMTP_FROM" comment:"From address for outgoing emails. Example: noreply@example.com"` } +// EmailTransport identifies the configured transactional email submission transport. +type EmailTransport string + +const ( + // EmailTransportSMTP submits transactional email through SMTP. + EmailTransportSMTP EmailTransport = "smtp" + // EmailTransportJMAP submits transactional email through JMAP. + EmailTransportJMAP EmailTransport = "jmap" +) + +// JMAPConfig contains settings for transactional email submitted through JMAP. +type JMAPConfig struct { + SessionURL string `toml:"session_url,commented" env:"CHATTO_EMAIL_JMAP_SESSION_URL" comment:"JMAP session resource URL. Must use HTTP or HTTPS."` + AccessToken string `toml:"access_token,commented" env:"CHATTO_EMAIL_JMAP_ACCESS_TOKEN" comment:"Bearer access token for the JMAP account. NEVER SHARE THIS!"` + From string `toml:"from,commented" env:"CHATTO_EMAIL_JMAP_FROM" comment:"From address for outgoing emails. It must match a JMAP identity. Example: noreply@example.com"` + AccountID string `toml:"account_id,commented" env:"CHATTO_EMAIL_JMAP_ACCOUNT_ID" comment:"Optional JMAP account ID. Defaults to the session's primary submission account."` + IdentityID string `toml:"identity_id,commented" env:"CHATTO_EMAIL_JMAP_IDENTITY_ID" comment:"Optional JMAP identity ID. Defaults to the identity matching jmap.from."` + DraftMailboxID string `toml:"draft_mailbox_id,commented" env:"CHATTO_EMAIL_JMAP_DRAFT_MAILBOX_ID" comment:"Optional JMAP Drafts mailbox ID. Defaults to the mailbox with role 'drafts'."` +} + +// EmailConfig contains transactional email transport settings. SMTP remains the +// default so existing configurations continue to work without changes. +type EmailConfig struct { + Transport EmailTransport `toml:"transport" env:"CHATTO_EMAIL_TRANSPORT" comment:"Transactional email transport: smtp (default) or jmap."` + JMAP JMAPConfig `toml:"jmap,commented" comment:"JMAP transactional email configuration. Used only when email.transport = 'jmap'."` +} + +// TransportOrDefault returns the selected transport, defaulting to SMTP for +// backward compatibility with existing SMTP-only configurations. +func (c EmailConfig) TransportOrDefault() EmailTransport { + transport := EmailTransport(strings.ToLower(strings.TrimSpace(string(c.Transport)))) + if transport == "" { + return EmailTransportSMTP + } + return transport +} + // PushConfig contains settings for Web Push notifications. // Push notifications allow messages to be delivered even when the browser is closed. type PushConfig struct { @@ -866,6 +904,7 @@ type ChattoConfig struct { Core CoreConfig `toml:"core" comment:"Core service configuration."` Auth AuthConfig `toml:"auth" comment:"Authentication configuration."` Limits LimitsConfig `toml:"limits,commented" comment:"Instance-wide resource limits. Use -1 for unlimited."` + Email EmailConfig `toml:"email" comment:"Transactional email transport configuration. SMTP is the default transport."` SMTP SMTPConfig `toml:"smtp" comment:"SMTP configuration for transactional emails."` Push PushConfig `toml:"push,commented" comment:"Web Push notification configuration."` Video VideoConfig `toml:"video,commented" comment:"Video processing configuration. Requires ffmpeg."` @@ -1096,25 +1135,47 @@ func (c *ChattoConfig) Validate() error { } } - // SMTP configuration - switch c.SMTP.TLSPolicyOrDefault() { - case SMTPTLSMandatory, SMTPTLSOpportunistic, SMTPTLSImplicit: - default: - errs = append(errs, "smtp.tls must be one of: mandatory, opportunistic, implicit") - } - if c.SMTP.Enabled { + // Transactional email configuration + switch c.Email.TransportOrDefault() { + case EmailTransportSMTP: + switch c.SMTP.TLSPolicyOrDefault() { + case SMTPTLSMandatory, SMTPTLSOpportunistic, SMTPTLSImplicit: + default: + errs = append(errs, "smtp.tls must be one of: mandatory, opportunistic, implicit") + } + if c.SMTP.Enabled { + if c.Webserver.URL == "" { + errs = append(errs, "webserver.url is required when SMTP is enabled") + } + if c.SMTP.Host == "" { + errs = append(errs, "smtp.host is required when SMTP is enabled") + } + if c.SMTP.Port < 1 || c.SMTP.Port > 65535 { + errs = append(errs, "smtp.port must be between 1 and 65535 when SMTP is enabled") + } + if c.SMTP.From == "" { + errs = append(errs, "smtp.from is required when SMTP is enabled") + } + } + case EmailTransportJMAP: if c.Webserver.URL == "" { - errs = append(errs, "webserver.url is required when SMTP is enabled") + errs = append(errs, "webserver.url is required when JMAP email is enabled") } - if c.SMTP.Host == "" { - errs = append(errs, "smtp.host is required when SMTP is enabled") + if c.Email.JMAP.SessionURL == "" { + errs = append(errs, "email.jmap.session_url is required when email.transport is jmap") + } else if err := validateAbsoluteHTTPURL("email.jmap.session_url", c.Email.JMAP.SessionURL); err != nil { + errs = append(errs, err.Error()) } - if c.SMTP.Port < 1 || c.SMTP.Port > 65535 { - errs = append(errs, "smtp.port must be between 1 and 65535 when SMTP is enabled") + if c.Email.JMAP.AccessToken == "" { + errs = append(errs, "email.jmap.access_token is required when email.transport is jmap") } - if c.SMTP.From == "" { - errs = append(errs, "smtp.from is required when SMTP is enabled") + if c.Email.JMAP.From == "" { + errs = append(errs, "email.jmap.from is required when email.transport is jmap") + } else if _, err := mail.ParseAddress(c.Email.JMAP.From); err != nil { + errs = append(errs, "email.jmap.from must be a valid email address") } + default: + errs = append(errs, "email.transport must be one of: smtp, jmap") } // Push notification configuration diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 2563bb22a..c3ca5d931 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -2427,6 +2427,100 @@ func TestChattoConfig_Validate_SMTP(t *testing.T) { } } +func TestChattoConfig_Validate_JMAPEmail(t *testing.T) { + baseConfig := func() ChattoConfig { + return ChattoConfig{ + Webserver: WebserverConfig{ + URL: "https://chat.example", + Port: 4000, + CookieSigningSecret: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + Core: CoreConfig{ + SecretKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + Assets: AssetsConfig{ + SigningSecret: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + }, + }, + Email: EmailConfig{ + Transport: EmailTransportJMAP, + JMAP: JMAPConfig{ + SessionURL: "https://mail.example/.well-known/jmap", + AccessToken: "token-1", + From: "Chatto ", + }, + }, + } + } + + tests := []struct { + name string + modify func(*ChattoConfig) + wantError string + }{ + {name: "valid JMAP transport"}, + { + name: "requires session URL", + modify: func(c *ChattoConfig) { c.Email.JMAP.SessionURL = "" }, + wantError: "email.jmap.session_url is required", + }, + { + name: "requires bearer token", + modify: func(c *ChattoConfig) { c.Email.JMAP.AccessToken = "" }, + wantError: "email.jmap.access_token is required", + }, + { + name: "requires valid sender address", + modify: func(c *ChattoConfig) { c.Email.JMAP.From = "not-an-email" }, + wantError: "email.jmap.from must be a valid email address", + }, + { + name: "rejects unknown transport", + modify: func(c *ChattoConfig) { c.Email.Transport = "carrier-pigeon" }, + wantError: "email.transport must be one of: smtp, jmap", + }, + { + name: "does not validate inactive SMTP settings", + modify: func(c *ChattoConfig) { + c.SMTP.TLS = "plaintext" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + if tt.modify != nil { + tt.modify(&cfg) + } + err := cfg.Validate() + if tt.wantError == "" { + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("Validate() error = %v, want %q", err, tt.wantError) + } + }) + } +} + +func TestEmailConfig_TransportOrDefault(t *testing.T) { + tests := []struct { + transport EmailTransport + want EmailTransport + }{ + {want: EmailTransportSMTP}, + {transport: " JMAP ", want: EmailTransportJMAP}, + } + for _, tt := range tests { + if got := (EmailConfig{Transport: tt.transport}).TransportOrDefault(); got != tt.want { + t.Errorf("TransportOrDefault() = %q, want %q", got, tt.want) + } + } +} + func TestChattoConfig_Validate_S3(t *testing.T) { baseConfig := func() ChattoConfig { return ChattoConfig{ diff --git a/cli/internal/email/email.go b/cli/internal/email/email.go index 926bf15be..4677d95a4 100644 --- a/cli/internal/email/email.go +++ b/cli/internal/email/email.go @@ -12,8 +12,11 @@ import ( "hmans.de/chatto/internal/config" ) -// ErrSMTPDisabled is returned when attempting to send email with SMTP disabled. -var ErrSMTPDisabled = errors.New("SMTP is not enabled") +// ErrEmailDisabled is returned when no transactional email transport is enabled. +var ErrEmailDisabled = errors.New("email delivery is not enabled") + +// ErrSMTPDisabled is retained for compatibility with SMTP sender callers. +var ErrSMTPDisabled = ErrEmailDisabled // Message represents an email message to be sent. type Message struct { @@ -30,6 +33,15 @@ type Sender interface { IsEnabled() bool } +// NewSender creates the configured transactional email transport. SMTP remains +// the default transport for configurations that predate EmailConfig. +func NewSender(emailConfig config.EmailConfig, smtpConfig config.SMTPConfig) Sender { + if emailConfig.TransportOrDefault() == config.EmailTransportJMAP { + return NewJMAPMailer(emailConfig.JMAP) + } + return NewMailer(smtpConfig) +} + // Mailer handles sending transactional emails via SMTP. type Mailer struct { config config.SMTPConfig diff --git a/cli/internal/email/jmap.go b/cli/internal/email/jmap.go new file mode 100644 index 000000000..a87149ead --- /dev/null +++ b/cli/internal/email/jmap.go @@ -0,0 +1,482 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/mail" + "strings" + "time" + + "hmans.de/chatto/internal/config" +) + +const ( + jmapCoreCapability = "urn:ietf:params:jmap:core" + jmapMailCapability = "urn:ietf:params:jmap:mail" + jmapSubmissionCapability = "urn:ietf:params:jmap:submission" + jmapRequestTimeout = 15 * time.Second + jmapResponseMaxBytes = 4 << 20 +) + +// JMAPMailer sends transactional email through the JMAP submission API. It +// creates a plaintext draft, submits it, and removes the draft after a +// successful submission. +type JMAPMailer struct { + config config.JMAPConfig + client *http.Client +} + +// Verify JMAPMailer implements Sender at compile time. +var _ Sender = (*JMAPMailer)(nil) + +// NewJMAPMailer creates a JMAP transactional email sender. +func NewJMAPMailer(cfg config.JMAPConfig) *JMAPMailer { + return newJMAPMailer(cfg, &http.Client{Timeout: jmapRequestTimeout}) +} + +func newJMAPMailer(cfg config.JMAPConfig, client *http.Client) *JMAPMailer { + return &JMAPMailer{config: cfg, client: client} +} + +// Send sends an email through JMAP. +func (m *JMAPMailer) Send(msg Message) error { + return m.SendContext(context.Background(), msg) +} + +// SendContext sends an email through JMAP with context support. A successful +// response means the JMAP server accepted the submission; it does not confirm +// final recipient delivery. +func (m *JMAPMailer) SendContext(ctx context.Context, msg Message) error { + if !m.IsEnabled() { + return ErrEmailDisabled + } + + from, err := mail.ParseAddress(m.config.From) + if err != nil { + return fmt.Errorf("invalid JMAP from address: %w", err) + } + to, err := mail.ParseAddress(msg.To) + if err != nil { + return fmt.Errorf("invalid JMAP recipient address: %w", err) + } + + session, err := m.getSession(ctx) + if err != nil { + return err + } + accountID, err := m.selectAccount(session) + if err != nil { + return err + } + identityID, draftMailboxID, err := m.resolveSubmissionResources(ctx, session.APIURL, accountID, from.Address) + if err != nil { + return err + } + + request := jmapRequest{ + Using: []string{jmapCoreCapability, jmapMailCapability, jmapSubmissionCapability}, + MethodCalls: []jmapMethodCall{ + { + Name: "Email/set", + Arguments: jmapEmailSetRequest{ + AccountID: accountID, + Create: map[string]jmapEmail{ + "email": { + MailboxIDs: map[string]bool{draftMailboxID: true}, + Keywords: map[string]bool{"$draft": true}, + From: []jmapAddress{{Name: from.Name, Email: from.Address}}, + To: []jmapAddress{{Name: to.Name, Email: to.Address}}, + Subject: msg.Subject, + BodyStructure: jmapBodyPart{ + PartID: "text", + Type: "text/plain", + }, + BodyValues: map[string]jmapBodyValue{ + "text": {Value: msg.Body}, + }, + }, + }, + }, + CallID: "create-email", + }, + { + Name: "EmailSubmission/set", + Arguments: jmapSubmissionSetRequest{ + AccountID: accountID, + Create: map[string]jmapSubmission{ + "submission": {IdentityID: identityID, EmailID: "#email"}, + }, + OnSuccessDestroyEmail: []string{"#submission"}, + }, + CallID: "submit-email", + }, + }, + } + + response, err := m.call(ctx, session.APIURL, request) + if err != nil { + return err + } + if err := response.requireCreated("Email/set", "create-email", "email"); err != nil { + return err + } + if err := response.requireCreated("EmailSubmission/set", "submit-email", "submission"); err != nil { + return err + } + + return nil +} + +// IsEnabled reports whether the JMAP sender has its required credentials and +// sender address. Startup validation provides the user-facing configuration +// errors; this guard keeps direct construction safe for tests and callers. +func (m *JMAPMailer) IsEnabled() bool { + return strings.TrimSpace(m.config.SessionURL) != "" && + strings.TrimSpace(m.config.AccessToken) != "" && + strings.TrimSpace(m.config.From) != "" +} + +func (m *JMAPMailer) getSession(ctx context.Context) (jmapSession, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.config.SessionURL, nil) + if err != nil { + return jmapSession{}, fmt.Errorf("create JMAP session request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+m.config.AccessToken) + + response, err := m.client.Do(req) + if err != nil { + return jmapSession{}, fmt.Errorf("request JMAP session: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return jmapSession{}, fmt.Errorf("JMAP session request returned HTTP %d", response.StatusCode) + } + + var session jmapSession + if err := json.NewDecoder(io.LimitReader(response.Body, jmapResponseMaxBytes)).Decode(&session); err != nil { + return jmapSession{}, fmt.Errorf("decode JMAP session response: %w", err) + } + if session.APIURL == "" { + return jmapSession{}, fmt.Errorf("JMAP session response has no API URL") + } + if !session.hasCapability(jmapCoreCapability) || !session.hasCapability(jmapMailCapability) || !session.hasCapability(jmapSubmissionCapability) { + return jmapSession{}, fmt.Errorf("JMAP server does not support mail submission") + } + return session, nil +} + +func (m *JMAPMailer) selectAccount(session jmapSession) (string, error) { + accountID := m.config.AccountID + if accountID == "" { + accountID = session.PrimaryAccounts[jmapSubmissionCapability] + } + if accountID == "" { + return "", fmt.Errorf("JMAP session has no primary submission account; set email.jmap.account_id") + } + account, ok := session.Accounts[accountID] + if !ok { + return "", fmt.Errorf("configured JMAP account is not available") + } + if !account.hasCapability(jmapMailCapability) || !account.hasCapability(jmapSubmissionCapability) { + return "", fmt.Errorf("configured JMAP account does not support mail submission") + } + return accountID, nil +} + +func (m *JMAPMailer) resolveSubmissionResources(ctx context.Context, apiURL, accountID, fromAddress string) (string, string, error) { + request := jmapRequest{ + Using: []string{jmapCoreCapability, jmapMailCapability, jmapSubmissionCapability}, + MethodCalls: []jmapMethodCall{ + {Name: "Identity/get", Arguments: jmapGetRequest{AccountID: accountID}, CallID: "identities"}, + {Name: "Mailbox/get", Arguments: jmapGetRequest{AccountID: accountID}, CallID: "mailboxes"}, + }, + } + response, err := m.call(ctx, apiURL, request) + if err != nil { + return "", "", err + } + + identities, err := response.identities("identities") + if err != nil { + return "", "", err + } + mailboxes, err := response.mailboxes("mailboxes") + if err != nil { + return "", "", err + } + + identityID, err := m.selectIdentity(identities, fromAddress) + if err != nil { + return "", "", err + } + draftMailboxID, err := m.selectDraftMailbox(mailboxes) + if err != nil { + return "", "", err + } + return identityID, draftMailboxID, nil +} + +func (m *JMAPMailer) selectIdentity(identities []jmapIdentity, fromAddress string) (string, error) { + if m.config.IdentityID != "" { + for _, identity := range identities { + if identity.ID == m.config.IdentityID { + return identity.ID, nil + } + } + return "", fmt.Errorf("configured JMAP identity is not available") + } + for _, identity := range identities { + if strings.EqualFold(identity.Email, fromAddress) { + return identity.ID, nil + } + } + return "", fmt.Errorf("JMAP account has no identity matching email.jmap.from; set email.jmap.identity_id") +} + +func (m *JMAPMailer) selectDraftMailbox(mailboxes []jmapMailbox) (string, error) { + if m.config.DraftMailboxID != "" { + for _, mailbox := range mailboxes { + if mailbox.ID == m.config.DraftMailboxID { + return mailbox.ID, nil + } + } + return "", fmt.Errorf("configured JMAP Drafts mailbox is not available") + } + for _, mailbox := range mailboxes { + if mailbox.Role == "drafts" { + return mailbox.ID, nil + } + } + return "", fmt.Errorf("JMAP account has no Drafts mailbox; set email.jmap.draft_mailbox_id") +} + +func (m *JMAPMailer) call(ctx context.Context, apiURL string, payload jmapRequest) (jmapResponse, error) { + body, err := json.Marshal(payload) + if err != nil { + return jmapResponse{}, fmt.Errorf("encode JMAP request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return jmapResponse{}, fmt.Errorf("create JMAP API request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+m.config.AccessToken) + + response, err := m.client.Do(req) + if err != nil { + return jmapResponse{}, fmt.Errorf("request JMAP API: %w", err) + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return jmapResponse{}, fmt.Errorf("JMAP API request returned HTTP %d", response.StatusCode) + } + + var decoded jmapResponse + if err := json.NewDecoder(io.LimitReader(response.Body, jmapResponseMaxBytes)).Decode(&decoded); err != nil { + return jmapResponse{}, fmt.Errorf("decode JMAP API response: %w", err) + } + return decoded, nil +} + +type jmapSession struct { + APIURL string `json:"apiUrl"` + Capabilities map[string]json.RawMessage `json:"capabilities"` + Accounts map[string]jmapAccount `json:"accounts"` + PrimaryAccounts map[string]string `json:"primaryAccounts"` +} + +func (s jmapSession) hasCapability(capability string) bool { + _, ok := s.Capabilities[capability] + return ok +} + +type jmapAccount struct { + AccountCapabilities map[string]json.RawMessage `json:"accountCapabilities"` +} + +func (a jmapAccount) hasCapability(capability string) bool { + _, ok := a.AccountCapabilities[capability] + return ok +} + +type jmapRequest struct { + Using []string `json:"using"` + MethodCalls []jmapMethodCall `json:"methodCalls"` +} + +type jmapMethodCall struct { + Name string + Arguments any + CallID string +} + +func (c jmapMethodCall) MarshalJSON() ([]byte, error) { + return json.Marshal([3]any{c.Name, c.Arguments, c.CallID}) +} + +type jmapGetRequest struct { + AccountID string `json:"accountId"` +} + +type jmapIdentity struct { + ID string `json:"id"` + Email string `json:"email"` +} + +type jmapMailbox struct { + ID string `json:"id"` + Role string `json:"role"` +} + +type jmapAddress struct { + Name string `json:"name,omitempty"` + Email string `json:"email"` +} + +type jmapBodyPart struct { + PartID string `json:"partId"` + Type string `json:"type"` +} + +type jmapBodyValue struct { + Value string `json:"value"` +} + +type jmapEmail struct { + MailboxIDs map[string]bool `json:"mailboxIds"` + Keywords map[string]bool `json:"keywords"` + From []jmapAddress `json:"from"` + To []jmapAddress `json:"to"` + Subject string `json:"subject"` + BodyStructure jmapBodyPart `json:"bodyStructure"` + BodyValues map[string]jmapBodyValue `json:"bodyValues"` +} + +type jmapEmailSetRequest struct { + AccountID string `json:"accountId"` + Create map[string]jmapEmail `json:"create"` +} + +type jmapSubmission struct { + IdentityID string `json:"identityId"` + EmailID string `json:"emailId"` +} + +type jmapSubmissionSetRequest struct { + AccountID string `json:"accountId"` + Create map[string]jmapSubmission `json:"create"` + OnSuccessDestroyEmail []string `json:"onSuccessDestroyEmail"` +} + +type jmapResponse struct { + MethodResponses [][]json.RawMessage `json:"methodResponses"` +} + +func (r jmapResponse) find(callID string) ([]json.RawMessage, error) { + for _, response := range r.MethodResponses { + if len(response) != 3 { + continue + } + responseCallID, err := jmapResponseString(response[2]) + if err != nil || responseCallID != callID { + continue + } + methodName, err := jmapResponseString(response[0]) + if err != nil { + return nil, fmt.Errorf("decode JMAP response method") + } + if methodName == "error" { + var errorResponse struct { + Type string `json:"type"` + } + if err := json.Unmarshal(response[1], &errorResponse); err != nil { + return nil, fmt.Errorf("decode JMAP error response") + } + if errorResponse.Type == "" { + return nil, fmt.Errorf("JMAP method %q failed", callID) + } + return nil, fmt.Errorf("JMAP method %q failed: %s", callID, errorResponse.Type) + } + return response, nil + } + return nil, fmt.Errorf("JMAP response did not include method %q", callID) +} + +func jmapResponseString(raw json.RawMessage) (string, error) { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + return value, nil +} + +func (r jmapResponse) identities(callID string) ([]jmapIdentity, error) { + response, err := r.find(callID) + if err != nil { + return nil, err + } + methodName, err := jmapResponseString(response[0]) + if err != nil || methodName != "Identity/get" { + return nil, fmt.Errorf("unexpected JMAP response for method %q", callID) + } + var payload struct { + List []jmapIdentity `json:"list"` + } + if err := json.Unmarshal(response[1], &payload); err != nil { + return nil, fmt.Errorf("decode JMAP identities response: %w", err) + } + return payload.List, nil +} + +func (r jmapResponse) mailboxes(callID string) ([]jmapMailbox, error) { + response, err := r.find(callID) + if err != nil { + return nil, err + } + methodName, err := jmapResponseString(response[0]) + if err != nil || methodName != "Mailbox/get" { + return nil, fmt.Errorf("unexpected JMAP response for method %q", callID) + } + var payload struct { + List []jmapMailbox `json:"list"` + } + if err := json.Unmarshal(response[1], &payload); err != nil { + return nil, fmt.Errorf("decode JMAP mailboxes response: %w", err) + } + return payload.List, nil +} + +func (r jmapResponse) requireCreated(methodName, callID, creationID string) error { + response, err := r.find(callID) + if err != nil { + return err + } + returnedMethodName, err := jmapResponseString(response[0]) + if err != nil || returnedMethodName != methodName { + return fmt.Errorf("unexpected JMAP response for method %q", callID) + } + var payload struct { + Created map[string]struct { + ID string `json:"id"` + } `json:"created"` + NotCreated map[string]struct { + Type string `json:"type"` + } `json:"notCreated"` + } + if err := json.Unmarshal(response[1], &payload); err != nil { + return fmt.Errorf("decode JMAP response for method %q: %w", callID, err) + } + if created, ok := payload.Created[creationID]; ok && created.ID != "" { + return nil + } + if failure, ok := payload.NotCreated[creationID]; ok && failure.Type != "" { + return fmt.Errorf("JMAP method %q failed: %s", callID, failure.Type) + } + return fmt.Errorf("JMAP method %q did not create %q", callID, creationID) +} diff --git a/cli/internal/email/jmap_test.go b/cli/internal/email/jmap_test.go new file mode 100644 index 000000000..1dae447a6 --- /dev/null +++ b/cli/internal/email/jmap_test.go @@ -0,0 +1,201 @@ +package email + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "hmans.de/chatto/internal/config" +) + +func TestJMAPMailer_SendContext(t *testing.T) { + var apiCalls int + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Errorf("Authorization = %q, want bearer token", got) + } + switch r.URL.Path { + case "/session": + if r.Method != http.MethodGet { + t.Errorf("session method = %s, want GET", r.Method) + } + writeJMAPJSON(t, w, map[string]any{ + "apiUrl": server.URL + "/api", + "capabilities": map[string]any{ + jmapCoreCapability: map[string]any{}, + jmapMailCapability: map[string]any{}, + jmapSubmissionCapability: map[string]any{}, + }, + "accounts": map[string]any{ + "account-1": map[string]any{"accountCapabilities": map[string]any{ + jmapMailCapability: map[string]any{}, + jmapSubmissionCapability: map[string]any{}, + }}, + }, + "primaryAccounts": map[string]string{jmapSubmissionCapability: "account-1"}, + }) + case "/api": + apiCalls++ + var payload struct { + MethodCalls [][]json.RawMessage `json:"methodCalls"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode JMAP request: %v", err) + } + switch apiCalls { + case 1: + if got := jmapCallName(t, payload.MethodCalls[0]); got != "Identity/get" { + t.Errorf("first method = %q, want Identity/get", got) + } + if got := jmapCallName(t, payload.MethodCalls[1]); got != "Mailbox/get" { + t.Errorf("second method = %q, want Mailbox/get", got) + } + writeJMAPJSON(t, w, map[string]any{"methodResponses": []any{ + []any{"Identity/get", map[string]any{"list": []any{map[string]any{"id": "identity-1", "email": "sender@example.com"}}}, "identities"}, + []any{"Mailbox/get", map[string]any{"list": []any{map[string]any{"id": "drafts-1", "role": "drafts"}}}, "mailboxes"}, + }}) + case 2: + if got := jmapCallName(t, payload.MethodCalls[0]); got != "Email/set" { + t.Errorf("first method = %q, want Email/set", got) + } + if got := jmapCallName(t, payload.MethodCalls[1]); got != "EmailSubmission/set" { + t.Errorf("second method = %q, want EmailSubmission/set", got) + } + assertJMAPSubmissionRequest(t, payload.MethodCalls) + writeJMAPJSON(t, w, map[string]any{"methodResponses": []any{ + []any{"Email/set", map[string]any{"created": map[string]any{"email": map[string]any{"id": "email-1"}}}, "create-email"}, + []any{"EmailSubmission/set", map[string]any{"created": map[string]any{"submission": map[string]any{"id": "submission-1"}}}, "submit-email"}, + []any{"Email/set", map[string]any{"destroyed": []string{"email-1"}}, "submit-email"}, + }}) + default: + t.Errorf("unexpected JMAP API request %d", apiCalls) + } + default: + t.Errorf("unexpected JMAP request path %q", r.URL.Path) + } + })) + defer server.Close() + + mailer := newJMAPMailer(config.JMAPConfig{ + SessionURL: server.URL + "/session", + AccessToken: "token-1", + From: "Chatto ", + }, server.Client()) + + err := mailer.SendContext(context.Background(), Message{ + To: "Recipient ", + Subject: "Verification code", + Body: "123456", + }) + if err != nil { + t.Fatalf("SendContext() error = %v", err) + } + if apiCalls != 2 { + t.Fatalf("JMAP API calls = %d, want 2", apiCalls) + } +} + +func TestJMAPMailer_SendContextRejectsUnsupportedServer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJMAPJSON(t, w, map[string]any{ + "apiUrl": serverURLPlaceholder + "/api", + "capabilities": map[string]any{ + jmapCoreCapability: map[string]any{}, + jmapMailCapability: map[string]any{}, + }, + }) + })) + defer server.Close() + + mailer := newJMAPMailer(config.JMAPConfig{ + SessionURL: server.URL, + AccessToken: "token-1", + From: "sender@example.com", + }, server.Client()) + err := mailer.Send(Message{To: "recipient@example.com"}) + if err == nil || !strings.Contains(err.Error(), "does not support mail submission") { + t.Fatalf("Send() error = %v, want unsupported submission error", err) + } +} + +func TestJMAPMailer_IsEnabled(t *testing.T) { + mailer := NewJMAPMailer(config.JMAPConfig{}) + if mailer.IsEnabled() { + t.Fatal("IsEnabled() = true, want false for incomplete configuration") + } + if err := mailer.Send(Message{}); err != ErrEmailDisabled { + t.Fatalf("Send() error = %v, want ErrEmailDisabled", err) + } +} + +const serverURLPlaceholder = "http://jmap.test" + +func writeJMAPJSON(t *testing.T, w http.ResponseWriter, body any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(body); err != nil { + t.Fatalf("encode JMAP response: %v", err) + } +} + +func jmapCallName(t *testing.T, call []json.RawMessage) string { + t.Helper() + if len(call) != 3 { + t.Fatalf("JMAP method call has %d fields, want 3", len(call)) + } + var name string + if err := json.Unmarshal(call[0], &name); err != nil { + t.Fatalf("decode JMAP method name: %v", err) + } + return name +} + +func assertJMAPSubmissionRequest(t *testing.T, calls [][]json.RawMessage) { + t.Helper() + var createEmail struct { + AccountID string `json:"accountId"` + Create map[string]struct { + MailboxIDs map[string]bool `json:"mailboxIds"` + Keywords map[string]bool `json:"keywords"` + From []jmapAddress `json:"from"` + To []jmapAddress `json:"to"` + Subject string `json:"subject"` + BodyValues map[string]jmapBodyValue `json:"bodyValues"` + } `json:"create"` + } + if err := json.Unmarshal(calls[0][1], &createEmail); err != nil { + t.Fatalf("decode Email/set request: %v", err) + } + message := createEmail.Create["email"] + if createEmail.AccountID != "account-1" || !message.MailboxIDs["drafts-1"] || !message.Keywords["$draft"] { + t.Fatalf("Email/set did not create a draft in the configured account: %#v", createEmail) + } + if got := message.From[0].Email; got != "sender@example.com" { + t.Errorf("from = %q, want sender@example.com", got) + } + if got := message.To[0].Email; got != "recipient@example.com" { + t.Errorf("to = %q, want recipient@example.com", got) + } + if message.Subject != "Verification code" || message.BodyValues["text"].Value != "123456" { + t.Errorf("unexpected email content: %#v", message) + } + + var submit struct { + AccountID string `json:"accountId"` + Create map[string]jmapSubmission `json:"create"` + OnSuccessDestroyEmail []string `json:"onSuccessDestroyEmail"` + } + if err := json.Unmarshal(calls[1][1], &submit); err != nil { + t.Fatalf("decode EmailSubmission/set request: %v", err) + } + if submit.AccountID != "account-1" || submit.Create["submission"].IdentityID != "identity-1" || submit.Create["submission"].EmailID != "#email" { + t.Errorf("unexpected EmailSubmission/set payload: %#v", submit) + } + if len(submit.OnSuccessDestroyEmail) != 1 || submit.OnSuccessDestroyEmail[0] != "#submission" { + t.Errorf("onSuccessDestroyEmail = %#v, want [#submission]", submit.OnSuccessDestroyEmail) + } +} diff --git a/cli/internal/email/mock.go b/cli/internal/email/mock.go index c53a1ea5f..bd3d21125 100644 --- a/cli/internal/email/mock.go +++ b/cli/internal/email/mock.go @@ -16,20 +16,20 @@ type MockSender struct { // NewMockSender creates a new MockSender. // If enabled is true, IsEnabled() returns true and Send() captures messages. -// If enabled is false, IsEnabled() returns false and Send() returns ErrSMTPDisabled. +// If enabled is false, IsEnabled() returns false and Send() returns ErrEmailDisabled. func NewMockSender(enabled bool) *MockSender { return &MockSender{enabled: enabled} } -// Send captures the message if enabled, or returns ErrSMTPDisabled. +// Send captures the message if enabled, or returns ErrEmailDisabled. func (m *MockSender) Send(msg Message) error { return m.SendContext(context.Background(), msg) } -// SendContext captures the message if enabled, or returns ErrSMTPDisabled. +// SendContext captures the message if enabled, or returns ErrEmailDisabled. func (m *MockSender) SendContext(_ context.Context, msg Message) error { if !m.enabled { - return ErrSMTPDisabled + return ErrEmailDisabled } if m.SendError != nil { return m.SendError diff --git a/cli/internal/http_server/server.go b/cli/internal/http_server/server.go index 415a29820..8380a2187 100644 --- a/cli/internal/http_server/server.go +++ b/cli/internal/http_server/server.go @@ -99,8 +99,8 @@ func limitRequestBody(maxBytes int64, readTimeout time.Duration) gin.HandlerFunc func NewHTTPServer(cfg HTTPServerConfig) (*HTTPServer, error) { logger := log.WithPrefix("server.HTTP") - // Create email mailer (mock if built with -tags test_endpoints, real otherwise) - mockMailer, mailer := createMailer(cfg.Config.SMTP) + // Create the configured email sender (mock if built with -tags test_endpoints). + mockMailer, mailer := createMailer(cfg.Config.Email, cfg.Config.SMTP) // Warn at startup if test endpoints are enabled (security-bypassing endpoints) if mockMailer != nil { diff --git a/cli/internal/http_server/test_endpoints.go b/cli/internal/http_server/test_endpoints.go index 5a1dd4540..2b5139309 100644 --- a/cli/internal/http_server/test_endpoints.go +++ b/cli/internal/http_server/test_endpoints.go @@ -15,8 +15,8 @@ import ( // createMailer creates a mock mailer when test endpoints are enabled. // Returns (mockMailer, mailer) where mailer is used for sending emails. -// The smtpConfig parameter is ignored when test endpoints are enabled. -func createMailer(_ config.SMTPConfig) (*email.MockSender, email.Sender) { +// The configured transport is ignored when test endpoints are enabled. +func createMailer(_ config.EmailConfig, _ config.SMTPConfig) (*email.MockSender, email.Sender) { mock := email.NewMockSender(true) log.Info("Test endpoints enabled - using mock email sender") return mock, mock diff --git a/cli/internal/http_server/test_endpoints_stub.go b/cli/internal/http_server/test_endpoints_stub.go index 95b1ea990..83cae6122 100644 --- a/cli/internal/http_server/test_endpoints_stub.go +++ b/cli/internal/http_server/test_endpoints_stub.go @@ -8,10 +8,10 @@ import ( "hmans.de/chatto/internal/email" ) -// createMailer creates a real email mailer for production builds. +// createMailer creates the configured transactional email sender for production builds. // Returns (nil, mailer) since mock mailer is not used in production. -func createMailer(smtpConfig config.SMTPConfig) (*email.MockSender, email.Sender) { - return nil, email.NewMailer(smtpConfig) +func createMailer(emailConfig config.EmailConfig, smtpConfig config.SMTPConfig) (*email.MockSender, email.Sender) { + return nil, email.NewSender(emailConfig, smtpConfig) } // registerTestEndpoints is a no-op in production builds. diff --git a/docs/fdr/FDR-029-transactional-email-delivery.md b/docs/fdr/FDR-029-transactional-email-delivery.md new file mode 100644 index 000000000..64fa4586f --- /dev/null +++ b/docs/fdr/FDR-029-transactional-email-delivery.md @@ -0,0 +1,40 @@ +# FDR-029: Transactional Email Delivery + +**Status:** Active +**Last reviewed:** 2026-07-12 + +## Overview + +Chatto sends transactional email for account registration, email-address verification, and password resets. Server operators choose either SMTP or JMAP submission; SMTP remains the default for existing and new deployments. + +## Behavior + +- Local-account registration, email-address verification, and password-reset flows use the selected transactional email transport without changing their user-facing workflow. +- Existing SMTP configuration continues to work unchanged. Operators select JMAP explicitly and configure a JMAP session URL, bearer token, and sender address. +- JMAP uses an available sending identity matching the configured sender and a Drafts mailbox. Operators can explicitly select the account, identity, or Drafts mailbox when automatic selection is unsuitable. +- A successful JMAP request means the JMAP server accepted the submission. Chatto does not claim final delivery to every recipient. + +## Design Decisions + +### 1. SMTP remains the default transport + +**Decision:** SMTP is the default; JMAP is an explicit alternative. +**Why:** SMTP is the broadly supported self-hosting integration and existing deployments already rely on it. JMAP support serves providers that expose bearer-token submission without replacing the Internet mail-delivery path. +**Tradeoff:** Chatto maintains two submission clients and operators must select JMAP deliberately. + +### 2. JMAP is submission-only + +**Decision:** JMAP support creates and submits a plain-text transactional message, then removes the temporary draft after successful submission. It does not synchronize a mailbox or track final delivery status. +**Why:** Chatto needs outbound account-flow messages, not a general-purpose mail client. Keeping the integration narrow avoids mailbox state and user-data concerns while meeting the transactional use case. +**Tradeoff:** Operators cannot use Chatto to inspect sent mail or final per-recipient delivery results. + +### 3. Use a bearer token rather than a mailbox password + +**Decision:** JMAP configuration uses a bearer access token. +**Why:** Token-based credentials can be scoped and revoked by the mail provider without storing a reusable mailbox password in Chatto configuration. +**Tradeoff:** Provider-specific token issuance and refresh lifecycle remain an operator responsibility; Chatto does not perform an OAuth authorization flow or refresh tokens. + +## Related + +- **ADRs:** None +- **FDRs:** FDR-018 (Account Lifecycle), FDR-023 (Authentication & Sessions) diff --git a/docs/fdr/INDEX.md b/docs/fdr/INDEX.md index 34ae42554..3f49f3b43 100644 --- a/docs/fdr/INDEX.md +++ b/docs/fdr/INDEX.md @@ -38,3 +38,4 @@ See [`.agents/skills/fdr/SKILL.md`](../../.agents/skills/fdr/SKILL.md) for the F | [FDR-026](FDR-026-last-room-memory.md) | Last-Room Memory | Active | 2026-05-19 | | [FDR-027](FDR-027-pwa-shell-and-service-worker.md) | PWA Shell & Service Worker | Active | 2026-07-05 | | [FDR-028](FDR-028-operator-api-and-cli.md) | Operator API & CLI | Active | 2026-06-29 | +| [FDR-029](FDR-029-transactional-email-delivery.md) | Transactional Email Delivery | Active | 2026-07-12 | diff --git a/examples/dockercompose/.env.example b/examples/dockercompose/.env.example index fb495d32e..bebdf7a9e 100644 --- a/examples/dockercompose/.env.example +++ b/examples/dockercompose/.env.example @@ -43,8 +43,8 @@ CHATTO_LIVEKIT_URL=wss://livekit.chat.example.com CHATTO_LIVEKIT_API_KEY=your-api-key CHATTO_LIVEKIT_API_SECRET=replace-with-at-least-32-character-livekit-secret -# SMTP configuration (required for direct email/password registration, -# email verification, and password reset) +# SMTP configuration (the default transactional email transport, required for +# direct email/password registration, email verification, and password reset) CHATTO_SMTP_ENABLED=true CHATTO_SMTP_HOST=smtp.example.com # Use port 587 with mandatory STARTTLS, or port 465 with implicit TLS/SMTPS. @@ -58,6 +58,13 @@ CHATTO_SMTP_USERNAME=your-smtp-username CHATTO_SMTP_PASSWORD=your-smtp-password CHATTO_SMTP_FROM=noreply@example.com +# JMAP is an alternative transactional email transport. Uncomment these and set +# CHATTO_EMAIL_TRANSPORT=jmap to use a provider's JMAP submission API instead. +# CHATTO_EMAIL_TRANSPORT=jmap +# CHATTO_EMAIL_JMAP_SESSION_URL=https://mail.example.com/.well-known/jmap +# CHATTO_EMAIL_JMAP_ACCESS_TOKEN= +# CHATTO_EMAIL_JMAP_FROM="Chatto " + # Push notifications (optional, for notifications when browser is closed) # Generate VAPID keys with: npx web-push generate-vapid-keys # CHATTO_PUSH_ENABLED=true diff --git a/examples/dockercompose/README.md b/examples/dockercompose/README.md index ae990fc0e..d33c01115 100644 --- a/examples/dockercompose/README.md +++ b/examples/dockercompose/README.md @@ -124,14 +124,14 @@ NATS communicate only over the private Compose network. - `PUBLIC_URL` - Your domain (e.g., `chat.example.com`) - `CHATTO_OWNERS_EMAILS` - Comma-separated verified email addresses that should become Chatto owners. Include the email address you will use for the first account. - - `CHATTO_SMTP_*` - Required for direct email/password registration, email verification, and password reset. + - `CHATTO_SMTP_*` - Default transactional email transport for direct email/password registration, email verification, and password reset. Use `CHATTO_EMAIL_*` instead when configuring JMAP. - `PUID` and `PGID` - Optional host user/group IDs for files Chatto writes to mounted volumes. Defaults to `1000:1000`. - `CHATTO_OPERATOR_API_*` - Enables the private in-container operator socket used by `chatto operator ...`. Leave `LIVEKIT_CONFIG_FILE=./livekit.generated.yaml` unless you deliberately want to maintain `livekit.yaml` by hand. -3. Configure SMTP settings if you use direct email/password registration. +3. Configure transactional email settings if you use direct email/password registration. SMTP is the default; JMAP is an alternative. ### Prefer to configure it by hand? @@ -245,7 +245,7 @@ If you don't need calls, remove the `livekit` service from `compose.yml`, delete **Chatto can't connect to NATS**: Ensure `NATS_TOKEN` and `CHATTO_NATS_CLIENT_TOKEN` match in your `.env` file. -**Registration says email delivery is not configured**: Configure the `CHATTO_SMTP_*` settings in `.env`. Direct email/password registration sends a code by email. +**Registration says email delivery is not configured**: Configure the SMTP `CHATTO_SMTP_*` settings or set `CHATTO_EMAIL_TRANSPORT=jmap` with the required `CHATTO_EMAIL_JMAP_*` values in `.env`. Direct email/password registration sends a code by email. **The first account is not an owner**: Ensure `CHATTO_OWNERS_EMAILS` contains that account's verified email address. Chatto assigns matching owner roles when the email is verified and on server boot. From 9e2cec9b67daddf7f9d4e5ef9e93e5fca72ab5df Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Sun, 12 Jul 2026 12:48:55 +0200 Subject: [PATCH 2/3] fix(email): secure JMAP submission --- .../docs/reference/environment-variables.mdx | 2 +- cli/internal/config/config.go | 18 +++- cli/internal/config/config_test.go | 5 + cli/internal/email/jmap.go | 99 ++++++++++++++++--- cli/internal/email/jmap_test.go | 78 ++++++++++++++- .../FDR-029-transactional-email-delivery.md | 2 +- 6 files changed, 180 insertions(+), 24 deletions(-) diff --git a/apps/docs-website/src/content/docs/reference/environment-variables.mdx b/apps/docs-website/src/content/docs/reference/environment-variables.mdx index 51b9d930b..b0d76c48e 100644 --- a/apps/docs-website/src/content/docs/reference/environment-variables.mdx +++ b/apps/docs-website/src/content/docs/reference/environment-variables.mdx @@ -340,7 +340,7 @@ SMTP is the default transactional email transport. Set `email.transport = "jmap" ### JMAP - JMAP session resource URL. Required when `email.transport` is `jmap`. + HTTPS JMAP session resource URL. Required when `email.transport` is `jmap`. diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 5878fc70c..5a1e98702 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -220,6 +220,20 @@ func validateAbsoluteHTTPURL(name, raw string) error { return nil } +func validateAbsoluteHTTPSURL(name, raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("%s is invalid: %w", name, err) + } + if u.Scheme != "https" { + return fmt.Errorf("%s must use https", name) + } + if u.Host == "" || u.User != nil { + return fmt.Errorf("%s must include a host and must not include user info", name) + } + return nil +} + func validateOrigin(name, raw string, allowWildcard bool, requireHTTPSExceptLoopback bool) error { raw = strings.TrimSpace(raw) if allowWildcard && raw == "*" { @@ -746,7 +760,7 @@ const ( // JMAPConfig contains settings for transactional email submitted through JMAP. type JMAPConfig struct { - SessionURL string `toml:"session_url,commented" env:"CHATTO_EMAIL_JMAP_SESSION_URL" comment:"JMAP session resource URL. Must use HTTP or HTTPS."` + SessionURL string `toml:"session_url,commented" env:"CHATTO_EMAIL_JMAP_SESSION_URL" comment:"JMAP session resource URL. Must use HTTPS."` AccessToken string `toml:"access_token,commented" env:"CHATTO_EMAIL_JMAP_ACCESS_TOKEN" comment:"Bearer access token for the JMAP account. NEVER SHARE THIS!"` From string `toml:"from,commented" env:"CHATTO_EMAIL_JMAP_FROM" comment:"From address for outgoing emails. It must match a JMAP identity. Example: noreply@example.com"` AccountID string `toml:"account_id,commented" env:"CHATTO_EMAIL_JMAP_ACCOUNT_ID" comment:"Optional JMAP account ID. Defaults to the session's primary submission account."` @@ -1163,7 +1177,7 @@ func (c *ChattoConfig) Validate() error { } if c.Email.JMAP.SessionURL == "" { errs = append(errs, "email.jmap.session_url is required when email.transport is jmap") - } else if err := validateAbsoluteHTTPURL("email.jmap.session_url", c.Email.JMAP.SessionURL); err != nil { + } else if err := validateAbsoluteHTTPSURL("email.jmap.session_url", c.Email.JMAP.SessionURL); err != nil { errs = append(errs, err.Error()) } if c.Email.JMAP.AccessToken == "" { diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index c3ca5d931..cc05eb3f9 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -2468,6 +2468,11 @@ func TestChattoConfig_Validate_JMAPEmail(t *testing.T) { modify: func(c *ChattoConfig) { c.Email.JMAP.AccessToken = "" }, wantError: "email.jmap.access_token is required", }, + { + name: "requires HTTPS session URL", + modify: func(c *ChattoConfig) { c.Email.JMAP.SessionURL = "http://mail.example/.well-known/jmap" }, + wantError: "email.jmap.session_url must use https", + }, { name: "requires valid sender address", modify: func(c *ChattoConfig) { c.Email.JMAP.From = "not-an-email" }, diff --git a/cli/internal/email/jmap.go b/cli/internal/email/jmap.go index a87149ead..a7ea7c923 100644 --- a/cli/internal/email/jmap.go +++ b/cli/internal/email/jmap.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/mail" + "net/url" "strings" "time" @@ -35,7 +36,12 @@ var _ Sender = (*JMAPMailer)(nil) // NewJMAPMailer creates a JMAP transactional email sender. func NewJMAPMailer(cfg config.JMAPConfig) *JMAPMailer { - return newJMAPMailer(cfg, &http.Client{Timeout: jmapRequestTimeout}) + return newJMAPMailer(cfg, &http.Client{ + Timeout: jmapRequestTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }) } func newJMAPMailer(cfg config.JMAPConfig, client *http.Client) *JMAPMailer { @@ -121,10 +127,14 @@ func (m *JMAPMailer) SendContext(ctx context.Context, msg Message) error { if err != nil { return err } - if err := response.requireCreated("Email/set", "create-email", "email"); err != nil { + emailID, err := response.createdID("Email/set", "create-email", "email") + if err != nil { return err } - if err := response.requireCreated("EmailSubmission/set", "submit-email", "submission"); err != nil { + if _, err := response.createdID("EmailSubmission/set", "submit-email", "submission"); err != nil { + return err + } + if err := response.requireDestroyedEmail("submit-email", emailID); err != nil { return err } @@ -141,6 +151,9 @@ func (m *JMAPMailer) IsEnabled() bool { } func (m *JMAPMailer) getSession(ctx context.Context) (jmapSession, error) { + if err := validateJMAPHTTPSURL("JMAP session URL", m.config.SessionURL); err != nil { + return jmapSession{}, err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.config.SessionURL, nil) if err != nil { return jmapSession{}, fmt.Errorf("create JMAP session request: %w", err) @@ -164,6 +177,9 @@ func (m *JMAPMailer) getSession(ctx context.Context) (jmapSession, error) { if session.APIURL == "" { return jmapSession{}, fmt.Errorf("JMAP session response has no API URL") } + if err := validateJMAPHTTPSURL("JMAP session API URL", session.APIURL); err != nil { + return jmapSession{}, err + } if !session.hasCapability(jmapCoreCapability) || !session.hasCapability(jmapMailCapability) || !session.hasCapability(jmapSubmissionCapability) { return jmapSession{}, fmt.Errorf("JMAP server does not support mail submission") } @@ -235,9 +251,22 @@ func (m *JMAPMailer) selectIdentity(identities []jmapIdentity, fromAddress strin return identity.ID, nil } } + for _, identity := range identities { + if jmapIdentityMatches(identity.Email, fromAddress) { + return identity.ID, nil + } + } return "", fmt.Errorf("JMAP account has no identity matching email.jmap.from; set email.jmap.identity_id") } +func jmapIdentityMatches(identityAddress, fromAddress string) bool { + if !strings.HasPrefix(identityAddress, "*@") { + return false + } + _, fromDomain, found := strings.Cut(fromAddress, "@") + return found && strings.EqualFold(strings.TrimPrefix(identityAddress, "*@"), fromDomain) +} + func (m *JMAPMailer) selectDraftMailbox(mailboxes []jmapMailbox) (string, error) { if m.config.DraftMailboxID != "" { for _, mailbox := range mailboxes { @@ -256,6 +285,9 @@ func (m *JMAPMailer) selectDraftMailbox(mailboxes []jmapMailbox) (string, error) } func (m *JMAPMailer) call(ctx context.Context, apiURL string, payload jmapRequest) (jmapResponse, error) { + if err := validateJMAPHTTPSURL("JMAP API URL", apiURL); err != nil { + return jmapResponse{}, err + } body, err := json.Marshal(payload) if err != nil { return jmapResponse{}, fmt.Errorf("encode JMAP request: %w", err) @@ -284,6 +316,14 @@ func (m *JMAPMailer) call(ctx context.Context, apiURL string, payload jmapReques return decoded, nil } +func validateJMAPHTTPSURL(name, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return fmt.Errorf("%s must be an absolute HTTPS URL", name) + } + return nil +} + type jmapSession struct { APIURL string `json:"apiUrl"` Capabilities map[string]json.RawMessage `json:"capabilities"` @@ -378,7 +418,7 @@ type jmapResponse struct { MethodResponses [][]json.RawMessage `json:"methodResponses"` } -func (r jmapResponse) find(callID string) ([]json.RawMessage, error) { +func (r jmapResponse) find(callID, expectedMethod string) ([]json.RawMessage, error) { for _, response := range r.MethodResponses { if len(response) != 3 { continue @@ -403,9 +443,11 @@ func (r jmapResponse) find(callID string) ([]json.RawMessage, error) { } return nil, fmt.Errorf("JMAP method %q failed: %s", callID, errorResponse.Type) } - return response, nil + if methodName == expectedMethod { + return response, nil + } } - return nil, fmt.Errorf("JMAP response did not include method %q", callID) + return nil, fmt.Errorf("JMAP response did not include method %q for call %q", expectedMethod, callID) } func jmapResponseString(raw json.RawMessage) (string, error) { @@ -417,7 +459,7 @@ func jmapResponseString(raw json.RawMessage) (string, error) { } func (r jmapResponse) identities(callID string) ([]jmapIdentity, error) { - response, err := r.find(callID) + response, err := r.find(callID, "Identity/get") if err != nil { return nil, err } @@ -435,7 +477,7 @@ func (r jmapResponse) identities(callID string) ([]jmapIdentity, error) { } func (r jmapResponse) mailboxes(callID string) ([]jmapMailbox, error) { - response, err := r.find(callID) + response, err := r.find(callID, "Mailbox/get") if err != nil { return nil, err } @@ -452,14 +494,14 @@ func (r jmapResponse) mailboxes(callID string) ([]jmapMailbox, error) { return payload.List, nil } -func (r jmapResponse) requireCreated(methodName, callID, creationID string) error { - response, err := r.find(callID) +func (r jmapResponse) createdID(methodName, callID, creationID string) (string, error) { + response, err := r.find(callID, methodName) if err != nil { - return err + return "", err } returnedMethodName, err := jmapResponseString(response[0]) if err != nil || returnedMethodName != methodName { - return fmt.Errorf("unexpected JMAP response for method %q", callID) + return "", fmt.Errorf("unexpected JMAP response for method %q", callID) } var payload struct { Created map[string]struct { @@ -470,13 +512,38 @@ func (r jmapResponse) requireCreated(methodName, callID, creationID string) erro } `json:"notCreated"` } if err := json.Unmarshal(response[1], &payload); err != nil { - return fmt.Errorf("decode JMAP response for method %q: %w", callID, err) + return "", fmt.Errorf("decode JMAP response for method %q: %w", callID, err) } if created, ok := payload.Created[creationID]; ok && created.ID != "" { - return nil + return created.ID, nil } if failure, ok := payload.NotCreated[creationID]; ok && failure.Type != "" { - return fmt.Errorf("JMAP method %q failed: %s", callID, failure.Type) + return "", fmt.Errorf("JMAP method %q failed: %s", callID, failure.Type) + } + return "", fmt.Errorf("JMAP method %q did not create %q", callID, creationID) +} + +func (r jmapResponse) requireDestroyedEmail(callID, emailID string) error { + response, err := r.find(callID, "Email/set") + if err != nil { + return err + } + var payload struct { + Destroyed []string `json:"destroyed"` + NotDestroyed map[string]struct { + Type string `json:"type"` + } `json:"notDestroyed"` + } + if err := json.Unmarshal(response[1], &payload); err != nil { + return fmt.Errorf("decode JMAP cleanup response: %w", err) + } + for _, destroyedID := range payload.Destroyed { + if destroyedID == emailID { + return nil + } + } + if failure, ok := payload.NotDestroyed[emailID]; ok && failure.Type != "" { + return fmt.Errorf("JMAP draft cleanup failed: %s", failure.Type) } - return fmt.Errorf("JMAP method %q did not create %q", callID, creationID) + return fmt.Errorf("JMAP draft cleanup did not destroy the submitted email") } diff --git a/cli/internal/email/jmap_test.go b/cli/internal/email/jmap_test.go index 1dae447a6..c752989f6 100644 --- a/cli/internal/email/jmap_test.go +++ b/cli/internal/email/jmap_test.go @@ -14,7 +14,7 @@ import ( func TestJMAPMailer_SendContext(t *testing.T) { var apiCalls int var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("Authorization"); got != "Bearer token-1" { t.Errorf("Authorization = %q, want bearer token", got) } @@ -100,9 +100,9 @@ func TestJMAPMailer_SendContext(t *testing.T) { } func TestJMAPMailer_SendContextRejectsUnsupportedServer(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeJMAPJSON(t, w, map[string]any{ - "apiUrl": serverURLPlaceholder + "/api", + "apiUrl": "https://jmap.test/api", "capabilities": map[string]any{ jmapCoreCapability: map[string]any{}, jmapMailCapability: map[string]any{}, @@ -132,7 +132,77 @@ func TestJMAPMailer_IsEnabled(t *testing.T) { } } -const serverURLPlaceholder = "http://jmap.test" +func TestJMAPMailer_RejectsInsecureSessionURL(t *testing.T) { + mailer := NewJMAPMailer(config.JMAPConfig{ + SessionURL: "http://mail.example/.well-known/jmap", + AccessToken: "token-1", + From: "sender@example.com", + }) + err := mailer.Send(Message{To: "recipient@example.com"}) + if err == nil || !strings.Contains(err.Error(), "JMAP session URL must be an absolute HTTPS URL") { + t.Fatalf("Send() error = %v, want insecure-session error", err) + } +} + +func TestJMAPMailer_RejectsInsecureAPIURL(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJMAPJSON(t, w, map[string]any{ + "apiUrl": "http://mail.example/api", + "capabilities": map[string]any{ + jmapCoreCapability: map[string]any{}, + jmapMailCapability: map[string]any{}, + jmapSubmissionCapability: map[string]any{}, + }, + }) + })) + defer server.Close() + + mailer := newJMAPMailer(config.JMAPConfig{ + SessionURL: server.URL, + AccessToken: "token-1", + From: "sender@example.com", + }, server.Client()) + err := mailer.Send(Message{To: "recipient@example.com"}) + if err == nil || !strings.Contains(err.Error(), "JMAP session API URL must be an absolute HTTPS URL") { + t.Fatalf("Send() error = %v, want insecure-API error", err) + } +} + +func TestJMAPMailer_SelectIdentityUsesWildcardDomain(t *testing.T) { + mailer := NewJMAPMailer(config.JMAPConfig{}) + identityID, err := mailer.selectIdentity([]jmapIdentity{{ID: "identity-1", Email: "*@example.com"}}, "sender@example.com") + if err != nil { + t.Fatalf("selectIdentity() error = %v", err) + } + if identityID != "identity-1" { + t.Fatalf("selectIdentity() = %q, want identity-1", identityID) + } +} + +func TestJMAPResponse_RequiresDraftCleanup(t *testing.T) { + response := jmapResponse{MethodResponses: [][]json.RawMessage{ + mustJMAPResponse(t, "Email/set", map[string]any{"created": map[string]any{"email": map[string]any{"id": "email-1"}}}, "create-email"), + mustJMAPResponse(t, "EmailSubmission/set", map[string]any{"created": map[string]any{"submission": map[string]any{"id": "submission-1"}}}, "submit-email"), + mustJMAPResponse(t, "Email/set", map[string]any{"notDestroyed": map[string]any{"email-1": map[string]any{"type": "forbidden"}}}, "submit-email"), + }} + + if err := response.requireDestroyedEmail("submit-email", "email-1"); err == nil || !strings.Contains(err.Error(), "JMAP draft cleanup failed") { + t.Fatalf("requireDestroyedEmail() error = %v, want cleanup failure", err) + } +} + +func mustJMAPResponse(t *testing.T, method string, arguments any, callID string) []json.RawMessage { + t.Helper() + encoded, err := json.Marshal([3]any{method, arguments, callID}) + if err != nil { + t.Fatalf("marshal JMAP response: %v", err) + } + var response []json.RawMessage + if err := json.Unmarshal(encoded, &response); err != nil { + t.Fatalf("unmarshal JMAP response: %v", err) + } + return response +} func writeJMAPJSON(t *testing.T, w http.ResponseWriter, body any) { t.Helper() diff --git a/docs/fdr/FDR-029-transactional-email-delivery.md b/docs/fdr/FDR-029-transactional-email-delivery.md index 64fa4586f..f3cf6f1ed 100644 --- a/docs/fdr/FDR-029-transactional-email-delivery.md +++ b/docs/fdr/FDR-029-transactional-email-delivery.md @@ -10,7 +10,7 @@ Chatto sends transactional email for account registration, email-address verific ## Behavior - Local-account registration, email-address verification, and password-reset flows use the selected transactional email transport without changing their user-facing workflow. -- Existing SMTP configuration continues to work unchanged. Operators select JMAP explicitly and configure a JMAP session URL, bearer token, and sender address. +- Existing SMTP configuration continues to work unchanged. Operators select JMAP explicitly and configure an HTTPS JMAP session URL, bearer token, and sender address. - JMAP uses an available sending identity matching the configured sender and a Drafts mailbox. Operators can explicitly select the account, identity, or Drafts mailbox when automatic selection is unsuitable. - A successful JMAP request means the JMAP server accepted the submission. Chatto does not claim final delivery to every recipient. From 9df1153880afc3e2b97e5d544925280d4f0b9d3e Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Sun, 12 Jul 2026 12:51:55 +0200 Subject: [PATCH 3/3] fix(email): preserve accepted JMAP submissions --- cli/internal/email/jmap.go | 12 +++-- cli/internal/email/jmap_test.go | 49 +++++++++++++++++++ .../FDR-029-transactional-email-delivery.md | 6 +-- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/cli/internal/email/jmap.go b/cli/internal/email/jmap.go index a7ea7c923..d6921a3c7 100644 --- a/cli/internal/email/jmap.go +++ b/cli/internal/email/jmap.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/charmbracelet/log" + "hmans.de/chatto/internal/config" ) @@ -24,8 +26,8 @@ const ( ) // JMAPMailer sends transactional email through the JMAP submission API. It -// creates a plaintext draft, submits it, and removes the draft after a -// successful submission. +// creates a plaintext draft, submits it, and requests removal of the draft +// after a successful submission. type JMAPMailer struct { config config.JMAPConfig client *http.Client @@ -135,7 +137,11 @@ func (m *JMAPMailer) SendContext(ctx context.Context, msg Message) error { return err } if err := response.requireDestroyedEmail("submit-email", emailID); err != nil { - return err + // The JMAP server has already accepted the submission, so reporting this + // cleanup failure to the account workflow would invalidate a credential + // that may have reached the recipient. The response errors contain only + // JMAP error types, never message contents or recipient addresses. + log.Warn("JMAP transactional email draft cleanup failed after submission", "error", err) } return nil diff --git a/cli/internal/email/jmap_test.go b/cli/internal/email/jmap_test.go index c752989f6..4353cc503 100644 --- a/cli/internal/email/jmap_test.go +++ b/cli/internal/email/jmap_test.go @@ -168,6 +168,55 @@ func TestJMAPMailer_RejectsInsecureAPIURL(t *testing.T) { } } +func TestJMAPMailer_TreatsDraftCleanupFailureAsSubmitted(t *testing.T) { + var apiCalls int + var server *httptest.Server + server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/session": + writeJMAPJSON(t, w, map[string]any{ + "apiUrl": server.URL + "/api", + "capabilities": map[string]any{ + jmapCoreCapability: map[string]any{}, + jmapMailCapability: map[string]any{}, + jmapSubmissionCapability: map[string]any{}, + }, + "accounts": map[string]any{"account-1": map[string]any{"accountCapabilities": map[string]any{ + jmapMailCapability: map[string]any{}, + jmapSubmissionCapability: map[string]any{}, + }}}, + "primaryAccounts": map[string]string{jmapSubmissionCapability: "account-1"}, + }) + case "/api": + apiCalls++ + if apiCalls == 1 { + writeJMAPJSON(t, w, map[string]any{"methodResponses": []any{ + []any{"Identity/get", map[string]any{"list": []any{map[string]any{"id": "identity-1", "email": "sender@example.com"}}}, "identities"}, + []any{"Mailbox/get", map[string]any{"list": []any{map[string]any{"id": "drafts-1", "role": "drafts"}}}, "mailboxes"}, + }}) + return + } + writeJMAPJSON(t, w, map[string]any{"methodResponses": []any{ + []any{"Email/set", map[string]any{"created": map[string]any{"email": map[string]any{"id": "email-1"}}}, "create-email"}, + []any{"EmailSubmission/set", map[string]any{"created": map[string]any{"submission": map[string]any{"id": "submission-1"}}}, "submit-email"}, + []any{"Email/set", map[string]any{"notDestroyed": map[string]any{"email-1": map[string]any{"type": "forbidden"}}}, "submit-email"}, + }}) + default: + t.Errorf("unexpected JMAP request path %q", r.URL.Path) + } + })) + defer server.Close() + + mailer := newJMAPMailer(config.JMAPConfig{ + SessionURL: server.URL + "/session", + AccessToken: "token-1", + From: "sender@example.com", + }, server.Client()) + if err := mailer.Send(Message{To: "recipient@example.com"}); err != nil { + t.Fatalf("Send() error = %v, want accepted submission", err) + } +} + func TestJMAPMailer_SelectIdentityUsesWildcardDomain(t *testing.T) { mailer := NewJMAPMailer(config.JMAPConfig{}) identityID, err := mailer.selectIdentity([]jmapIdentity{{ID: "identity-1", Email: "*@example.com"}}, "sender@example.com") diff --git a/docs/fdr/FDR-029-transactional-email-delivery.md b/docs/fdr/FDR-029-transactional-email-delivery.md index f3cf6f1ed..2a0d0797c 100644 --- a/docs/fdr/FDR-029-transactional-email-delivery.md +++ b/docs/fdr/FDR-029-transactional-email-delivery.md @@ -11,7 +11,7 @@ Chatto sends transactional email for account registration, email-address verific - Local-account registration, email-address verification, and password-reset flows use the selected transactional email transport without changing their user-facing workflow. - Existing SMTP configuration continues to work unchanged. Operators select JMAP explicitly and configure an HTTPS JMAP session URL, bearer token, and sender address. -- JMAP uses an available sending identity matching the configured sender and a Drafts mailbox. Operators can explicitly select the account, identity, or Drafts mailbox when automatic selection is unsuitable. +- JMAP uses an available sending identity matching the configured sender and a Drafts mailbox. Operators can explicitly select the account, identity, or Drafts mailbox when automatic selection is unsuitable. Chatto requests removal of the temporary draft after submission and records a safe operator warning if that cleanup fails. - A successful JMAP request means the JMAP server accepted the submission. Chatto does not claim final delivery to every recipient. ## Design Decisions @@ -24,9 +24,9 @@ Chatto sends transactional email for account registration, email-address verific ### 2. JMAP is submission-only -**Decision:** JMAP support creates and submits a plain-text transactional message, then removes the temporary draft after successful submission. It does not synchronize a mailbox or track final delivery status. +**Decision:** JMAP support creates and submits a plain-text transactional message, then requests removal of the temporary draft after successful submission. A cleanup failure does not turn an accepted submission into a failed account flow. It does not synchronize a mailbox or track final delivery status. **Why:** Chatto needs outbound account-flow messages, not a general-purpose mail client. Keeping the integration narrow avoids mailbox state and user-data concerns while meeting the transactional use case. -**Tradeoff:** Operators cannot use Chatto to inspect sent mail or final per-recipient delivery results. +**Tradeoff:** Operators cannot use Chatto to inspect sent mail or final per-recipient delivery results. A provider-side cleanup failure can leave a temporary draft that the operator must investigate from the safe warning log. ### 3. Use a bearer token rather than a mailbox password