feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s

- Per-user routing (own mail / own Discord webhook) for task alarms
  (sent at once), shares, completed shared tasks and watched tasks;
  settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
  after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
  HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
  asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
  count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 09:49:13 +01:00
1 parent 362dd3b4c6
commit a69bcbaf10
22 files changed
+1018 -21

No files matched your search

+26 -12
View File
@@ -117,7 +117,7 @@ func senderAddress(raw, name string) (envelope, header string, err error) {
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
func buildMessage(from, to, subject, body string, now time.Time) []byte {
func buildMessage(from, to, subject, body, html string, now time.Time) []byte {
idBytes := make([]byte, 12)
_, _ = rand.Read(idBytes)
domain := ehloName(from)
@@ -129,12 +129,26 @@ func buildMessage(from, to, subject, body string, now time.Time) []byte {
fmt.Fprintf(&buf, "Date: %s\r\n", now.Format(time.RFC1123Z))
fmt.Fprintf(&buf, "Message-ID: <%s@%s>\r\n", hex.EncodeToString(idBytes), domain)
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(body, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close()
buf.WriteString("\r\n")
part := func(contentType, text string) {
fmt.Fprintf(&buf, "Content-Type: %s; charset=\"utf-8\"\r\n", contentType)
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
qp := quotedprintable.NewWriter(&buf)
_, _ = qp.Write([]byte(strings.ReplaceAll(strings.ReplaceAll(text, "\r\n", "\n"), "\n", "\r\n")))
_ = qp.Close()
buf.WriteString("\r\n")
}
if html == "" {
part("text/plain", body)
return buf.Bytes()
}
// Text for simple clients, HTML for the rest
boundary := "ms-" + hex.EncodeToString(idBytes)
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
part("text/plain", body)
fmt.Fprintf(&buf, "--%s\r\n", boundary)
part("text/html", html)
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
return buf.Bytes()
}
@@ -199,14 +213,14 @@ func authenticate(client *smtp.Client, cfg smtpSettings) error {
// sendMail sends one mail. If AUTH PLAIN is refused and the server also offers LOGIN, it reconnects once
// and tries LOGIN; a 451 on both points to the mail server's SASL backend.
func sendMail(cfg smtpSettings, to, subject, body string) error {
err := sendMailOnce(cfg, to, subject, body)
func sendMail(cfg smtpSettings, to, subject, body, html string) error {
err := sendMailOnce(cfg, to, subject, body, html)
var first *authAttemptError
if cfg.authMech != "" || !errors.As(err, &first) || first.mech != "PLAIN" || !first.loginOffered {
return err
}
cfg.authMech = "LOGIN"
retryErr := sendMailOnce(cfg, to, subject, body)
retryErr := sendMailOnce(cfg, to, subject, body, html)
var second *authAttemptError
if retryErr == nil || !errors.As(retryErr, &second) {
return retryErr
@@ -220,7 +234,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
// properly, honour SkipVerify and report the exact step a server refuses.
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
func sendMailOnce(cfg smtpSettings, to, subject, body, html string) error {
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
if err != nil {
return &smtpStepError{"Absender", err}
@@ -278,7 +292,7 @@ func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
if err != nil {
return &smtpStepError{"DATA", err}
}
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, time.Now())); err != nil {
if _, err := wc.Write(buildMessage(fromHeader, toHeader, subject, body, html, time.Now())); err != nil {
_ = wc.Close()
return &smtpStepError{"DATA", err}
}