summaryrefslogtreecommitdiff
path: root/internal/controlplane/outbox.go
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--internal/controlplane/outbox.go152
1 files changed, 152 insertions, 0 deletions
diff --git a/internal/controlplane/outbox.go b/internal/controlplane/outbox.go
new file mode 100644
index 0000000..b54a064
--- /dev/null
+++ b/internal/controlplane/outbox.go
@@ -0,0 +1,152 @@
+package controlplane
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "aigw/internal/mailer"
+
+ "github.com/jackc/pgx/v5"
+)
+
+const (
+ actionVerifyEmail = "verify_email"
+ actionPasswordReset = "password_reset"
+ actionInvite = "invite"
+)
+
+func (s *Store) issueActionToken(ctx context.Context, tx pgx.Tx, userID, email, displayName, purpose, publicURL, remoteIP string, ttl time.Duration) (string, error) {
+ token, tokenHash, err := randomCredential("act-aigw-")
+ if err != nil {
+ return "", err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now()
+ WHERE user_id=$1 AND purpose=$2 AND consumed_at IS NULL`, userID, purpose); err != nil {
+ return "", fmt.Errorf("expire previous account action: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_action_tokens
+ (user_id,purpose,token_hash,expires_at,requested_ip) VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet)`,
+ userID, purpose, tokenHash, time.Now().UTC().Add(ttl), remoteIP); err != nil {
+ return "", fmt.Errorf("create account action: %w", err)
+ }
+ link, err := accountActionURL(publicURL, purpose, token)
+ if err != nil {
+ return "", err
+ }
+ subject, body := actionMail(displayName, purpose, link, ttl)
+ ciphertext, err := s.cipher.Encrypt(body)
+ if err != nil {
+ return "", fmt.Errorf("encrypt account email: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_mail_outbox
+ (recipient,template,subject,body_ciphertext) VALUES ($1,$2,$3,$4)`, email, purpose, subject, ciphertext); err != nil {
+ return "", fmt.Errorf("queue account email: %w", err)
+ }
+ return token, nil
+}
+
+func accountActionURL(publicURL, purpose, token string) (string, error) {
+ parsed, err := url.Parse(publicURL)
+ if err != nil || parsed.Host == "" {
+ return "", errors.New("console public URL is invalid")
+ }
+ query := parsed.Query()
+ switch purpose {
+ case actionVerifyEmail:
+ query.Set("action", "verify-email")
+ case actionPasswordReset:
+ query.Set("action", "reset-password")
+ case actionInvite:
+ query.Set("action", "accept-invite")
+ default:
+ return "", errors.New("unsupported account action")
+ }
+ query.Set("token", token)
+ parsed.RawQuery = query.Encode()
+ return parsed.String(), nil
+}
+
+func actionMail(displayName, purpose, link string, ttl time.Duration) (string, string) {
+ name := strings.TrimSpace(displayName)
+ if name == "" {
+ name = "there"
+ }
+ minutes := int(ttl.Minutes())
+ expires := fmt.Sprintf("%d minutes", minutes)
+ if minutes%1440 == 0 {
+ expires = durationLabel(minutes/1440, "day")
+ } else if minutes%60 == 0 {
+ expires = durationLabel(minutes/60, "hour")
+ }
+ switch purpose {
+ case actionVerifyEmail:
+ return "Verify your AIGW email", fmt.Sprintf("Hi %s,\n\nVerify your email to activate your AIGW workspace:\n\n%s\n\nThis link expires in %s and can be used once. If you did not create this account, ignore this email.\n", name, link, expires)
+ case actionPasswordReset:
+ return "Reset your AIGW password", fmt.Sprintf("Hi %s,\n\nReset your AIGW password:\n\n%s\n\nThis link expires in %s and can be used once. If you did not request this, your password has not changed.\n", name, link, expires)
+ default:
+ return "You have been invited to AIGW", fmt.Sprintf("Hi %s,\n\nAccept your AIGW invitation and choose a password:\n\n%s\n\nThis link expires in %s and can be used once.\n", name, link, expires)
+ }
+}
+
+func durationLabel(value int, unit string) string {
+ if value != 1 {
+ unit += "s"
+ }
+ return fmt.Sprintf("%d %s", value, unit)
+}
+
+func (s *Store) ClaimMail(ctx context.Context) (mailer.Message, bool, error) {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return mailer.Message{}, false, err
+ }
+ defer tx.Rollback(ctx)
+ var message mailer.Message
+ var ciphertext []byte
+ err = tx.QueryRow(ctx, `WITH candidate AS (
+ SELECT id FROM console_mail_outbox
+ WHERE ((status IN ('pending','failed') AND available_at <= now())
+ OR (status='sending' AND claimed_at < now()-interval '5 minutes'))
+ ORDER BY available_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1
+ ) UPDATE console_mail_outbox o SET status='sending',claimed_at=now(),attempts=attempts+1,last_error=''
+ FROM candidate WHERE o.id=candidate.id
+ RETURNING o.id::text,o.recipient,o.subject,o.body_ciphertext`,
+ ).Scan(&message.ID, &message.Recipient, &message.Subject, &ciphertext)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return mailer.Message{}, false, nil
+ }
+ if err != nil {
+ return mailer.Message{}, false, fmt.Errorf("claim mail outbox item: %w", err)
+ }
+ message.Body, err = s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return mailer.Message{}, false, fmt.Errorf("decrypt mail outbox item: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return mailer.Message{}, false, err
+ }
+ return message, true, nil
+}
+
+func (s *Store) MarkMailSent(ctx context.Context, id string) error {
+ _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='sent',sent_at=now(),claimed_at=NULL,last_error=''
+ WHERE id=$1 AND status='sending'`, id)
+ return err
+}
+
+func (s *Store) MarkMailFailed(ctx context.Context, id string, deliveryErr error) error {
+ message := "delivery failed"
+ if deliveryErr != nil {
+ message = deliveryErr.Error()
+ }
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+ _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='failed',claimed_at=NULL,last_error=$2,
+ available_at=now()+make_interval(secs => LEAST(300, 5 * attempts)) WHERE id=$1 AND status='sending'`, id, message)
+ return err
+}