feat(notify): bundled notifications, task watchers, daily summary
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
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:
1 parent
362dd3b4c6
commit
a69bcbaf10
22 files changed
+1018
-21
No files matched your search
+26
-12
@@ -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}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user