From 871e4013dcb07671ebef64126f9484212a3e282e Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 19 May 2026 18:29:16 +0200 Subject: [PATCH 1/7] docs: create announcements.md --- announcements.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 announcements.md diff --git a/announcements.md b/announcements.md new file mode 100644 index 00000000..dfbfad69 --- /dev/null +++ b/announcements.md @@ -0,0 +1 @@ +# Announcements \ No newline at end of file From 70485f7d26e0db4147603bdd5c160b3416a3a1eb Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 20 May 2026 10:01:58 +0200 Subject: [PATCH 2/7] docs: update announcements.md --- announcements.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/announcements.md b/announcements.md index dfbfad69..a74faf35 100644 --- a/announcements.md +++ b/announcements.md @@ -1 +1,19 @@ -# Announcements \ No newline at end of file +# Announcements + +## Announcement 2 + +Hello announcements! + +- This is the first announcement. +- Stay tuned for more updates. + +[https://github.com/ivpn/mailx/releases/tag/0.2.4](https://github.com/ivpn/mailx/releases/tag/0.2.4) + +## Announcement 1 + +Hello announcements! + +- This is the first announcement. +- Stay tuned for more updates. + +[https://github.com/ivpn/mailx/releases/tag/0.2.1](https://github.com/ivpn/mailx/releases/tag/0.2.1) From a269f8cb681fd3454be9efe43b1217090a1e3178 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 20 May 2026 10:28:53 +0200 Subject: [PATCH 3/7] feat(api): create announcements.go --- api/.env.sample | 1 + api/config/config.go | 2 + api/internal/transport/api/announcements.go | 136 ++++++++++++++++++++ api/internal/transport/api/routes.go | 1 + 4 files changed, 140 insertions(+) create mode 100644 api/internal/transport/api/announcements.go diff --git a/api/.env.sample b/api/.env.sample index add73d61..648f4c2c 100644 --- a/api/.env.sample +++ b/api/.env.sample @@ -19,6 +19,7 @@ SIGNUP_WEBHOOK_PSK= PREAUTH_URL= PREAUTH_PSK= PREAUTH_TTL=60m +ANNOUNCEMENTS_URL= APP_PORT=3001 diff --git a/api/config/config.go b/api/config/config.go index b4a5eab6..f7f65a4b 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -27,6 +27,7 @@ type APIConfig struct { PreauthURL string PreauthPSK string PreauthTTL time.Duration + AnnouncementsURL string } type DBConfig struct { @@ -168,6 +169,7 @@ func New() (Config, error) { PreauthURL: os.Getenv("PREAUTH_URL"), PreauthPSK: os.Getenv("PREAUTH_PSK"), PreauthTTL: preauthTTL, + AnnouncementsURL: os.Getenv("ANNOUNCEMENTS_URL"), }, DB: DBConfig{ Hosts: dbHosts, diff --git a/api/internal/transport/api/announcements.go b/api/internal/transport/api/announcements.go new file mode 100644 index 00000000..a4f78ea0 --- /dev/null +++ b/api/internal/transport/api/announcements.go @@ -0,0 +1,136 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "github.com/gofiber/fiber/v2" +) + +type Announcement struct { + Title string `json:"title"` + Body string `json:"body"` + Link string `json:"link"` +} + +// @Summary Get announcements +// @Description Get list of announcements from the public announcements file +// @Produce json +// @Success 200 {array} Announcement +// @Failure 500 {object} ErrorRes +// @Router /announcements [get] +func (h *Handler) GetAnnouncements(c *fiber.Ctx) error { + resp, err := http.Get(h.Cfg.AnnouncementsURL) //nolint:noctx + if err != nil { + return c.Status(500).JSON(ErrorRes{Error: "failed to fetch announcements"}) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return c.Status(500).JSON(ErrorRes{Error: fmt.Sprintf("unexpected status fetching announcements: %d", resp.StatusCode)}) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return c.Status(500).JSON(ErrorRes{Error: "failed to read announcements"}) + } + + announcements := parseAnnouncements(string(data)) + return c.JSON(announcements) +} + +var linkPattern = regexp.MustCompile(`^\[.*?\]\((https?://[^)]+)\)$`) + +func parseAnnouncements(content string) []Announcement { + lines := strings.Split(content, "\n") + var announcements []Announcement + var current *Announcement + var bodyLines []string + + flush := func() { + if current == nil { + return + } + current.Body = bodyLinesToHTML(bodyLines) + announcements = append(announcements, *current) + current = nil + bodyLines = nil + } + + for _, line := range lines { + line = strings.TrimRight(line, "\r") + + if strings.HasPrefix(line, "## ") { + flush() + current = &Announcement{Title: strings.TrimPrefix(line, "## ")} + continue + } + + if current == nil { + continue + } + + if m := linkPattern.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + current.Link = m[1] + continue + } + + bodyLines = append(bodyLines, line) + } + + flush() + return announcements +} + +func bodyLinesToHTML(lines []string) string { + var sb strings.Builder + var listItems []string + var paraLines []string + + flushList := func() { + if len(listItems) == 0 { + return + } + sb.WriteString("
    ") + for _, item := range listItems { + sb.WriteString("
  • ") + sb.WriteString(item) + sb.WriteString("
  • ") + } + sb.WriteString("
") + listItems = nil + } + + flushPara := func() { + text := strings.TrimSpace(strings.Join(paraLines, " ")) + if text != "" { + sb.WriteString("

") + sb.WriteString(text) + sb.WriteString("

