// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"path/filepath"
"charm.land/huh/v2"
"github.com/club-1/newsletter-go/v3"
"github.com/club-1/newsletter-go/v3/mailer"
"github.com/club-1/newsletter-go/v3/messages"
)
const CmdName = "newsletter"
// Set by the compiler
var version = "unknown"
var (
flagVerbose bool
flagYes bool
flagPreview bool
flagHelp bool
flagVersion bool
)
func getCmdPrefix() (string, error) {
executable, err := os.Executable()
if err != nil {
return "", fmt.Errorf("get executable path: %w", err)
}
realpath, err := filepath.EvalSymlinks(executable)
if err != nil {
return "", fmt.Errorf("eval symlinks: %w", err)
}
return filepath.Dir(filepath.Dir(realpath)), nil
}
// getSubjectBody returns the subject and body contents.
// If the second argument is ommited, the body content is read from the
// standard input.
func getSubjectBody(args []string) (string, string, error) {
var bodyB []byte
var err error
switch len(args) {
case 0:
return "", "", fmt.Errorf("missing arguments")
case 1:
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) != 0 {
return "", "", fmt.Errorf("missing STDIN piped input")
}
bodyB, err = io.ReadAll(os.Stdin)
if err != nil {
return "", "", fmt.Errorf("read content from STDIN: %w", err)
}
case 2:
bodyPath := args[1]
bodyB, err = os.ReadFile(bodyPath)
if err != nil {
return "", "", fmt.Errorf("load newsletter body: %w", err)
}
default:
return "", "", fmt.Errorf("too many arguments")
}
return args[0], string(bodyB), nil
}
func printPreview(mail *mailer.Mail) {
fmt.Print("================ PREVIEW START ================\n")
fmt.Print("โ---- Header ------\n")
fmt.Printf("| Subject: %s\n", mail.Subject)
fmt.Printf("| From: %s\n", mail.From)
fmt.Print("โ------------------\n")
fmt.Printf("%s\n", mail.Body)
fmt.Print("================ PREVIEW END ================\n")
}
func initForwardFiles() error {
prefix, err := getCmdPrefix()
if err != nil {
return fmt.Errorf("get command prefix: %w", err)
}
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get user home directory: %w", err)
}
errCount := 0
for _, route := range newsletter.Routes {
fileName := ".forward+" + route
filePath := filepath.Join(homeDir, fileName)
_, err = os.Stat(filePath)
if errors.Is(err, os.ErrNotExist) {
if flagVerbose {
fmt.Printf("writting file %q\n", filePath)
}
cmdPath := filepath.Join(prefix, "sbin/newsletterctl")
content := []byte("| \"" + cmdPath + " " + route + "\"\n")
err := os.WriteFile(filePath, content, 0664)
if err != nil {
log.Printf("cannot write file %q: %v", filePath, err)
errCount++
}
}
}
if errCount > 0 {
return fmt.Errorf("write %v file(s)", errCount)
}
return nil
}
func stop(nl *newsletter.Newsletter) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("get user home directory: %w", err)
}
errCount := 0
for _, route := range newsletter.Routes {
fileName := ".forward+" + route
filePath := filepath.Join(homeDir, fileName)
if flagVerbose {
fmt.Printf("deleting file %q\n", filePath)
}
err := os.Remove(filePath)
if err != nil {
log.Printf("cannot delete file %q: %v", filePath, err)
errCount++
}
}
if errCount > 0 {
return fmt.Errorf("remove %v file(s)", errCount)
}
return nil
}
func setup(nl *newsletter.Newsletter) error {
err := initForwardFiles()
if err != nil {
return err
}
setupForm := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Newsletter title ?").
Description("It will be visible before the subject inside square brackets").
Value(&nl.Config.Settings.Title),
huh.NewInput().
Title("Sender displayed name").
Description("Newsletter sender's name").
Value(&nl.Config.Settings.DisplayName),
),
huh.NewGroup(
huh.NewSelect[messages.Language]().
Title("Language").
Description("Language used for subscription and unsubscription mails").
Options(
huh.NewOption("english", messages.LangEnglish),
huh.NewOption("french", messages.LangFrench),
).
Value(&nl.Config.Settings.Language),
),
huh.NewGroup(
huh.NewText().
Title("Signature").
Description("newsletter's signature will be inserted under each newsletter").
Value(&nl.Config.Signature),
),
)
if err := setupForm.Run(); err != nil {
return fmt.Errorf("build setup form: %w", err)
}
err = nl.Config.SaveSettings()
if err != nil {
return err
}
if flagVerbose {
fmt.Printf("settings sucessfully saved to file %q\n", newsletter.SettingsFile)
}
err = nl.Config.SaveSignature()
if err != nil {
return err
}
if flagVerbose {
fmt.Printf("signature sucessfully saved to file %q\n", newsletter.SignatureFile)
}
fmt.Println("๐พ saved !")
return nil
}
func send(nl *newsletter.Newsletter, args []string) error {
subject, body, err := getSubjectBody(args)
if err != nil {
return err
}
mail := nl.DefaultMail(subject, body)
mail.Body += fmt.Sprintf(messages.Newsletter_footer.Print(), nl.UnsubscribeAddr())
addrCount := len(nl.Config.Emails)
if !flagYes {
err = nl.SendPreviewMail(*mail)
if err != nil {
return err
}
if flagPreview {
os.Exit(0)
}
duration := float32(addrCount) / 5.0
var confirm bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Do you really want to send this to %v email addresses ?\n", addrCount)).
Description(fmt.Sprintf("this will take %v seconds", duration)).
Value(&confirm),
),
)
if err := confirmForm.Run(); err != nil {
return fmt.Errorf("build confirm form: %w", err)
}
if !confirm {
fmt.Printf("โ sending aborted\n")
os.Exit(2)
}
}
fmt.Print("sending ")
var errCount = 0
for err := range nl.SendNews(mail) {
if err != nil {
errCount++
fmt.Print("x")
} else {
fmt.Print("ยท")
}
}
fmt.Printf(" done !\n")
if errCount > 0 {
return fmt.Errorf("error occured while sending mail to %v addresses", errCount)
}
log.Printf("newsletter sent to %v email addresses with %v error(s)", addrCount, errCount)
return nil
}
const banner = "" +
" __ __ __ / __ _/_ _/_ __ __\n" +
" / ) /___)| /| / (_ ` / /___) / / /___) / `\n" +
"___/___/_(___ _|/_|/__(__)_/__(___ _(_ __(_ __(___ _/____%s___\n"
const usage = `
Usage: newsletter [OPTION]... setup
newsletter [OPTION]... send SUBJECT [CONTENT_FILE]
Options:`
func help() {
fmt.Printf(banner, version)
flag.CommandLine.SetOutput(os.Stdout)
flag.Usage()
os.Exit(0)
}
func cmdlineFatalf(format string, v ...any) {
log.Printf(format, v...)
flag.Usage()
os.Exit(2)
}
func main() {
flag.Usage = func() {
fmt.Fprintln(flag.CommandLine.Output(), usage)
flag.PrintDefaults()
}
flag.BoolVar(&flagVerbose, "v", false, "verbose: increase verbosity of program")
flag.BoolVar(&flagYes, "y", false, "yes: always answer yes when program ask for confirmation")
flag.BoolVar(&flagPreview, "p", false, "preview: limit to a preview (cannot by used with -y)")
flag.BoolVar(&flagHelp, "h", false, "shorthand for -help")
flag.BoolVar(&flagHelp, "help", false, "show help message")
flag.BoolVar(&flagVersion, "version", false, "show version")
flag.Parse()
if flagHelp {
help()
}
if flagVersion {
fmt.Println(CmdName, version)
return
}
log.SetFlags(0) // remove all logger flags (remove timestamp)
if flagYes && flagPreview {
cmdlineFatalf("illegal combination: -y and -p connot be used at the same time")
}
args := flag.Args()
if len(args) < 1 {
help()
}
nl, err := newsletter.New()
if err != nil {
log.Fatalf("init newsletter: %v", err)
}
messages.SetLanguage(nl.Config.Settings.Language)
var cmdErr error
switch args[0] {
case "stop":
cmdErr = stop(nl)
case "setup":
cmdErr = setup(nl)
case "send":
cmdErr = send(nl, args[1:])
default:
cmdlineFatalf("invalid sub command: %s", args[0])
}
if cmdErr != nil {
log.Fatalf("%s error: %v", args[0], cmdErr)
}
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package main
import (
"flag"
"fmt"
"log"
"os"
"github.com/club-1/newsletter-go/v3/control"
)
const CmdName = "newsletterctl"
// Set by the compiler
var version = "unknown"
var (
flagVersion bool
)
func main() {
flag.BoolVar(&flagVersion, "version", false, "show version")
flag.Parse()
if flagVersion {
fmt.Println(CmdName, version)
return
}
args := flag.Args()
if len(args) < 1 {
log.Fatal("missing sub command")
}
controller, err := control.NewController()
if err != nil {
log.Fatalln("error:", err)
}
cmdErr := controller.Handle(args[0], os.Stdin)
if cmdErr != nil {
// do not send non-zero response code because otherwise
// it would answer an error feedback automatically by email
}
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package newsletter
import (
"bufio"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
"github.com/club-1/newsletter-go/v3/messages"
)
const (
EmailsFile string = "emails"
SecretFile string = ".secret"
SignatureFile string = "signature.txt"
SettingsFile string = "settings.json"
)
// Some error values.
var (
ErrNotSubscribed = errors.New("not subscribed")
)
type Settings struct {
Title string
DisplayName string
Language messages.Language
}
type Config struct {
Dir string
Emails []string
Secret string
Signature string
Settings Settings
}
func (c *Config) Unsubscribe(addr string) error {
index := slices.Index(c.Emails, addr)
if index == -1 {
return ErrNotSubscribed
}
c.Emails = append(c.Emails[:index], c.Emails[index+1:]...)
return c.saveEmails()
}
func (c *Config) Subscribe(addr string) error {
c.Emails = append(c.Emails, addr)
return c.saveEmails()
}
func (c *Config) saveEmails() error {
emailsFilePath := filepath.Join(c.Dir, EmailsFile)
err := writeLines(c.Emails, emailsFilePath)
if err != nil {
return fmt.Errorf("could not save emails: %w", err)
}
return nil
}
func (c *Config) SaveSignature() error {
signatureFilePath := filepath.Join(c.Dir, SignatureFile)
err := os.WriteFile(signatureFilePath, []byte(c.Signature), 0660)
if err != nil {
return fmt.Errorf("could not save signature: %w", err)
}
return nil
}
func (c *Config) SaveSettings() error {
settingsFilePath := filepath.Join(c.Dir, SettingsFile)
if err := saveSettings(settingsFilePath, c.Settings); err != nil {
return fmt.Errorf("could not save settings: %w", err)
}
return nil
}
// readLines reads a whole file into memory
// and returns a slice of its lines.
func readLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
// writeLines writes the lines to the given file.
func writeLines(lines []string, path string) error {
content := strings.Join(lines, "\n")
err := os.WriteFile(path, []byte(content+"\n"), 0664)
if err != nil {
return fmt.Errorf("write file error: %w", err)
}
return nil
}
func randString() string {
key := make([]byte, 32)
rand.Read(key)
dst := make([]byte, base64.StdEncoding.EncodedLen(len(key)))
base64.StdEncoding.Encode(dst, key)
return string(dst)
}
func saveSettings(path string, settings Settings) error {
settingsJson, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("encode settings JSON: %w", err)
}
err = os.WriteFile(path, settingsJson, 0660)
if err != nil {
return fmt.Errorf("write settings: %w", err)
}
return nil
}
// InitConfig returns a new [*Config] loaded from the given configDir.
func InitConfig(configDir string) (*Config, error) {
err := os.MkdirAll(configDir, 0775)
if err != nil {
return nil, fmt.Errorf("init config directory: %w", err)
}
var emails []string
emailsFilePath := filepath.Join(configDir, EmailsFile)
_, err = os.Stat(emailsFilePath)
if errors.Is(err, os.ErrNotExist) {
emails = []string{}
} else {
emails, err = readLines(emailsFilePath)
if err != nil {
return nil, fmt.Errorf("get emails: %w", err)
}
}
var signature string
signatureFilePath := filepath.Join(configDir, SignatureFile)
_, err = os.Stat(signatureFilePath)
if errors.Is(err, os.ErrNotExist) {
signature = ""
} else {
signatureB, err := os.ReadFile(signatureFilePath)
if err != nil {
return nil, fmt.Errorf("get signature: %w", err)
}
signature = string(signatureB)
}
var secret string
secretFilePath := filepath.Join(configDir, SecretFile)
_, err = os.Stat(secretFilePath)
if errors.Is(err, os.ErrNotExist) {
secret = randString()
err := os.WriteFile(secretFilePath, []byte(secret+"\n"), 0660)
if err != nil {
return nil, fmt.Errorf("store generated secret: %w", err)
}
log.Print("generated secret")
} else {
secretB, err := os.ReadFile(secretFilePath)
if err != nil {
return nil, fmt.Errorf("get secret: %w", err)
}
secret = strings.TrimSpace(string(secretB))
}
var settings Settings
settingsFilePath := filepath.Join(configDir, SettingsFile)
_, err = os.Stat(settingsFilePath)
if errors.Is(err, os.ErrNotExist) {
settings = Settings{}
if err := saveSettings(settingsFilePath, settings); err != nil {
return nil, fmt.Errorf("init settings: %w", err)
}
} else {
settingsJson, err := os.ReadFile(settingsFilePath)
if err != nil {
return nil, fmt.Errorf("get settings: %w", err)
}
err = json.Unmarshal(settingsJson, &settings)
if err != nil {
return nil, fmt.Errorf("decode settings: %w", err)
}
}
return &Config{
Dir: configDir,
Emails: emails,
Signature: signature,
Secret: secret,
Settings: settings,
}, nil
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package control
import (
"crypto/sha256"
"encoding/base32"
"errors"
"fmt"
"io"
"log/syslog"
"os"
"path/filepath"
"slices"
"strings"
"github.com/club-1/newsletter-go/v3"
"github.com/club-1/newsletter-go/v3/mailer"
"github.com/club-1/newsletter-go/v3/messages"
)
const (
logIdentifier = "newsletter"
)
type Controller struct {
log *Logger
nl *newsletter.Newsletter
}
func NewController() (*Controller, error) {
sysLog, err := syslog.New(syslog.LOG_USER, logIdentifier)
if err != nil {
return nil, fmt.Errorf("init syslog: %w", err)
}
logger := &Logger{Writer: sysLog}
nl, err := newsletter.New()
if err != nil {
logger.Criticalf("init newsletter: %v", err)
return nil, err
}
messages.SetLanguage(nl.Config.Settings.Language)
logger.AddContext(nl.LocalUser)
return &Controller{
log: logger,
nl: nl,
}, nil
}
// response creates a new [mailer.Mail] directed towards the request's From
// address.
func (c *Controller) response(req *Request, subject string, body string) *mailer.Mail {
mail := c.nl.DefaultMail(subject, body)
mail.InReplyTo = fmt.Sprintf("<%s>", req.MessageID)
mail.To = req.From.Address
referencesBuilder := strings.Builder{}
for _, id := range req.Headers.References {
fmt.Fprintf(&referencesBuilder, "<%s> ", id)
}
fmt.Fprintf(&referencesBuilder, "<%s>", req.MessageID)
mail.References = referencesBuilder.String()
return mail
}
// sendResponse sends a reply to the received mail and logs the result.
func (c *Controller) sendResponse(req *Request, subject string, body string) {
mail := c.response(req, subject, body)
err := c.nl.Mailer.Send(mail)
if err != nil {
c.log.Errorf("error while sending response mail: %v", err)
} else {
c.log.Infof("response mail sent to %q", req.From.Address)
}
}
func hashString(s string) string {
sum := sha256.Sum256([]byte(s))
return base32.StdEncoding.EncodeToString(sum[0:32])
}
func (c *Controller) HashWithSecret(s string) string {
return hashString(s + c.nl.Config.Secret)
}
// GenerateId generates a Message-ID for this newsletter using the given hash.
func (c *Controller) GenerateId(hash string) string {
return fmt.Sprintf("%s-%s@%s", c.nl.LocalUser, hash, c.nl.Hostname)
}
func (c *Controller) GenerateConfirmID(req *Request) string {
hash := c.HashWithSecret(req.From.Address)
return c.GenerateId(hash)
}
// GetHashFromId retrieves the hash from the given messageID of the form: `USER-HASH@SERVER`
func (c *Controller) GetHashFromId(messageID string) (string, error) {
after, prefixFound := strings.CutPrefix(messageID, c.nl.LocalUser+"-")
before, suffixFound := strings.CutSuffix(after, "@"+c.nl.Hostname)
if !prefixFound || !suffixFound {
return "", errors.New("message ID doesn't match generated ID form")
}
return before, nil
}
func (c *Controller) subscribe(req *Request) error {
if slices.Contains(c.nl.Config.Emails, req.From.Address) {
c.log.Warningf("address is already subscribed: %s", req.From.Address)
c.sendResponse(
req,
messages.AlreadySubscribed_subject.Print(),
fmt.Sprintf(messages.AlreadySubscribed_body.Print(), c.nl.PostmasterAddr()),
)
return nil
}
var responseBody string
if c.nl.Config.Settings.Title == "" {
responseBody = fmt.Sprintf(messages.ConfirmSubscriptionAlt_body.Print(), c.nl.LocalUser)
} else {
responseBody = fmt.Sprintf(messages.ConfirmSubscription_body.Print(), c.nl.Config.Settings.Title)
}
mail := c.response(req, messages.ConfirmSubscription_subject.Print(), responseBody)
mail.ReplyTo = c.nl.SubscribeConfirmAddr()
mail.Id = fmt.Sprintf("<%s>", c.GenerateConfirmID(req))
err := c.nl.Mailer.Send(mail)
if err != nil {
return fmt.Errorf("send response mail: %v", err)
}
c.log.Infof("subscription confirmation mail sent to %q", req.From.Address)
return nil
}
func (c *Controller) subscribeConfirm(req *Request) error {
if slices.Contains(c.nl.Config.Emails, req.From.Address) {
c.log.Warningf("address is already subscribed: %s", req.From.Address)
c.sendResponse(
req,
messages.AlreadySubscribed_subject.Print(),
fmt.Sprintf(messages.AlreadySubscribed_body.Print(), c.nl.PostmasterAddr()),
)
return nil
}
if len(req.Headers.InReplyTo) == 0 {
return fmt.Errorf("missing In-Reply-To header")
}
messageId := string(req.Headers.InReplyTo[0])
if messageId != c.GenerateConfirmID(req) {
c.sendResponse(
req,
messages.VerificationFailed_subject.Print(),
fmt.Sprintf(messages.VerificationFailed_body.Print(), c.nl.LocalUserAddr()),
)
return fmt.Errorf("hash verification failed")
}
err := c.nl.Config.Subscribe(req.From.Address)
if err != nil {
return fmt.Errorf("error while subscribing address: %v", err)
}
c.log.Infof("address %q has been added to subscribers", req.From.Address)
var responseBody string
if c.nl.Config.Settings.Title == "" {
responseBody = fmt.Sprintf(messages.SuccessfullSubscriptionAlt_body.Print(), c.nl.LocalUser)
} else {
responseBody = fmt.Sprintf(messages.SuccessfullSubscription_body.Print(), c.nl.Config.Settings.Title)
}
c.sendResponse(req, messages.SuccessfullSubscription_subject.Print(), responseBody)
return nil
}
func (c *Controller) unsubscribe(req *Request) error {
err := c.nl.Config.Unsubscribe(req.From.Address)
switch {
case err == nil:
c.log.Infof("address %q removed from subscribers", req.From.Address)
case errors.Is(err, newsletter.ErrNotSubscribed):
c.log.Warningf("address is not subscribed: %s", req.From.Address)
default:
var responseBody string
if c.nl.Config.Settings.Title == "" {
responseBody = fmt.Sprintf(messages.UnsubscriptionFailedAlt_body.Print(), c.nl.LocalUser, c.nl.LocalUserAddr())
} else {
responseBody = fmt.Sprintf(messages.UnsubscriptionFailed_body.Print(), c.nl.Config.Settings.Title, c.nl.LocalUserAddr())
}
c.sendResponse(req, messages.UnsubscriptionFailed_subject.Print(), responseBody)
return fmt.Errorf("could not unsubscribe: %w", err)
}
var responseBody string
if c.nl.Config.Settings.Title == "" {
responseBody = fmt.Sprintf(messages.SuccessfullUnsubscriptionAlt_body.Print(), c.nl.LocalUser)
} else {
responseBody = fmt.Sprintf(messages.SuccessfullUnsubscription_body.Print(), c.nl.Config.Settings.Title)
}
c.sendResponse(req, messages.SuccessfullUnsubscription_subject.Print(), responseBody)
return nil
}
func (c *Controller) send(req *Request) error {
if req.From.Address != c.nl.LocalUserAddr() {
return fmt.Errorf("email From doesn't match user address")
}
body := req.Text
subject := req.Headers.Subject
hash := c.HashWithSecret(body + subject)
bodyFilePath := filepath.Join(os.TempDir(), "newsletter-send-"+hash+".body.txt")
subjectFilePath := filepath.Join(os.TempDir(), "newsletter-send-"+hash+".subject.txt")
var err error
err = os.WriteFile(bodyFilePath, []byte(body), 0660)
if err != nil {
return err
}
err = os.WriteFile(subjectFilePath, []byte(subject), 0660)
if err != nil {
return err
}
mail := c.nl.DefaultMail(subject, body)
mail.Id = c.GenerateId(hash)
mail.Body += fmt.Sprintf(messages.Newsletter_footer.Print(), c.nl.UnsubscribeAddr())
mail.Body += fmt.Sprintf("\n\n(this is a preview mail, if you want to confirm and send the newsletter to all the %v subscribers, reply to this email)", len(c.nl.Config.Emails))
mail.ReplyTo = c.nl.SendConfirmAddr()
return c.nl.SendPreviewMail(*mail)
}
func (c *Controller) sendConfirm(req *Request) error {
if req.From.Address != c.nl.LocalUserAddr() {
return fmt.Errorf("email From header doesn't match user address")
}
if len(req.Headers.InReplyTo) == 0 {
return fmt.Errorf("missing In-Reply-To header")
}
messageId := string(req.Headers.InReplyTo[0])
hash, err := c.GetHashFromId(messageId)
if err != nil {
return fmt.Errorf("In-Reply-To parsing error: %w", err)
}
bodyFilePath := filepath.Join(os.TempDir(), "newsletter-send-"+hash+".body.txt")
subjectFilePath := filepath.Join(os.TempDir(), "newsletter-send-"+hash+".subject.txt")
var body string
bodyB, err := os.ReadFile(bodyFilePath)
if err != nil {
return fmt.Errorf("read temporary body file: %w", err)
}
body = string(bodyB)
var subject string
subjectB, err := os.ReadFile(subjectFilePath)
if err != nil {
return fmt.Errorf("read temporary subject file: %w", err)
}
subject = string(subjectB)
mail := c.nl.DefaultMail(subject, body)
mail.Body += fmt.Sprintf(messages.Newsletter_footer.Print(), c.nl.UnsubscribeAddr())
errs := slices.Collect(c.nl.SendNews(mail))
err = errors.Join(errs...)
if err != nil {
return fmt.Errorf("sending newsletter: %w", err)
}
c.log.Infof("newsletter successfully sent to all the %v subscribers", len(c.nl.Config.Emails))
return nil
}
func (c *Controller) Handle(route string, r io.Reader) error {
c.log.AddContext(fmt.Sprintf("route %q", route))
request, err := ParseRequest(r)
if err != nil {
c.log.Errorf("parse email: %v", err)
return err // TODO: maybe here return a better error
}
c.log.AddContext(fmt.Sprintf("from %q", request.From.Address))
var cmdErr error
switch route {
case newsletter.RouteSubscribe:
cmdErr = c.subscribe(request)
case newsletter.RouteSubscribeConfirm:
cmdErr = c.subscribeConfirm(request)
case newsletter.RouteUnSubscribe:
cmdErr = c.unsubscribe(request)
case newsletter.RouteSend:
cmdErr = c.send(request)
case newsletter.RouteSendConfirm:
cmdErr = c.sendConfirm(request)
default:
c.log.Errorf("invalid sub command: %q", route)
}
if cmdErr != nil {
c.log.Errorf("error: %v", cmdErr)
}
return cmdErr
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package control
import (
"fmt"
"strings"
)
// Writer is the minimal interface required by [Logger] for its underlying writer.
type Writer interface {
Info(message string) error
Warning(message string) error
Err(message string) error
Crit(message string) error
}
// Logger is a basic wrapper around [syslog.Writer] that allows to add context
// and offers formatting methods.
type Logger struct {
Writer Writer
ctx strings.Builder
}
func (l *Logger) AddContext(v string) {
l.ctx.WriteString(v)
l.ctx.WriteString(": ")
}
func (l *Logger) Infof(format string, v ...any) error {
return l.Writer.Info(l.ctx.String() + fmt.Sprintf(format, v...))
}
func (l *Logger) Warningf(format string, v ...any) error {
return l.Writer.Warning(l.ctx.String() + fmt.Sprintf(format, v...))
}
func (l *Logger) Errorf(format string, v ...any) error {
return l.Writer.Err(l.ctx.String() + fmt.Sprintf(format, v...))
}
func (l *Logger) Criticalf(format string, v ...any) error {
return l.Writer.Crit(l.ctx.String() + fmt.Sprintf(format, v...))
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package control
import (
"errors"
"io"
"net/mail"
"github.com/mnako/letters"
)
// Request is an email received by the controller.
//
// It is a very basic wrapper around [letters.Email] that parses some
// additional header fields that we always want to be valid.
type Request struct {
letters.Email
From *mail.Address
MessageID string
}
func ParseRequest(r io.Reader) (*Request, error) {
email, err := letters.ParseEmail(r)
if err != nil {
return nil, err
}
if len(email.Headers.From) == 0 {
return nil, errors.New(`"From" field missing from header or empty`)
}
if email.Headers.MessageID == "" {
return nil, errors.New(`"Message-ID" field missing from header or empty`)
}
return &Request{
Email: email,
From: email.Headers.From[0],
MessageID: string(email.Headers.MessageID),
}, nil
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package mailer
type Mail struct {
From string
To string
Id string
InReplyTo string
References string
ReplyTo string
ListId string
ListUnsubscribe string
Subject string
Body string
}
type Mailer interface {
Send(m *Mail) error
}
var defaultMailer Mailer = &mailxMailer{}
func Default() Mailer {
return defaultMailer
}
// Send sends a mail using the default [Mailer].
//
// Deprecated: use [Default()] to get a usable [Mailer] instead.
func Send(mail *Mail) error {
return defaultMailer.Send(mail)
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package mailertest
import "github.com/club-1/newsletter-go/v3/mailer"
// Mailer is a [mailer.Mailer] that calls its underlying Handler upon Send().
type Mailer struct {
Handler func(m *mailer.Mail) error
}
// Send implements [mailer.Mailer].
func (m *Mailer) Send(mail *mailer.Mail) error {
return m.Handler(mail)
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package mailer
import (
"bytes"
"fmt"
"mime/quotedprintable"
"os/exec"
)
func quotedPrintable(s string) (*bytes.Buffer, error) {
var buf bytes.Buffer
w := quotedprintable.NewWriter(&buf)
_, err := w.Write([]byte(s))
if err != nil {
return nil, err
}
err = w.Close()
if err != nil {
return nil, err
}
return &buf, nil
}
type mailxMailer struct{}
func (m *mailxMailer) Send(mail *Mail) error {
if mail.To == "" {
return fmt.Errorf("no recipient address found")
}
encodedBody, err := quotedPrintable(mail.Body)
if err != nil {
return fmt.Errorf("encode body: %w", err)
}
args := []string{
"-s", mail.Subject,
"-r", mail.From,
"-a", "Content-Transfer-Encoding: quoted-printable",
"-a", "Content-Type: text/plain; charset=UTF-8",
}
type Header struct {
name string
value string
}
headers := []Header{
{"Message-Id", mail.Id},
{"In-Reply-To", mail.InReplyTo},
{"References", mail.References},
{"Reply-To", mail.ReplyTo},
{"List-Id", mail.ListId},
{"List-Unsubscribe", mail.ListUnsubscribe},
}
for _, header := range headers {
if header.value != "" {
args = append(args, "-a", header.name+": "+header.value)
}
}
args = append(args, "--", mail.To)
cmd := exec.Command("mailx", args...)
cmd.Stdin = encodedBody
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("execute command: %w: %s", err, out)
}
return nil
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package messages
type Language string
const (
LangEnglish Language = "en"
LangFrench Language = "fr"
)
var language Language
func SetLanguage(l Language) {
language = l
}
type Message struct {
en string
fr string
}
func (m Message) Print() string {
switch language {
case LangEnglish:
return m.en
case LangFrench:
return m.fr
default:
return m.en
}
}
// This file is part of club-1/newsletter-go.
//
// Copyright (c) 2026 CLUB1 Members <contact@club1.fr>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: AGPL-3.0-or-later
package newsletter
import (
"fmt"
"iter"
"os"
"os/user"
"path/filepath"
"time"
"github.com/club-1/newsletter-go/v3/mailer"
)
const (
ConfigPath = ".config/newsletter"
RouteSubscribe = "subscribe"
RouteSubscribeConfirm = "subscribe-confirm"
RouteUnSubscribe = "unsubscribe"
RouteSend = "send"
RouteSendConfirm = "send-confirm"
)
var (
Routes = [...]string{RouteSubscribe, RouteSubscribeConfirm, RouteUnSubscribe, RouteSend, RouteSendConfirm}
)
type Newsletter struct {
Config *Config
Hostname string
LocalUser string
Mailer mailer.Mailer
}
// New creates a new [Newsletter] instance and initialises it.
//
// It reads information about the system, the current user and its config
// directory, then loads the config from the filesystem.
func New() (*Newsletter, error) {
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
user, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get local user: %w", err)
}
homeDir := os.Getenv("HOME")
if homeDir == "" {
homeDir = user.HomeDir
}
config, err := InitConfig(filepath.Join(homeDir, ConfigPath))
if err != nil {
return nil, fmt.Errorf("init config: %w", err)
}
return &Newsletter{
Config: config,
Hostname: hostname,
LocalUser: user.Username,
Mailer: mailer.Default(),
}, nil
}
func (nl *Newsletter) PostmasterAddr() string {
return "postmaster@" + nl.Hostname
}
func (nl *Newsletter) LocalUserAddr() string {
return nl.LocalUser + "@" + nl.Hostname
}
func (nl *Newsletter) FromHdr() string {
if nl.Config.Settings.DisplayName != "" {
return fmt.Sprintf(`%s <%s>`, nl.Config.Settings.DisplayName, nl.LocalUserAddr())
} else {
return fmt.Sprintf("<%s>", nl.LocalUserAddr())
}
}
func (nl *Newsletter) ListIdHdr() string {
if nl.Config.Settings.DisplayName != "" {
return fmt.Sprintf(`%s <%s.%s>`, nl.Config.Settings.DisplayName, nl.LocalUser, nl.Hostname)
} else {
return fmt.Sprintf("<%s.%s>", nl.LocalUser, nl.Hostname)
}
}
func (nl *Newsletter) UnsubscribeAddr() string {
return nl.LocalUser + "+" + RouteUnSubscribe + "@" + nl.Hostname
}
func (nl *Newsletter) ListUnsubscribeHdr() string {
return fmt.Sprintf("<mailto:%s>", nl.UnsubscribeAddr())
}
func (nl *Newsletter) SubscribeConfirmAddr() string {
return nl.LocalUser + "+" + RouteSubscribeConfirm + "@" + nl.Hostname
}
func (nl *Newsletter) SendConfirmAddr() string {
return nl.LocalUser + "+" + RouteSendConfirm + "@" + nl.Hostname
}
// DefaultMail creates a new [mailer.Mail] struct with default values.
func (nl *Newsletter) DefaultMail(subject string, body string) *mailer.Mail {
if nl.Config.Settings.Title != "" {
subject = "[" + nl.Config.Settings.Title + "] " + subject
}
if nl.Config.Signature != "" {
body = body + "\n\n-- \n" + nl.Config.Signature
}
return &mailer.Mail{
From: nl.FromHdr(),
ListId: nl.ListIdHdr(),
ListUnsubscribe: nl.ListUnsubscribeHdr(),
Subject: subject,
Body: body,
}
}
// SendPreviewMail sends a preview of the given mail to the owner of the
// newsletter, appending (preview) to the original subject.
func (nl *Newsletter) SendPreviewMail(mail mailer.Mail) error {
mail.To = nl.LocalUserAddr()
mail.Subject += " (preview)"
err := nl.Mailer.Send(&mail)
if err != nil {
return fmt.Errorf("send preview mail: %w", err)
}
fmt.Printf("๐จ preview email sent to %s\n", nl.LocalUserAddr())
return nil
}
// SendNews sends the given mail to all the addresses subscribed to the
// newsletter.
func (nl *Newsletter) SendNews(mail *mailer.Mail) iter.Seq[error] {
return func(yield func(error) bool) {
for _, address := range nl.Config.Emails {
time.Sleep(200 * time.Millisecond)
mail.To = address
if !yield(nl.Mailer.Send(mail)) {
return
}
}
}
}