diff --git a/.env.example b/examples/fullstack/.env.example similarity index 100% rename from .env.example rename to examples/fullstack/.env.example diff --git a/Makefile b/examples/fullstack/Makefile similarity index 100% rename from Makefile rename to examples/fullstack/Makefile diff --git a/config/middleware.go b/examples/fullstack/config/middleware.go similarity index 100% rename from config/middleware.go rename to examples/fullstack/config/middleware.go diff --git a/config/views.go b/examples/fullstack/config/views.go similarity index 100% rename from config/views.go rename to examples/fullstack/config/views.go diff --git a/database/database.go b/examples/fullstack/database/database.go similarity index 100% rename from database/database.go rename to examples/fullstack/database/database.go diff --git a/go.mod b/examples/fullstack/go.mod similarity index 61% rename from go.mod rename to examples/fullstack/go.mod index ddc50b2..a0ffb34 100644 --- a/go.mod +++ b/examples/fullstack/go.mod @@ -1,8 +1,9 @@ -module git.tavo.one/tavo/axiom +module axiom-fullstack -go 1.22.1 +go 1.24.4 require ( + git.tavo.one/tavo/axiom v0.0.0-20250701062914-3086b8fcd640 github.com/mattn/go-sqlite3 v1.14.28 github.com/tdewolff/minify/v2 v2.23.8 ) diff --git a/go.sum b/examples/fullstack/go.sum similarity index 76% rename from go.sum rename to examples/fullstack/go.sum index 28312e3..08749ab 100644 --- a/go.sum +++ b/examples/fullstack/go.sum @@ -1,3 +1,5 @@ +git.tavo.one/tavo/axiom v0.0.0-20250701062914-3086b8fcd640 h1:0M5+uKndwQi5U43n+C0botOx4sfnU+2Mav9yvEQzhDs= +git.tavo.one/tavo/axiom v0.0.0-20250701062914-3086b8fcd640/go.mod h1:jeOZoBsGgvFJ3XlPEzs0yYMNZwWcsYUbYjDJ0qKW1FM= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/tdewolff/minify/v2 v2.23.8 h1:tvjHzRer46kwOfpdCBCWsDblCw3QtnLJRd61pTVkyZ8= diff --git a/handlers/example_handlers.go b/examples/fullstack/handlers/example_handlers.go similarity index 100% rename from handlers/example_handlers.go rename to examples/fullstack/handlers/example_handlers.go diff --git a/handlers/handler.go b/examples/fullstack/handlers/handler.go similarity index 100% rename from handlers/handler.go rename to examples/fullstack/handlers/handler.go diff --git a/main.go b/examples/fullstack/main.go similarity index 100% rename from main.go rename to examples/fullstack/main.go diff --git a/routes.go b/examples/fullstack/routes.go similarity index 100% rename from routes.go rename to examples/fullstack/routes.go diff --git a/scripts/minify.go b/examples/fullstack/scripts/minify.go similarity index 100% rename from scripts/minify.go rename to examples/fullstack/scripts/minify.go diff --git a/static/icon.svg b/examples/fullstack/static/icon.svg similarity index 100% rename from static/icon.svg rename to examples/fullstack/static/icon.svg diff --git a/templates/_partials/footer.html b/examples/fullstack/templates/_partials/footer.html similarity index 100% rename from templates/_partials/footer.html rename to examples/fullstack/templates/_partials/footer.html diff --git a/templates/_partials/head.html b/examples/fullstack/templates/_partials/head.html similarity index 100% rename from templates/_partials/head.html rename to examples/fullstack/templates/_partials/head.html diff --git a/templates/_partials/header.html b/examples/fullstack/templates/_partials/header.html similarity index 100% rename from templates/_partials/header.html rename to examples/fullstack/templates/_partials/header.html diff --git a/templates/baseof.html b/examples/fullstack/templates/baseof.html similarity index 100% rename from templates/baseof.html rename to examples/fullstack/templates/baseof.html diff --git a/templates/index-page.html b/examples/fullstack/templates/index-page.html similarity index 100% rename from templates/index-page.html rename to examples/fullstack/templates/index-page.html diff --git a/templates/index.html b/examples/fullstack/templates/index.html similarity index 100% rename from templates/index.html rename to examples/fullstack/templates/index.html diff --git a/templates/login-page.html b/examples/fullstack/templates/login-page.html similarity index 100% rename from templates/login-page.html rename to examples/fullstack/templates/login-page.html diff --git a/templates/login.html b/examples/fullstack/templates/login.html similarity index 100% rename from templates/login.html rename to examples/fullstack/templates/login.html diff --git a/mail/smtp.go b/mail/smtp.go new file mode 100644 index 0000000..c494eb1 --- /dev/null +++ b/mail/smtp.go @@ -0,0 +1,203 @@ +package smtp + +import ( + "bytes" + "crypto/tls" + "fmt" + "mime/multipart" + "net/smtp" + "net/textproto" + "strconv" + "strings" +) + +type Auth struct { + Host string + Port string + Pass string + Debug bool +} + +func Client(host, port, password string) *Auth { + return &Auth{ + Host: host, + Port: port, + Pass: password, + } +} + +func (s *Auth) tlsConfig() *tls.Config { + return &tls.Config{ServerName: s.Host} +} + +func (s *Auth) smtpClient() (*smtp.Client, error) { + address := s.Host + ":" + s.Port + portNum, err := strconv.Atoi(s.Port) + if err != nil { + return nil, fmt.Errorf("invalid port number: %w", err) + } + + if portNum == 465 { + conn, err := tls.Dial("tcp", address, s.tlsConfig()) + if err != nil { + return nil, fmt.Errorf("failed to connect to SMTPS server: %w", err) + } + return smtp.NewClient(conn, s.Host) + } + + client, err := smtp.Dial(address) + if err != nil { + return nil, fmt.Errorf("failed to connect to SMTP server: %w", err) + } + + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(s.tlsConfig()); err != nil { + client.Close() + return nil, fmt.Errorf("failed to start TLS: %w", err) + } + } else { + client.Close() + return nil, fmt.Errorf("TLS not supported by the server") + } + + return client, nil +} + +func (s *Auth) SendText( + from string, + to []string, + subject string, + body string, + attachments map[string]*bytes.Buffer, +) error { + return s.sendMessage(from, to, subject, body, "text/plain; charset=utf-8", attachments) +} + +func (s *Auth) SendHTML( + from string, + to []string, + subject string, + html string, + attachments map[string]*bytes.Buffer, +) error { + return s.sendMessage(from, to, subject, html, "text/html; charset=utf-8", attachments) +} + +func (s *Auth) sendMessage( + from string, + to []string, + subject string, + body string, + contentType string, + attachments map[string]*bytes.Buffer, +) error { + message, err := s.buildMessage(from, to, subject, body, contentType, attachments) + if err != nil { + return fmt.Errorf("failed to create email message: %w", err) + } + + client, err := s.smtpClient() + if err != nil { + return err + } + defer client.Close() + + auth := smtp.PlainAuth("", from, s.Pass, s.Host) + if err = client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + if err = client.Mail(from); err != nil { + return fmt.Errorf("failed to set sender: %w", err) + } + for _, recipient := range to { + if err = client.Rcpt(recipient); err != nil { + return fmt.Errorf("failed to add recipient %s: %w", recipient, err) + } + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("failed to get data writer: %w", err) + } + if _, err = w.Write(message); err != nil { + return fmt.Errorf("failed to write message: %w", err) + } + if err = w.Close(); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return client.Quit() +} + +func (s *Auth) Validate(user string) error { + client, err := s.smtpClient() + if err != nil { + return err + } + defer client.Close() + + auth := smtp.PlainAuth("", user, s.Pass, s.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + return nil +} + +func (s *Auth) buildMessage( + from string, + to []string, + subject string, + body string, + contentType string, + attachments map[string]*bytes.Buffer, +) ([]byte, error) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + buf.WriteString(fmt.Sprintf("From: %s\r\n", from)) + buf.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(to, ","))) + buf.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString(fmt.Sprintf("Content-Type: multipart/mixed; boundary=%s\r\n\r\n", writer.Boundary())) + + if err := s.write(writer, contentType, body); err != nil { + return nil, fmt.Errorf("failed to write email body: %w", err) + } + + for filename, fileBuffer := range attachments { + if err := s.attach(writer, filename, fileBuffer); err != nil { + return nil, fmt.Errorf("failed to attach file %s: %w", filename, err) + } + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close writer: %w", err) + } + + return buf.Bytes(), nil +} + +func (s *Auth) write(writer *multipart.Writer, contentType, content string) error { + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Type", contentType) + part, err := writer.CreatePart(partHeader) + if err != nil { + return fmt.Errorf("failed to create part: %w", err) + } + _, err = part.Write([]byte(content)) + return err +} + +func (s *Auth) attach(writer *multipart.Writer, filename string, fileBuffer *bytes.Buffer) error { + attachmentHeader := make(textproto.MIMEHeader) + attachmentHeader.Set("Content-Type", "application/octet-stream") + attachmentHeader.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + attachment, err := writer.CreatePart(attachmentHeader) + if err != nil { + return fmt.Errorf("failed to create attachment part: %w", err) + } + _, err = attachment.Write(fileBuffer.Bytes()) + return err +} diff --git a/views/views.go b/views/views.go index d17de48..4018508 100644 --- a/views/views.go +++ b/views/views.go @@ -84,3 +84,17 @@ func RenderHTML(w http.ResponseWriter, r *http.Request, name string, data any) e _, err := io.Copy(w, &buf) return err } + +func RenderHTMLToString(name string, data any) (string, error) { + tmpl, ok := TemplateCache[name] + if !ok { + return "", fmt.Errorf("template %q not found", name) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, tmpl.Name(), data); err != nil { + return "", fmt.Errorf("failed to execute template %q: %w", name, err) + } + + return buf.String(), nil +}