") + } + paraLines = nil + } + + for _, line := range lines { + if strings.TrimSpace(line) == "" { + flushPara() + flushList() + continue + } + if strings.HasPrefix(line, "- ") { + flushPara() + listItems = append(listItems, strings.TrimPrefix(line, "- ")) + continue + } + flushList() + paraLines = append(paraLines, line) + } + + flushPara() + flushList() + + return sb.String() +} diff --git a/api/internal/transport/api/routes.go b/api/internal/transport/api/routes.go index 6f47f3cc..83425146 100644 --- a/api/internal/transport/api/routes.go +++ b/api/internal/transport/api/routes.go @@ -23,6 +23,7 @@ func (h *Handler) SetupRoutes(cfg config.APIConfig) { h.Server.Use(helmet.New()) h.Server.Use(healthcheck.New()) + h.Server.Get("/v1/announcements", limiter.New(), h.GetAnnouncements) h.Server.Post("/v1/register", limiter.New(), h.Register) h.Server.Post("/v1/login", limit.New(5, 10*time.Minute), h.Login) h.Server.Post("/v1/initiatepasswordreset", limiter.New(), h.InitiatePasswordReset) From f94a2699ba55d1e0ed24adff77f083f8bb4f70cc Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 20 May 2026 11:24:30 +0200 Subject: [PATCH 4/7] feat(app): update Announcements.vue --- app/src/api/announcements.ts | 5 ++ app/src/components/Announcements.vue | 72 ++++++++++++++++++++++++++++ app/src/router.ts | 6 +++ 3 files changed, 83 insertions(+) create mode 100644 app/src/api/announcements.ts create mode 100644 app/src/components/Announcements.vue diff --git a/app/src/api/announcements.ts b/app/src/api/announcements.ts new file mode 100644 index 00000000..f53c5335 --- /dev/null +++ b/app/src/api/announcements.ts @@ -0,0 +1,5 @@ +import { api } from './api.ts' + +export const announcementsApi = { + getList: () => api.get('/announcements'), +} diff --git a/app/src/components/Announcements.vue b/app/src/components/Announcements.vue new file mode 100644 index 00000000..a8f2524b --- /dev/null +++ b/app/src/components/Announcements.vue @@ -0,0 +1,72 @@ + + + diff --git a/app/src/router.ts b/app/src/router.ts index a7a142e4..1f434fa6 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -15,6 +15,7 @@ import ResetPassword from './components/ResetPassword.vue' import Terms from './components/Terms.vue' import Privacy from './components/Privacy.vue' import Faq from './components/Faq.vue' +import Announcements from './components/Announcements.vue' import NotFound from './components/NotFound.vue' import Landing from './components/Landing.vue' import { type IStaticMethods } from 'preline/preline' @@ -123,6 +124,11 @@ const routes: RouteRecordRaw[] = [ name: `${AppName}: FAQ`, component: Faq }, + { + path: '/announcements', + name: `${AppName}: Announcements`, + component: Announcements + }, { path: '/:pathMatch(.*)*', name: '404: Not Found', From 10c902051522646cb57fd9cb61988af2f273d8a7 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 19 May 2026 18:55:43 +0200 Subject: [PATCH 5/7] refactor(service): update message.go --- api/internal/service/message.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/internal/service/message.go b/api/internal/service/message.go index eee19983..b70518b3 100644 --- a/api/internal/service/message.go +++ b/api/internal/service/message.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "slices" "ivpn.net/email/api/internal/model" ) @@ -92,9 +93,9 @@ func (s *Service) RemoveLastMessage(ctx context.Context, aliasId string, userId } var lastMessageID uint - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type == typ { - lastMessageID = messages[i].ID + for _, m := range slices.Backward(messages) { + if m.Type == typ { + lastMessageID = m.ID break } } From 2117c865995457dbe81e7868b8e9fc740d2c22ba Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Wed, 20 May 2026 11:47:08 +0200 Subject: [PATCH 6/7] feat(app): update Announcements.vue --- app/src/components/Announcements.vue | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/components/Announcements.vue b/app/src/components/Announcements.vue index a8f2524b..0b941cf1 100644 --- a/app/src/components/Announcements.vue +++ b/app/src/components/Announcements.vue @@ -9,15 +9,15 @@

News


-
+

Loading...

-
+

{{ error }}

-
+

No news.

@@ -66,6 +66,10 @@ const getList = async () => { } catch (err) { if (axios.isAxiosError(err)) { error.value = err.response?.data.error || err.message + + if (err.response?.status === 429) { + error.value = 'Too many requests, please try again later.' + } } } } From 607bf2e52380ed969fa81ff3ed2168f9d0582f3d Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Sat, 30 May 2026 10:10:07 +0200 Subject: [PATCH 7/7] feat(app): update Sidebar.vue --- app/src/components/Sidebar.vue | 6 +++--- app/src/router.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/components/Sidebar.vue b/app/src/components/Sidebar.vue index b35a7d29..b7792767 100644 --- a/app/src/components/Sidebar.vue +++ b/app/src/components/Sidebar.vue @@ -52,9 +52,9 @@

- Support: - Email / - FAQ + Support / + FAQ / + News

diff --git a/app/src/router.ts b/app/src/router.ts index 4c04a295..89579b19 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -131,8 +131,8 @@ const routes: RouteRecordRaw[] = [ component: Faq }, { - path: '/announcements', - name: `${AppName}: Announcements`, + path: '/news', + name: `${AppName}: News`, component: Announcements }, {