feat: initialize nuntius project with full server implementation

This commit is contained in:
2026-06-20 20:48:09 +02:00
commit 1b700f7975
33 changed files with 4313 additions and 0 deletions
+292
View File
@@ -0,0 +1,292 @@
//go:build linux || freebsd
// Package email handles SMTP message composition and delivery.
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"codeberg.org/petrbalvin/nuntius/internal/config"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
// FormSender delivers contact form submissions for a single form
// using its own SMTP credentials. Each form has its own FormSender
// so credentials are isolated per tenant.
type FormSender struct {
form *config.Form
}
// NewFormSender returns a new FormSender bound to the given form.
func NewFormSender(form *config.Form) *FormSender {
return &FormSender{form: form}
}
// Send composes and sends a multi-part email (plain text + HTML)
// for the given request. Returns an error if the SMTP round-trip fails.
func (s *FormSender) Send(req contactform.Request) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg := compose(s.form.From, s.form.To, req, s.form.Name, s.form.Type)
return smtp.SendMail(s.form.SMTP.AddrFor(), auth, s.form.From, []string{s.form.To}, msg)
}
// emailTemplateContact is the HTML body template for contact-type forms.
var emailTemplateContact = template.Must(template.New("contact").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,0.06)">
<!-- Header -->
<tr>
<td style="background-color:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:0.5px">
Contact Form Submission
</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#ffffff">
{{.FormName}}
</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px 28px 12px">
<!-- Submitter -->
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:20px">
<tr>
<td style="padding-bottom:8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">From</span>
<br>
<span style="font-size:15px;font-weight:600;color:#1f2937">{{.Name}}</span>
<span style="font-size:14px;color:#1e40af;margin-left:8px">{{.Email}}</span>
</td>
</tr>
{{if .Service}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Service Interest</span>
<br>
<span style="font-size:14px;color:#1f2937">{{.Service}}</span>
</td>
</tr>
{{end}}
<tr>
<td style="padding:12px 0 8px;border-bottom:1px solid #e5e7eb">
<span style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px">Received</span>
<br>
<span style="font-size:13px;color:#9ca3af">{{.Received}}</span>
</td>
</tr>
</table>
<!-- Message -->
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;padding:6px 0">{{.Message}}</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:16px 28px 28px">
<p style="margin:0;font-size:11px;color:#9ca3af;border-top:1px solid #e5e7eb;padding-top:16px">
Delivered by <strong style="color:#6b7280">nuntius</strong> — a contact form backend for Linux servers.
</p>
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateFeedback is the HTML body template for feedback-type forms.
var emailTemplateFeedback = template.Must(template.New("feedback").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#0f766e;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#5eead4;text-transform:uppercase;letter-spacing:.5px">New Feedback</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#0f766e">{{.Email}}</span></p>
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Feedback</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateNewsletter is the HTML body template for newsletter-signup forms.
var emailTemplateNewsletter = template.Must(template.New("newsletter").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="460" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:20px 24px">
<p style="margin:0;font-size:18px;font-weight:700;color:#fff">{{.FormName}} — new subscriber</p>
</td></tr>
<tr><td style="padding:24px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 4px">Email</p>
<p style="font-size:16px;font-weight:600;color:#1e40af;margin:0 0 16px">{{.Email}}</p>
<p style="font-size:11px;color:#9ca3af;margin:16px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// emailTemplateGeneric is the HTML body template for generic forms.
var emailTemplateGeneric = template.Must(template.New("generic").Parse(`<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Inter,ui-sans-serif,system-ui,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:32px 0">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 16px rgba(0,0,0,.06)">
<tr><td style="background:#1e40af;padding:24px 28px">
<p style="margin:0;font-size:13px;font-weight:600;color:#93c5fd;text-transform:uppercase;letter-spacing:.5px">Form Submission</p>
<p style="margin:4px 0 0;font-size:18px;font-weight:700;color:#fff">{{.FormName}}</p>
</td></tr>
<tr><td style="padding:28px">
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0">From</p>
<p style="font-size:15px;font-weight:600;color:#1f2937;margin:2px 0 16px">{{.Name}} <span style="color:#1e40af">{{.Email}}</span></p>
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Message</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap">{{.Message}}</div>
<p style="font-size:11px;color:#9ca3af;margin:20px 0 0;border-top:1px solid #e5e7eb;padding-top:16px">Delivered by <strong style="color:#6b7280">nuntius</strong></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
func compose(from, to string, req contactform.Request, formName, formType string) []byte {
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
// --- HTML part ---
var htmlBuf bytes.Buffer
tmpl := emailTemplateContact
switch formType {
case "newsletter":
tmpl = emailTemplateNewsletter
case "feedback":
tmpl = emailTemplateFeedback
case "generic":
tmpl = emailTemplateGeneric
}
_ = tmpl.Execute(&htmlBuf, map[string]string{
"FormName": formName,
"Name": req.Name,
"Email": req.Email,
"Service": req.Service,
"Message": req.Message,
"Received": received,
})
// --- Subject + plain-text body per form type ---
var subject, text string
switch formType {
case "newsletter":
subject = fmt.Sprintf("[nuntius/%s] New newsletter subscriber", formName)
text = fmt.Sprintf(
"New Newsletter Subscriber: %s\r\n"+
"---\r\n"+
"Email: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"Delivered by nuntius\r\n",
formName, req.Email, received,
)
case "feedback":
subject = fmt.Sprintf("[nuntius/%s] New feedback", formName)
text = fmt.Sprintf(
"New Feedback: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
case "generic":
subject = fmt.Sprintf("[nuntius/%s] New submission", formName)
text = fmt.Sprintf(
"New Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, received, req.Message,
)
default: // contact
subject = fmt.Sprintf("[nuntius/%s] Contact form submission", formName)
if req.Service != "" {
subject = fmt.Sprintf("[nuntius/%s][%s] Contact form submission", formName, req.Service)
}
text = fmt.Sprintf(
"Contact Form Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"Service interest: %s\r\n"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n"+
"Delivered by nuntius\r\n",
formName, req.Name, req.Email, req.Service, received, req.Message,
)
}
// Assemble multipart/alternative message.
boundary := "nuntius-boundary"
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", req.Email))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(text)
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s\r\n", boundary))
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("\r\n")
msg.WriteString(htmlBuf.String())
msg.WriteString("\r\n")
msg.WriteString(fmt.Sprintf("--%s--\r\n", boundary))
return msg.Bytes()
}