44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
3.7 KiB
Go
140 lines
3.7 KiB
Go
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"mime"
|
|
"net"
|
|
"net/mail"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type SMTPOptions struct {
|
|
Host string
|
|
Port int
|
|
Username string
|
|
Password string
|
|
UseTLS bool
|
|
StartTLS bool
|
|
InsecureSkipVerify bool
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type SMTPSender struct {
|
|
opts SMTPOptions
|
|
}
|
|
|
|
func NewSMTPSender(opts SMTPOptions) (*SMTPSender, error) {
|
|
opts.Host = strings.TrimSpace(opts.Host)
|
|
if opts.Host == "" {
|
|
return nil, fmt.Errorf("%w: smtp host is required", ErrProviderNotReady)
|
|
}
|
|
if opts.Port <= 0 {
|
|
opts.Port = 587
|
|
}
|
|
if opts.Timeout <= 0 {
|
|
opts.Timeout = 10 * time.Second
|
|
}
|
|
return &SMTPSender{opts: opts}, nil
|
|
}
|
|
|
|
func (s *SMTPSender) SendLoginCode(ctx context.Context, message EmailMessage) error {
|
|
if s == nil {
|
|
return ErrProviderNotReady
|
|
}
|
|
if strings.TrimSpace(message.FromEmail) == "" || strings.TrimSpace(message.ToEmail) == "" {
|
|
return fmt.Errorf("%w: email sender and recipient are required", ErrInvalidCredentials)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, s.opts.Timeout)
|
|
defer cancel()
|
|
|
|
client, err := s.smtpClient(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
|
|
if s.opts.StartTLS && !s.opts.UseTLS {
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(s.tlsConfig()); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(s.opts.Username) != "" {
|
|
auth := smtp.PlainAuth("", s.opts.Username, s.opts.Password, s.opts.Host)
|
|
if err := client.Auth(auth); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := client.Mail(message.FromEmail); err != nil {
|
|
return err
|
|
}
|
|
if err := client.Rcpt(message.ToEmail); err != nil {
|
|
return err
|
|
}
|
|
writer, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := writer.Write(buildSMTPMessage(message)); err != nil {
|
|
_ = writer.Close()
|
|
return err
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
return err
|
|
}
|
|
return client.Quit()
|
|
}
|
|
|
|
func (s *SMTPSender) smtpClient(ctx context.Context) (*smtp.Client, error) {
|
|
address := fmt.Sprintf("%s:%d", s.opts.Host, s.opts.Port)
|
|
dialer := &net.Dialer{Timeout: s.opts.Timeout}
|
|
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if s.opts.UseTLS {
|
|
tlsConn := tls.Client(conn, s.tlsConfig())
|
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
|
_ = conn.Close()
|
|
return nil, err
|
|
}
|
|
conn = tlsConn
|
|
}
|
|
return smtp.NewClient(conn, s.opts.Host)
|
|
}
|
|
|
|
func (s *SMTPSender) tlsConfig() *tls.Config {
|
|
return &tls.Config{
|
|
ServerName: s.opts.Host,
|
|
InsecureSkipVerify: s.opts.InsecureSkipVerify,
|
|
}
|
|
}
|
|
|
|
func buildSMTPMessage(message EmailMessage) []byte {
|
|
boundary := "moteva-auth-code"
|
|
from := mail.Address{Name: message.FromName, Address: message.FromEmail}
|
|
to := mail.Address{Address: message.ToEmail}
|
|
var buffer bytes.Buffer
|
|
buffer.WriteString("From: " + from.String() + "\r\n")
|
|
buffer.WriteString("To: " + to.String() + "\r\n")
|
|
buffer.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", message.Subject) + "\r\n")
|
|
buffer.WriteString("MIME-Version: 1.0\r\n")
|
|
buffer.WriteString("Content-Type: multipart/alternative; boundary=" + boundary + "\r\n\r\n")
|
|
buffer.WriteString("--" + boundary + "\r\n")
|
|
buffer.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
buffer.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
|
buffer.WriteString(message.Text + "\r\n\r\n")
|
|
buffer.WriteString("--" + boundary + "\r\n")
|
|
buffer.WriteString("Content-Type: text/html; charset=utf-8\r\n")
|
|
buffer.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
|
|
buffer.WriteString(message.HTML + "\r\n\r\n")
|
|
buffer.WriteString("--" + boundary + "--\r\n")
|
|
return buffer.Bytes()
|
|
}
|