From d915e5fb648794563f5a913a01aede2be50ee6fc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 21 Mar 2026 21:33:55 +0100 Subject: [PATCH 01/10] feat: add all multi-part TLDs for domain validation #81 --- app/api/favicon/route.ts | 378 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 352 insertions(+), 26 deletions(-) diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index a3e94e69..f003957e 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -40,32 +40,358 @@ function isValidDomain(domain: string): boolean { // Known multi-part TLDs where the registrable domain includes one extra label. const MULTI_PART_TLDS = new Set([ - "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", - "co.jp", "or.jp", "ne.jp", "ac.jp", "go.jp", - "co.kr", "or.kr", "go.kr", "ac.kr", - "co.in", "net.in", "org.in", "ac.in", "gov.in", - "co.nz", "org.nz", "net.nz", "govt.nz", "ac.nz", - "co.za", "org.za", "net.za", "gov.za", "ac.za", - "com.au", "net.au", "org.au", "edu.au", "gov.au", - "com.br", "net.br", "org.br", "edu.br", "gov.br", - "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", - "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx", - "com.ar", "net.ar", "org.ar", "gob.ar", "edu.ar", - "com.tw", "net.tw", "org.tw", "edu.tw", "gov.tw", - "com.hk", "net.hk", "org.hk", "edu.hk", "gov.hk", - "com.sg", "net.sg", "org.sg", "edu.sg", "gov.sg", - "com.my", "net.my", "org.my", "edu.my", "gov.my", - "com.ph", "net.ph", "org.ph", "edu.ph", "gov.ph", - "com.pk", "net.pk", "org.pk", "edu.pk", "gov.pk", - "com.ng", "net.ng", "org.ng", "edu.ng", "gov.ng", - "co.il", "org.il", "net.il", "ac.il", "gov.il", - "co.th", "or.th", "ac.th", "go.th", "in.th", - "co.id", "or.id", "ac.id", "go.id", "web.id", - "com.tr", "net.tr", "org.tr", "edu.tr", "gov.tr", - "com.ua", "net.ua", "org.ua", "edu.ua", "gov.ua", - "com.eg", "net.eg", "org.eg", "edu.eg", "gov.eg", - "com.sa", "net.sa", "org.sa", "edu.sa", "gov.sa", - "co.ke", "or.ke", "ac.ke", "go.ke", "ne.ke", + // .ac + "com.ac", "gov.ac", "mil.ac", "net.ac", "org.ac", + // .ae + "ac.ae", "co.ae", "gov.ae", "mil.ae", "name.ae", "net.ae", "org.ae", "pro.ae", "sch.ae", + // .af + "com.af", "edu.af", "gov.af", "net.af", "org.af", + // .al + "com.al", "edu.al", "gov.al", "mil.al", "net.al", "org.al", + // .ao + "co.ao", "ed.ao", "gv.ao", "it.ao", "og.ao", "pb.ao", + // .ar + "com.ar", "edu.ar", "gob.ar", "gov.ar", "int.ar", "mil.ar", "net.ar", "org.ar", "tur.ar", + // .at + "ac.at", "co.at", "gv.at", "or.at", + // .au + "asn.au", "com.au", "csiro.au", "edu.au", "gov.au", "id.au", "net.au", "org.au", + // .ba + "co.ba", "com.ba", "edu.ba", "gov.ba", "mil.ba", "net.ba", "org.ba", "rs.ba", + "unbi.ba", "unmo.ba", "unsa.ba", "untz.ba", "unze.ba", + // .bb + "biz.bb", "co.bb", "com.bb", "edu.bb", "gov.bb", "info.bb", "net.bb", "org.bb", + "store.bb", "tv.bb", + // .bh + "biz.bh", "cc.bh", "com.bh", "edu.bh", "gov.bh", "info.bh", "net.bh", "org.bh", + // .bn + "com.bn", "edu.bn", "gov.bn", "net.bn", "org.bn", + // .bo + "com.bo", "edu.bo", "gob.bo", "gov.bo", "int.bo", "mil.bo", "net.bo", "org.bo", "tv.bo", + // .br + "adm.br", "adv.br", "agr.br", "am.br", "arq.br", "art.br", "ato.br", "b.br", + "bio.br", "blog.br", "bmd.br", "cim.br", "cng.br", "cnt.br", "com.br", "coop.br", + "ecn.br", "edu.br", "eng.br", "esp.br", "etc.br", "eti.br", "far.br", "flog.br", + "fm.br", "fnd.br", "fot.br", "fst.br", "g12.br", "ggf.br", "gov.br", "imb.br", + "ind.br", "inf.br", "jor.br", "jus.br", "lel.br", "mat.br", "med.br", "mil.br", + "mus.br", "net.br", "nom.br", "not.br", "ntr.br", "odo.br", "org.br", "ppg.br", + "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", + "trd.br", "tur.br", "tv.br", "vet.br", "vlog.br", "wiki.br", "zlg.br", + // .bs + "com.bs", "edu.bs", "gov.bs", "net.bs", "org.bs", + // .bz + "com.bz", "edu.bz", "gov.bz", "net.bz", "org.bz", + // .ca + "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", + "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca", + // .ck + "biz.ck", "co.ck", "edu.ck", "gen.ck", "gov.ck", "info.ck", "net.ck", "org.ck", + // .cn + "ac.cn", "ah.cn", "bj.cn", "com.cn", "cq.cn", "edu.cn", "fj.cn", "gd.cn", + "gov.cn", "gs.cn", "gx.cn", "gz.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", + "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mil.cn", "net.cn", + "nm.cn", "nx.cn", "org.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", + "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn", + // .co + "com.co", "edu.co", "gov.co", "mil.co", "net.co", "nom.co", "org.co", + // .cr + "ac.cr", "co.cr", "ed.cr", "fi.cr", "go.cr", "or.cr", "sa.cr", + // .cy + "ac.cy", "biz.cy", "com.cy", "ekloges.cy", "gov.cy", "ltd.cy", "name.cy", + "net.cy", "org.cy", "parliament.cy", "press.cy", "pro.cy", "tm.cy", + // .do + "art.do", "com.do", "edu.do", "gob.do", "gov.do", "mil.do", "net.do", "org.do", + "sld.do", "web.do", + // .dz + "art.dz", "asso.dz", "com.dz", "edu.dz", "gov.dz", "net.dz", "org.dz", "pol.dz", + // .ec + "com.ec", "edu.ec", "fin.ec", "gov.ec", "info.ec", "med.ec", "mil.ec", "net.ec", + "org.ec", "pro.ec", + // .eg + "com.eg", "edu.eg", "eun.eg", "gov.eg", "mil.eg", "name.eg", "net.eg", "org.eg", "sci.eg", + // .er + "com.er", "edu.er", "gov.er", "ind.er", "mil.er", "net.er", "org.er", "rochest.er", "w.er", + // .es + "com.es", "edu.es", "gob.es", "nom.es", "org.es", + // .et + "biz.et", "com.et", "edu.et", "gov.et", "info.et", "name.et", "net.et", "org.et", + // .fj + "ac.fj", "biz.fj", "com.fj", "info.fj", "mil.fj", "name.fj", "net.fj", "org.fj", "pro.fj", + // .fk + "ac.fk", "co.fk", "gov.fk", "net.fk", "nom.fk", "org.fk", + // .fr + "asso.fr", "com.fr", "gouv.fr", "nom.fr", "prd.fr", "presse.fr", "tm.fr", + // .gg + "co.gg", "net.gg", "org.gg", + // .gh + "com.gh", "edu.gh", "gov.gh", "mil.gh", "org.gh", + // .gn + "ac.gn", "com.gn", "gov.gn", "net.gn", "org.gn", + // .gr + "com.gr", "edu.gr", "gov.gr", "mil.gr", "net.gr", "org.gr", + // .gt + "com.gt", "edu.gt", "gob.gt", "ind.gt", "mil.gt", "net.gt", "org.gt", + // .gu + "com.gu", "edu.gu", "gov.gu", "net.gu", "org.gu", + // .hk + "com.hk", "edu.hk", "gov.hk", "idv.hk", "net.hk", "org.hk", + // .id + "ac.id", "co.id", "go.id", "mil.id", "net.id", "or.id", "sch.id", "web.id", + // .il + "ac.il", "co.il", "gov.il", "idf.il", "k12.il", "muni.il", "net.il", "org.il", + // .in + "4fd.in", "ac.in", "co.in", "edu.in", "ernet.in", "firm.in", "gen.in", "gov.in", + "ind.in", "mil.in", "net.in", "nic.in", "org.in", "res.in", + // .iq + "com.iq", "edu.iq", "gov.iq", "mil.iq", "net.iq", "org.iq", + // .ir + "ac.ir", "co.ir", "dnssec.ir", "gov.ir", "id.ir", "net.ir", "org.ir", "sch.ir", + // .it + "edu.it", "gov.it", + // .je + "co.je", "net.je", "org.je", + // .jo + "com.jo", "edu.jo", "gov.jo", "mil.jo", "name.jo", "net.jo", "org.jo", "sch.jo", + // .jp + "ac.jp", "ad.jp", "co.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp", "ne.jp", "or.jp", + // .ke + "ac.ke", "co.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "ne.ke", "or.ke", "sc.ke", + // .kh + "com.kh", "edu.kh", "gov.kh", "mil.kh", "net.kh", "org.kh", "per.kh", + // .ki + "biz.ki", "com.ki", "de.ki", "edu.ki", "gov.ki", "info.ki", "mob.ki", "net.ki", + "org.ki", "tel.ki", + // .km + "asso.km", "com.km", "coop.km", "edu.km", "gouv.km", "medecin.km", "mil.km", + "nom.km", "notaires.km", "pharmaciens.km", "presse.km", "tm.km", "veterinaire.km", + // .kn + "edu.kn", "gov.kn", "net.kn", "org.kn", + // .kr + "ac.kr", "busan.kr", "chungbuk.kr", "chungnam.kr", "co.kr", "daegu.kr", + "daejeon.kr", "es.kr", "gangwon.kr", "go.kr", "gwangju.kr", "gyeongbuk.kr", + "gyeonggi.kr", "gyeongnam.kr", "hs.kr", "incheon.kr", "jeju.kr", "jeonbuk.kr", + "jeonnam.kr", "kg.kr", "mil.kr", "ms.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", + "sc.kr", "seoul.kr", "ulsan.kr", + // .kw + "com.kw", "edu.kw", "gov.kw", "net.kw", "org.kw", + // .ky + "com.ky", "edu.ky", "gov.ky", "net.ky", "org.ky", + // .kz + "com.kz", "edu.kz", "gov.kz", "mil.kz", "net.kz", "org.kz", + // .lb + "com.lb", "edu.lb", "gov.lb", "net.lb", "org.lb", + // .lk + "assn.lk", "com.lk", "edu.lk", "gov.lk", "grp.lk", "hotel.lk", "int.lk", "ltd.lk", + "net.lk", "ngo.lk", "org.lk", "sch.lk", "soc.lk", "web.lk", + // .lr + "com.lr", "edu.lr", "gov.lr", "net.lr", "org.lr", + // .lv + "asn.lv", "com.lv", "conf.lv", "edu.lv", "gov.lv", "id.lv", "mil.lv", "net.lv", "org.lv", + // .ly + "com.ly", "edu.ly", "gov.ly", "id.ly", "med.ly", "net.ly", "org.ly", "plc.ly", "sch.ly", + // .ma + "ac.ma", "co.ma", "gov.ma", "net.ma", "org.ma", "press.ma", + // .mc + "asso.mc", "tm.mc", + // .me + "ac.me", "co.me", "edu.me", "gov.me", "its.me", "net.me", "org.me", "priv.me", + // .mg + "com.mg", "edu.mg", "gov.mg", "mil.mg", "nom.mg", "org.mg", "prd.mg", "tm.mg", + // .mk + "com.mk", "edu.mk", "gov.mk", "inf.mk", "name.mk", "net.mk", "org.mk", "pro.mk", + // .ml + "com.ml", "edu.ml", "gov.ml", "net.ml", "org.ml", "presse.ml", + // .mn + "edu.mn", "gov.mn", "org.mn", + // .mo + "com.mo", "edu.mo", "gov.mo", "net.mo", "org.mo", + // .mt + "com.mt", "edu.mt", "gov.mt", "net.mt", "org.mt", + // .mu + "ac.mu", "co.mu", "com.mu", "gov.mu", "net.mu", "or.mu", "org.mu", + // .mv + "aero.mv", "biz.mv", "com.mv", "coop.mv", "edu.mv", "gov.mv", "info.mv", + "int.mv", "mil.mv", "museum.mv", "name.mv", "net.mv", "org.mv", "pro.mv", + // .mw + "ac.mw", "co.mw", "com.mw", "coop.mw", "edu.mw", "gov.mw", "int.mw", + "museum.mw", "net.mw", "org.mw", + // .mx + "com.mx", "edu.mx", "gob.mx", "net.mx", "org.mx", + // .my + "com.my", "edu.my", "gov.my", "mil.my", "name.my", "net.my", "org.my", "sch.my", + // .mz + "ac.mz", "co.mz", "edu.mz", "gov.mz", "org.mz", + // .na + "co.na", "com.na", + // .nf + "arts.nf", "com.nf", "firm.nf", "info.nf", "net.nf", "other.nf", "per.nf", + "rec.nf", "store.nf", "web.nf", + // .ng + "biz.ng", "com.ng", "edu.ng", "gov.ng", "mil.ng", "mobi.ng", "name.ng", + "net.ng", "org.ng", "sch.ng", + // .ni + "ac.ni", "co.ni", "com.ni", "edu.ni", "gob.ni", "mil.ni", "net.ni", "nom.ni", "org.ni", + // .np + "com.np", "edu.np", "gov.np", "mil.np", "net.np", "org.np", + // .nr + "biz.nr", "com.nr", "edu.nr", "gov.nr", "info.nr", "net.nr", "org.nr", + // .nz + "ac.nz", "co.nz", "cri.nz", "geek.nz", "gen.nz", "govt.nz", "health.nz", + "iwi.nz", "maori.nz", "mil.nz", "net.nz", "org.nz", "parliament.nz", "school.nz", + // .om + "ac.om", "biz.om", "co.om", "com.om", "edu.om", "gov.om", "med.om", "mil.om", + "museum.om", "net.om", "org.om", "pro.om", "sch.om", + // .pa + "abo.pa", "ac.pa", "com.pa", "edu.pa", "gob.pa", "ing.pa", "med.pa", "net.pa", + "nom.pa", "org.pa", "sld.pa", + // .pe + "com.pe", "edu.pe", "gob.pe", "mil.pe", "net.pe", "nom.pe", "org.pe", "sld.pe", + // .ph + "com.ph", "edu.ph", "gov.ph", "i.ph", "mil.ph", "net.ph", "ngo.ph", "org.ph", + // .pk + "biz.pk", "com.pk", "edu.pk", "fam.pk", "gob.pk", "gok.pk", "gon.pk", "gop.pk", + "gos.pk", "gov.pk", "net.pk", "org.pk", "web.pk", + // .pl + "art.pl", "bialystok.pl", "biz.pl", "com.pl", "edu.pl", "gda.pl", "gdansk.pl", + "gorzow.pl", "gov.pl", "info.pl", "katowice.pl", "krakow.pl", "lodz.pl", + "lublin.pl", "mil.pl", "net.pl", "ngo.pl", "olsztyn.pl", "org.pl", "poznan.pl", + "pwr.pl", "radom.pl", "slupsk.pl", "szczecin.pl", "torun.pl", "warszawa.pl", + "waw.pl", "wroc.pl", "wroclaw.pl", "zgora.pl", + // .pr + "ac.pr", "biz.pr", "com.pr", "edu.pr", "est.pr", "gov.pr", "info.pr", "isla.pr", + "name.pr", "net.pr", "org.pr", "pro.pr", "prof.pr", + // .ps + "com.ps", "edu.ps", "gov.ps", "net.ps", "org.ps", "plo.ps", "sec.ps", + // .pt + "com.pt", "edu.pt", "gov.pt", "int.pt", "net.pt", "nome.pt", "org.pt", "publ.pt", + // .pw + "belau.pw", "co.pw", "ed.pw", "go.pw", "ne.pw", "or.pw", + // .py + "com.py", "edu.py", "gov.py", "mil.py", "net.py", "org.py", + // .qa + "com.qa", "edu.qa", "gov.qa", "mil.qa", "net.qa", "org.qa", + // .re + "asso.re", "com.re", "nom.re", + // .ro + "arts.ro", "com.ro", "firm.ro", "info.ro", "nom.ro", "nt.ro", "org.ro", + "rec.ro", "store.ro", "tm.ro", "www.ro", + // .rs + "ac.rs", "co.rs", "edu.rs", "gov.rs", "in.rs", "org.rs", + // .ru + "ac.ru", "adygeya.ru", "altai.ru", "amur.ru", "arkhangelsk.ru", "astrakhan.ru", + "bashkiria.ru", "belgorod.ru", "bir.ru", "bryansk.ru", "buryatia.ru", "cbg.ru", + "chel.ru", "chelyabinsk.ru", "chita.ru", "chukotka.ru", "chuvashia.ru", "com.ru", + "dagestan.ru", "e-burg.ru", "edu.ru", "gov.ru", "grozny.ru", "int.ru", + "irkutsk.ru", "ivanovo.ru", "izhevsk.ru", "jar.ru", "joshkar-ola.ru", + "kalmykia.ru", "kaluga.ru", "kamchatka.ru", "karelia.ru", "kazan.ru", "kchr.ru", + "kemerovo.ru", "khabarovsk.ru", "khakassia.ru", "khv.ru", "kirov.ru", + "koenig.ru", "komi.ru", "kostroma.ru", "kranoyarsk.ru", "kuban.ru", "kurgan.ru", + "kursk.ru", "lipetsk.ru", "magadan.ru", "mari.ru", "mari-el.ru", "marine.ru", + "mil.ru", "mordovia.ru", "mosreg.ru", "msk.ru", "murmansk.ru", "nalchik.ru", + "net.ru", "nnov.ru", "nov.ru", "novosibirsk.ru", "nsk.ru", "omsk.ru", + "orenburg.ru", "org.ru", "oryol.ru", "penza.ru", "perm.ru", "pp.ru", "pskov.ru", + "ptz.ru", "rnd.ru", "ryazan.ru", "sakhalin.ru", "samara.ru", "saratov.ru", + "simbirsk.ru", "smolensk.ru", "spb.ru", "stavropol.ru", "stv.ru", "surgut.ru", + "tambov.ru", "tatarstan.ru", "tom.ru", "tomsk.ru", "tsaritsyn.ru", "tsk.ru", + "tula.ru", "tuva.ru", "tver.ru", "tyumen.ru", "udm.ru", "udmurtia.ru", + "ulan-ude.ru", "vladikavkaz.ru", "vladimir.ru", "vladivostok.ru", "volgograd.ru", + "vologda.ru", "voronezh.ru", "vrn.ru", "vyatka.ru", "yakutia.ru", "yamal.ru", + "yekaterinburg.ru", "yuzhno-sakhalinsk.ru", + // .rw + "ac.rw", "co.rw", "com.rw", "edu.rw", "gouv.rw", "gov.rw", "int.rw", "mil.rw", "net.rw", + // .sa + "com.sa", "edu.sa", "gov.sa", "med.sa", "net.sa", "org.sa", "pub.sa", "sch.sa", + // .sb + "com.sb", "edu.sb", "gov.sb", "net.sb", "org.sb", + // .sc + "com.sc", "edu.sc", "gov.sc", "net.sc", "org.sc", + // .sd + "com.sd", "edu.sd", "gov.sd", "info.sd", "med.sd", "net.sd", "org.sd", "tv.sd", + // .se + "a.se", "ac.se", "b.se", "bd.se", "c.se", "d.se", "e.se", "f.se", "g.se", + "h.se", "i.se", "k.se", "l.se", "m.se", "n.se", "o.se", "org.se", "p.se", + "parti.se", "pp.se", "press.se", "r.se", "s.se", "t.se", "tm.se", "u.se", + "w.se", "x.se", "y.se", "z.se", + // .sg + "com.sg", "edu.sg", "gov.sg", "idn.sg", "net.sg", "org.sg", "per.sg", + // .sh + "co.sh", "com.sh", "edu.sh", "gov.sh", "net.sh", "nom.sh", "org.sh", + // .sl + "com.sl", "edu.sl", "gov.sl", "net.sl", "org.sl", + // .sn + "art.sn", "com.sn", "edu.sn", "gouv.sn", "org.sn", "perso.sn", "univ.sn", + // .st + "co.st", "com.st", "consulado.st", "edu.st", "embaixada.st", "gov.st", "mil.st", + "net.st", "org.st", "principe.st", "saotome.st", "store.st", + // .sv + "com.sv", "edu.sv", "gob.sv", "org.sv", "red.sv", + // .sy + "com.sy", "edu.sy", "gov.sy", "mil.sy", "net.sy", "news.sy", "org.sy", + // .sz + "ac.sz", "co.sz", "org.sz", + // .th + "ac.th", "co.th", "go.th", "in.th", "mi.th", "net.th", "or.th", + // .tj + "ac.tj", "biz.tj", "co.tj", "com.tj", "edu.tj", "go.tj", "gov.tj", "info.tj", + "int.tj", "mil.tj", "name.tj", "net.tj", "nic.tj", "org.tj", "test.tj", "web.tj", + // .tn + "agrinet.tn", "com.tn", "defense.tn", "edunet.tn", "ens.tn", "fin.tn", "gov.tn", + "ind.tn", "info.tn", "intl.tn", "mincom.tn", "nat.tn", "net.tn", "org.tn", + "perso.tn", "rnrt.tn", "rns.tn", "rnu.tn", "tourism.tn", + // .tr + "av.tr", "bbs.tr", "bel.tr", "biz.tr", "com.tr", "dr.tr", "edu.tr", "gen.tr", + "gov.tr", "info.tr", "k12.tr", "name.tr", "net.tr", "org.tr", "pol.tr", + "tel.tr", "tsk.tr", "tv.tr", "web.tr", + // .tt + "aero.tt", "biz.tt", "cat.tt", "co.tt", "com.tt", "coop.tt", "edu.tt", "gov.tt", + "info.tt", "int.tt", "jobs.tt", "mil.tt", "mobi.tt", "museum.tt", "name.tt", + "net.tt", "org.tt", "pro.tt", "tel.tt", "travel.tt", + // .tw + "club.tw", "com.tw", "ebiz.tw", "edu.tw", "game.tw", "gov.tw", "idv.tw", + "mil.tw", "net.tw", "org.tw", + // .tz + "ac.tz", "co.tz", "go.tz", "ne.tz", "or.tz", + // .ua + "biz.ua", "cherkassy.ua", "chernigov.ua", "chernovtsy.ua", "ck.ua", "cn.ua", + "co.ua", "com.ua", "crimea.ua", "cv.ua", "dn.ua", "dnepropetrovsk.ua", + "donetsk.ua", "dp.ua", "edu.ua", "gov.ua", "if.ua", "in.ua", + "ivano-frankivsk.ua", "kh.ua", "kharkov.ua", "kherson.ua", "khmelnitskiy.ua", + "kiev.ua", "kirovograd.ua", "km.ua", "kr.ua", "ks.ua", "kv.ua", "lg.ua", + "lugansk.ua", "lutsk.ua", "lviv.ua", "me.ua", "mk.ua", "net.ua", + "nikolaev.ua", "od.ua", "odessa.ua", "org.ua", "pl.ua", "poltava.ua", "pp.ua", + "rovno.ua", "rv.ua", "sebastopol.ua", "sumy.ua", "te.ua", "ternopil.ua", + "uzhgorod.ua", "vinnica.ua", "vn.ua", "zaporizhzhe.ua", "zhitomir.ua", + "zp.ua", "zt.ua", + // .ug + "ac.ug", "co.ug", "go.ug", "ne.ug", "or.ug", "org.ug", "sc.ug", + // .uk + "ac.uk", "bl.uk", "british-library.uk", "co.uk", "cym.uk", "gov.uk", "govt.uk", + "icnet.uk", "jet.uk", "lea.uk", "ltd.uk", "me.uk", "mil.uk", "mod.uk", + "national-library-scotland.uk", "nel.uk", "net.uk", "nhs.uk", "nic.uk", + "nls.uk", "org.uk", "orgn.uk", "parliament.uk", "plc.uk", "police.uk", + "sch.uk", "scot.uk", "soc.uk", + // .us + "4fd.us", "dni.us", "fed.us", "isa.us", "kids.us", "nsn.us", + // .uy + "com.uy", "edu.uy", "gub.uy", "mil.uy", "net.uy", "org.uy", + // .ve + "co.ve", "com.ve", "edu.ve", "gob.ve", "info.ve", "mil.ve", "net.ve", "org.ve", "web.ve", + // .vi + "co.vi", "com.vi", "k12.vi", "net.vi", "org.vi", + // .vn + "ac.vn", "biz.vn", "com.vn", "edu.vn", "gov.vn", "health.vn", "info.vn", + "int.vn", "name.vn", "net.vn", "org.vn", "pro.vn", + // .ye + "co.ye", "com.ye", "gov.ye", "ltd.ye", "me.ye", "net.ye", "org.ye", "plc.ye", + // .yu + "ac.yu", "co.yu", "edu.yu", "gov.yu", "org.yu", + // .za + "ac.za", "agric.za", "alt.za", "bourse.za", "city.za", "co.za", "cybernet.za", + "db.za", "edu.za", "gov.za", "grondar.za", "iaccess.za", "imt.za", "inca.za", + "landesign.za", "law.za", "mil.za", "net.za", "ngo.za", "nis.za", "nom.za", + "olivetti.za", "org.za", "pix.za", "school.za", "tm.za", "web.za", + // .zm + "ac.zm", "co.zm", "com.zm", "edu.zm", "gov.zm", "net.zm", "org.zm", "sch.zm", ]); function getRootDomain(domain: string): string { From 8eff9fdfab1ab669492f40df717720d66c533617 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 22 Mar 2026 01:55:21 +0100 Subject: [PATCH 02/10] feat: add notification settings with sound picker and preview --- app/[locale]/login/page.tsx | 2 +- app/[locale]/page.tsx | 5 +- app/[locale]/settings/page.tsx | 7 +- components/settings/calendar-settings.tsx | 34 ----- components/settings/notification-settings.tsx | 115 +++++++++++++++++ hooks/use-calendar-alerts.ts | 8 +- lib/jmap/client.ts | 117 +++++++++++++++++- lib/notification-sound.ts | 58 ++++++--- locales/de/common.json | 37 +++++- locales/en/common.json | 37 +++++- locales/es/common.json | 37 +++++- locales/fr/common.json | 37 +++++- locales/it/common.json | 37 +++++- locales/ja/common.json | 37 +++++- locales/nl/common.json | 37 +++++- locales/pt/common.json | 37 +++++- public/notification/cheerful-527.mp3 | Bin 0 -> 26748 bytes public/notification/involved-notification.mp3 | Bin 0 -> 24658 bytes .../notification-tone-swift-gesture.mp3 | Bin 0 -> 19225 bytes public/notification/relax-message-tone.mp3 | Bin 0 -> 64782 bytes stores/settings-store.ts | 14 +++ 21 files changed, 590 insertions(+), 66 deletions(-) create mode 100644 components/settings/notification-settings.tsx create mode 100644 public/notification/cheerful-527.mp3 create mode 100644 public/notification/involved-notification.mp3 create mode 100644 public/notification/notification-tone-swift-gesture.mp3 create mode 100644 public/notification/relax-message-tone.mp3 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 53123ee7..72ecbc71 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -148,7 +148,7 @@ export default function LoginPage() { if (!serverUrl) return; const handleClickOutside = (event: MouseEvent) => { if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && - inputRef.current && !inputRef.current.contains(event.target as Node)) { + inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0f5852ff..5f4ca016 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -407,7 +407,10 @@ export default function Home() { // Handle new email notifications - play sound useEffect(() => { if (newEmailNotification) { - playNotificationSound(); + const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState(); + if (emailNotificationsEnabled && emailNotificationSound) { + playNotificationSound(notificationSoundChoice); + } debug.log('New email received:', newEmailNotification.subject); clearNewEmailNotification(); } diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 8ccd3179..d9d81a80 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -24,6 +24,7 @@ import { BookUser, KeyRound, PanelLeftClose, + Bell, type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -44,6 +45,7 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings'; import { ContactsSettings } from '@/components/settings/contacts-settings'; import { SmimeSettings } from '@/components/settings/smime-settings'; import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings'; +import { NotificationSettings } from '@/components/settings/notification-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { useIsDesktop } from '@/hooks/use-media-query'; @@ -55,7 +57,7 @@ import { ResizeHandle } from '@/components/layout/resize-handle'; import { useConfig } from '@/hooks/use-config'; import { cn } from '@/lib/utils'; -type Tab = 'appearance' | 'email' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced'; +type Tab = 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'calendar' | 'contacts' | 'filters' | 'templates' | 'folders' | 'keywords' | 'files' | 'sidebar_apps' | 'advanced'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; interface TabDef { @@ -68,6 +70,7 @@ interface TabDef { const tabIcons: Record = { appearance: Palette, email: Mail, + notifications: Bell, account: User, security: Shield, identities: UserPen, @@ -138,6 +141,7 @@ export default function SettingsPage() { const tabs: TabDef[] = [ { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'general' }, { id: 'email', label: t('tabs.email'), icon: tabIcons.email, group: 'general' }, + { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'account' }, ...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'account' as TabGroup }] : []), { id: 'identities', label: t('tabs.identities'), icon: tabIcons.identities, group: 'account' }, @@ -177,6 +181,7 @@ export default function SettingsPage() { <> {activeTab === 'appearance' && } {activeTab === 'email' && } + {activeTab === 'notifications' && } {activeTab === 'account' && } {activeTab === 'security' && } {activeTab === 'identities' && } diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index 96b6882b..9d714565 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -16,9 +16,6 @@ export function CalendarSettings() { firstDayOfWeek, showTimeInMonthView, showWeekNumbers, - calendarNotificationsEnabled, - calendarNotificationSound, - calendarInvitationParsingEnabled, enableCalendarTasks, showTasksOnCalendar, updateSetting, @@ -103,37 +100,6 @@ export function CalendarSettings() { )} - - updateSetting('calendarNotificationsEnabled', checked)} - /> - - - - updateSetting('calendarNotificationSound', checked)} - disabled={!calendarNotificationsEnabled} - /> - - - - updateSetting('calendarInvitationParsingEnabled', checked)} - /> - - ); } diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx new file mode 100644 index 00000000..3198e390 --- /dev/null +++ b/components/settings/notification-settings.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useTranslations } from 'next-intl'; +import { useSettingsStore } from '@/stores/settings-store'; +import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; +import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; +import type { NotificationSoundChoice } from '@/lib/notification-sound'; +import { Button } from '@/components/ui/button'; +import { Volume2 } from 'lucide-react'; + +export function NotificationSettings() { + const t = useTranslations('settings.notifications'); + const { + emailNotificationsEnabled, + emailNotificationSound, + notificationSoundChoice, + calendarNotificationsEnabled, + calendarNotificationSound, + calendarInvitationParsingEnabled, + updateSetting, + } = useSettingsStore(); + + const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({ + value: s.id, + label: t(`sounds.${s.id}`), + })); + + return ( +
+ + +
+ + ) : ( From 13010c158d5c9cf1c21a0492a4b50ee3d033433d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:44:42 +0100 Subject: [PATCH 05/10] feat: implement path prefix handling for OAuth and login redirects --- app/[locale]/auth/callback/page.tsx | 11 +++-- app/[locale]/login/page.tsx | 7 ++- .../providers/embedded-bridge-provider.tsx | 7 +-- lib/browser-navigation.ts | 44 +++++++++++++++++++ stores/auth-store.ts | 8 ++-- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx index bb8c2896..e5c48f22 100644 --- a/app/[locale]/auth/callback/page.tsx +++ b/app/[locale]/auth/callback/page.tsx @@ -4,6 +4,7 @@ import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; +import { getPathPrefix } from "@/lib/browser-navigation"; import { Loader2, AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useParams } from "next/navigation"; @@ -48,7 +49,8 @@ function OAuthCallbackInner() { return; } - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; loginWithOAuth(serverUrl, code, codeVerifier, redirectUri) .then((success) => { @@ -57,7 +59,7 @@ function OAuthCallbackInner() { sessionStorage.removeItem("oauth_code_verifier"); sessionStorage.removeItem("oauth_server_url"); sessionStorage.removeItem("oauth_add_account_mode"); - let redirectTo = `/${params.locale}`; + let redirectTo = `${prefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { @@ -75,10 +77,11 @@ function OAuthCallbackInner() { }); } else if (state) { // Server-side SSO flow — state was stored in encrypted httpOnly cookie + const ssoPrefix = getPathPrefix(params.locale as string); loginWithServerSso(code, state) .then((success) => { if (success) { - let redirectTo = `/${params.locale}`; + let redirectTo = `${ssoPrefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); if (saved) { @@ -114,7 +117,7 @@ function OAuthCallbackInner() {

diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 72ecbc71..119c71ca 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -15,6 +15,7 @@ import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Mon import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; +import { getPathPrefix } from "@/lib/browser-navigation"; const APP_VERSION = "1.4.7"; @@ -180,7 +181,8 @@ export default function LoginPage() { const startServerSideSso = useCallback(async () => { setOauthLoading(true); try { - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; const res = await fetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -358,7 +360,8 @@ export default function LoginPage() { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); - const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; + const prefix = getPathPrefix(params.locale as string); + const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); diff --git a/components/providers/embedded-bridge-provider.tsx b/components/providers/embedded-bridge-provider.tsx index 4f4cc0f1..3cb6edc4 100644 --- a/components/providers/embedded-bridge-provider.tsx +++ b/components/providers/embedded-bridge-provider.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { isEmbedded, listenFromParent } from "@/lib/iframe-bridge"; +import { getPathPrefix, getLocaleFromPath } from "@/lib/browser-navigation"; import { useAuthStore } from "@/stores/auth-store"; import { useConfig } from "@/hooks/use-config"; @@ -16,9 +17,9 @@ export function EmbeddedBridgeProvider({ children }: { children: React.ReactNode switch (msg.type) { case "sso:trigger-login": { // Navigate to login page to start SSO flow - const segments = window.location.pathname.split("/").filter(Boolean); - const locale = segments[0] || "en"; - window.location.href = `/${locale}/login`; + const prefix = getPathPrefix(); + const locale = getLocaleFromPath(); + window.location.href = `${prefix}/${locale}/login`; break; } case "sso:trigger-logout": diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts index d7b377f3..59be2d35 100644 --- a/lib/browser-navigation.ts +++ b/lib/browser-navigation.ts @@ -1,7 +1,51 @@ +import { locales } from '@/i18n/routing'; + export function replaceWindowLocation(url: string): void { if (typeof window === 'undefined') { return; } window.location.replace(url); +} + +/** + * Returns the mount prefix from the current URL. + * When the app is served behind a reverse proxy at e.g. /bulwark, + * the browser sees /bulwark/en/login while Next.js sees /en/login. + * + * If a locale is supplied (e.g. from route params) it is used directly; + * otherwise the first path segment that matches a known locale is used. + * + * Returns '' when there is no prefix. + */ +export function getPathPrefix(locale?: string): string { + if (typeof window === 'undefined') return ''; + + const segments = window.location.pathname.split('/').filter(Boolean); + + let localeIndex: number; + if (locale) { + localeIndex = segments.indexOf(locale); + } else { + localeIndex = segments.findIndex(s => + (locales as readonly string[]).includes(s) + ); + } + + if (localeIndex <= 0) return ''; + return '/' + segments.slice(0, localeIndex).join('/'); +} + +/** + * Extracts the locale from the current URL, skipping any mount prefix. + * Falls back to 'en' when no known locale segment is found. + */ +export function getLocaleFromPath(): string { + if (typeof window === 'undefined') return 'en'; + + const segments = window.location.pathname.split('/').filter(Boolean); + const locale = segments.find(s => + (locales as readonly string[]).includes(s) + ); + return locale || 'en'; } \ No newline at end of file diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f3874772..338c43c2 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -12,7 +12,7 @@ import { useAccountStore } from './account-store'; import { fetchConfig } from '@/hooks/use-config'; import { debug } from '@/lib/debug'; import { generateAccountId } from '@/lib/account-utils'; -import { replaceWindowLocation } from '@/lib/browser-navigation'; +import { replaceWindowLocation, getPathPrefix, getLocaleFromPath } from '@/lib/browser-navigation'; import { notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; import type { Identity } from '@/lib/jmap/types'; @@ -107,9 +107,9 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti function getLocaleLoginPath(): string { if (typeof window === 'undefined') return '/en/login'; - const segments = window.location.pathname.split('/').filter(Boolean); - const locale = segments[0] || 'en'; - return `/${locale}/login`; + const prefix = getPathPrefix(); + const locale = getLocaleFromPath(); + return `${prefix}/${locale}/login`; } function saveRedirectAfterLogin(): void { From 0c1f182b6b65bb656e6c6a5909dd85574aabf021 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:54:37 +0100 Subject: [PATCH 06/10] fix: detect tasks created by external CalDAV clients (Thunderbird) Tasks created in Thunderbird via CalDAV were not visible because getCalendarTasks() used a strict @type === 'Task' check. Stalwart may not set @type when converting VTODO from CalDAV to JMAP. - Use case-insensitive @type matching for server variations - Add fallback heuristic: detect tasks by presence of 'progress' property (exclusive to JSCalendar Task, never on Event objects) - Normalize @type to 'Task' on detected tasks for consistent downstream handling - Refresh task store on CalendarEvent state changes so tasks created externally appear without manual page refresh Fixes #84 --- lib/jmap/client.ts | 21 ++++++++++++++++++--- stores/email-store.ts | 6 ++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index dfe363d9..c2395464 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3178,9 +3178,24 @@ export class JMAPClient implements IJMAPClient { async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise { try { const events = await this.getCalendarEvents(calendarIds, targetAccountId); - return events.filter((e): e is CalendarTask & CalendarEvent => - (e as unknown as CalendarTask)['@type'] === 'Task' - ) as unknown as CalendarTask[]; + return events.filter((e) => { + const obj = e as unknown as Record; + const type = obj['@type']; + // Explicit @type check (case-insensitive to handle server variations) + if (typeof type === 'string' && type.toLowerCase() === 'task') return true; + // Fallback: detect tasks created via CalDAV (e.g. Thunderbird) where @type + // may be missing. The "progress" property is exclusive to JSCalendar Task + // objects and never appears on Event objects. + if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true; + return false; + }).map((e) => { + const task = e as unknown as CalendarTask; + // Normalize @type for tasks detected by fallback heuristic + if (task['@type'] !== 'Task') { + (task as unknown as Record)['@type'] = 'Task'; + } + return task; + }); } catch (error) { console.error('Failed to get calendar tasks:', error); return []; diff --git a/stores/email-store.ts b/stores/email-store.ts index 1fa2bd69..675e126b 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1102,6 +1102,12 @@ export const useEmailStore = create((set, get) => ({ if (dateRange && selectedCalendarIds.length > 0) { calendarStore.fetchEvents(client, dateRange.start, dateRange.end); } + // Refresh tasks when calendar events change (e.g. task created via CalDAV) + const { useTaskStore } = await import('./task-store'); + const taskStore = useTaskStore.getState(); + if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') { + taskStore.fetchTasks(client); + } } } From f7ee2042626c4bb9bef7fe09f5ac2771dfaf5eff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:45:37 +0100 Subject: [PATCH 07/10] feat: add support for marking emails as answered or forwarded and update UI accordingly --- app/[locale]/page.tsx | 27 +++++++++++++++++++++++ components/email/email-list-item.tsx | 16 +++++++++++++- components/email/thread-email-item.tsx | 16 +++++++++++++- components/email/thread-list-item.tsx | 30 ++++++++++++++++++++++++-- lib/__tests__/thread-utils.test.ts | 24 +++++++++++++++++++++ lib/demo/demo-client.ts | 5 +++++ lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 13 +++++++++++ lib/jmap/types.ts | 2 ++ lib/thread-utils.ts | 8 +++++++ package.json | 5 ++++- 11 files changed, 142 insertions(+), 5 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 5f4ca016..def07bca 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -444,9 +444,27 @@ export default function Home() { if (!client) return; try { + const effectiveMode = pendingDraft?.mode ?? composerMode; + const originalEmailId = selectedEmail?.id; + await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments); setShowComposer(false); + // Mark the original email with $answered or $forwarded keyword + if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) { + try { + await client.setKeyword(originalEmailId, '$answered'); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } + } else if (originalEmailId && effectiveMode === 'forward') { + try { + await client.setKeyword(originalEmailId, '$forwarded'); + } catch (e) { + debug.error('Failed to set $forwarded keyword:', e); + } + } + // Refresh the current mailbox to update the UI await fetchEmails(client, selectedMailbox); } catch (error) { @@ -861,6 +879,8 @@ export default function Home() { // Append signature from the primary identity const finalBody = appendPlainTextSignature(body, primaryIdentity); + const originalEmailId = selectedEmail.id; + // Send reply with just the body text await sendEmail( client, @@ -875,6 +895,13 @@ export default function Home() { primaryIdentity?.name || undefined ); + // Mark the original email as answered + try { + await client.setKeyword(originalEmailId, '$answered'); + } catch (e) { + debug.error('Failed to set $answered keyword:', e); + } + // Refresh emails to show the sent reply await fetchEmails(client, selectedMailbox); }; diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 2b69a663..0ddded47 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -6,7 +6,7 @@ import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square, Tag } from "lucide-react"; +import { Paperclip, Star, Circle, CheckSquare, Square, Tag, Reply, Forward } from "lucide-react"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useAuthStore } from "@/stores/auth-store"; @@ -41,6 +41,8 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isImportant = email.keywords?.["$important"]; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; // Resolve color tag using keyword definitions from settings @@ -175,6 +177,18 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl
)} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} diff --git a/components/email/thread-email-item.tsx b/components/email/thread-email-item.tsx index e9c9a782..4c927028 100644 --- a/components/email/thread-email-item.tsx +++ b/components/email/thread-email-item.tsx @@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils"; import { Email } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react"; +import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useEmailDrag } from "@/hooks/use-email-drag"; import { useLongPress } from "@/hooks/use-long-press"; import { useEmailStore } from "@/stores/email-store"; @@ -29,6 +29,8 @@ export function ThreadEmailItem({ }: ThreadEmailItemProps) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const density = useSettingsStore((state) => state.density); @@ -151,6 +153,18 @@ export function ThreadEmailItem({ {isStarred && ( )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 311c13cf..b2e5d13b 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -5,7 +5,7 @@ import { formatDate } from "@/lib/utils"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square } from "lucide-react"; +import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -53,6 +53,8 @@ const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; + const isAnswered = email.keywords?.$answered; + const isForwarded = email.keywords?.$forwarded; const sender = email.from?.[0]; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); const emailKeywords = useSettingsStore((state) => state.emailKeywords); @@ -182,6 +184,18 @@ const SingleEmailItem = React.forwardRef( {isStarred && ( )} + {isAnswered && !isForwarded && ( + + )} + {isForwarded && !isAnswered && ( + + )} + {isAnswered && isForwarded && ( + <> + + + + )} {email.hasAttachment && ( )} @@ -267,7 +281,7 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); const isMobile = useUIStore((state) => state.isMobile); - const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread; + const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); @@ -482,6 +496,18 @@ export const ThreadListItem = React.forwardRef )} + {hasAnswered && !hasForwarded && ( + + )} + {hasForwarded && !hasAnswered && ( + + )} + {hasAnswered && hasForwarded && ( + <> + + + + )} {hasAttachment && ( )} diff --git a/lib/__tests__/thread-utils.test.ts b/lib/__tests__/thread-utils.test.ts index d0b413c2..bde3a6b5 100644 --- a/lib/__tests__/thread-utils.test.ts +++ b/lib/__tests__/thread-utils.test.ts @@ -89,6 +89,22 @@ describe('groupEmailsByThread', () => { expect(groupEmailsByThread(emails)[0].hasAttachment).toBe(true); }); + it('detects hasAnswered when an email has $answered', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { $seen: true } }), + makeEmail({ id: 'e2', keywords: { $seen: true, $answered: true } }), + ]; + expect(groupEmailsByThread(emails)[0].hasAnswered).toBe(true); + }); + + it('detects hasForwarded when an email has $forwarded', () => { + const emails = [ + makeEmail({ id: 'e1', keywords: { $seen: true } }), + makeEmail({ id: 'e2', keywords: { $seen: true, $forwarded: true } }), + ]; + expect(groupEmailsByThread(emails)[0].hasForwarded).toBe(true); + }); + it('returns empty array for empty input', () => { expect(groupEmailsByThread([])).toEqual([]); }); @@ -110,6 +126,8 @@ describe('sortThreadGroups', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }, { @@ -120,6 +138,8 @@ describe('sortThreadGroups', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }, ]; @@ -169,6 +189,8 @@ describe('mergeThreadEmails', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 2, }; const fetched = [ @@ -189,6 +211,8 @@ describe('mergeThreadEmails', () => { hasUnread: false, hasStarred: false, hasAttachment: false, + hasAnswered: false, + hasForwarded: false, emailCount: 1, }; const fetched = [ diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 2a8df588..f9f3a706 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -216,6 +216,11 @@ export class DemoJMAPClient implements IJMAPClient { if (email) email.keywords = { ...email.keywords, ...keywords }; } + async setKeyword(emailId: string, keyword: string): Promise { + const email = this.data.emails.find(e => e.id === emailId); + if (email) email.keywords[keyword] = true; + } + async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { let count = 0; for (const email of this.data.emails) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0bcfb1a5..777875c9 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -72,6 +72,7 @@ export interface IJMAPClient { batchMarkAsRead(emailIds: string[], read?: boolean): Promise; toggleStar(emailId: string, starred: boolean): Promise; updateEmailKeywords(emailId: string, keywords: Record): Promise; + setKeyword(emailId: string, keyword: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c2395464..92addd39 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -763,6 +763,19 @@ export class JMAPClient implements IJMAPClient { ]); } + async setKeyword(emailId: string, keyword: string): Promise { + await this.request([ + ["Email/set", { + accountId: this.accountId, + update: { + [emailId]: { + [`keywords/${keyword}`]: true, + }, + }, + }, "0"], + ]); + } + async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { // Query all email IDs that have the old keyword const allIds: string[] = []; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index c4854ee5..b7e3f5f6 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -142,6 +142,8 @@ export interface ThreadGroup { hasUnread: boolean; // Any unread emails in thread hasStarred: boolean; // Any starred emails in thread hasAttachment: boolean; // Any email has attachment + hasAnswered: boolean; // Any email has been replied to + hasForwarded: boolean; // Any email has been forwarded emailCount: number; // Total emails in thread } diff --git a/lib/thread-utils.ts b/lib/thread-utils.ts index 18681278..7a7de164 100644 --- a/lib/thread-utils.ts +++ b/lib/thread-utils.ts @@ -38,6 +38,8 @@ export function groupEmailsByThread(emails: Email[]): ThreadGroup[] { const hasUnread = sortedEmails.some(e => !e.keywords?.$seen); const hasStarred = sortedEmails.some(e => e.keywords?.$flagged); const hasAttachment = sortedEmails.some(e => e.hasAttachment); + const hasAnswered = sortedEmails.some(e => e.keywords?.$answered); + const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded); threadGroups.push({ threadId, @@ -47,6 +49,8 @@ export function groupEmailsByThread(emails: Email[]): ThreadGroup[] { hasUnread, hasStarred, hasAttachment, + hasAnswered, + hasForwarded, emailCount: sortedEmails.length, }); } @@ -123,6 +127,8 @@ export function mergeThreadEmails( const hasUnread = mergedEmails.some(e => !e.keywords?.$seen); const hasStarred = mergedEmails.some(e => e.keywords?.$flagged); const hasAttachment = mergedEmails.some(e => e.hasAttachment); + const hasAnswered = mergedEmails.some(e => e.keywords?.$answered); + const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded); return { threadId: existingGroup.threadId, @@ -132,6 +138,8 @@ export function mergeThreadEmails( hasUnread, hasStarred, hasAttachment, + hasAnswered, + hasForwarded, emailCount: mergedEmails.length, }; } diff --git a/package.json b/package.json index 7324819a..2ee97157 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,10 @@ "vitest": "^4.0.16" }, "overrides": { - "elliptic": "^6.6.1", + "elliptic": { + ".": "^6.6.1", + "webcrypto-liner": "$elliptic" + }, "flatted": "^3.4.2", "undici": "^7.24.0" } From 0effb97691e1bfcfb7aeb8142114d255a24cd4ff Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:22:15 +0100 Subject: [PATCH 08/10] refactor: fix bugs in calendar logic across duration parsing, RFC compliance, and event handling - Fix buildDuration() trailing "T" producing invalid ISO 8601 durations - Fix DURATION_RE missing week (W) support in alerts and invitation parsing - Fix computeFireTime() end fallback when utcEnd is missing - Fix recurrenceOverrides patch escaping per RFC 6901 (updateEvent/rsvpEvent) - Fix layoutOverlappingEvents endMin overflow past 1440 - Fix addDurationToDate() to support weeks and use UTC methods for UTC inputs - Fix getEffectiveAlerts() null guard on calendarIds - Fix buildAllDayDuration() DST-safe day calculation using differenceInCalendarDays - Fix participant matching to check calendarAddress and sendTo (not just email) - Fix buildParticipantMap() using crypto.randomUUID() instead of hardcoded IDs - Fix overnight preview negative endMin in week view - Fix sendImipInvitation() to emit DURATION when utcEnd is absent - Fix sendImipCancellation() to validate status before sending - Fix createEvent() to remap all calendarIds for shared calendars - Fix getCalendarTasks() to clone before mutating @type - Fix importEvents() error matching to include 'duplicate' and 'conflict' - Fix looksLikeReply() false positive by requiring organizer + responded attendee - Fix alert offset regex to require T before minutes - Fix handleDuplicate() to generate new UID - Fix formatSnapTime() input clamping - Replace console.log/error with debug.log/error/warn in iMIP functions --- components/calendar/calendar-week-view.tsx | 3 +- components/calendar/event-modal.tsx | 22 +++++--- lib/calendar-alerts.ts | 27 ++++++---- lib/calendar-invitation.ts | 53 +++++++++++--------- lib/calendar-participants.ts | 58 +++++++++++++++++----- lib/calendar-utils.ts | 11 ++-- lib/jmap/client.ts | 25 ++++++---- stores/calendar-store.ts | 34 +++++++++---- 8 files changed, 153 insertions(+), 80 deletions(-) diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index d181b8ab..0817e758 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -448,7 +448,8 @@ export function CalendarWeekView({ {pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && ( (() => { const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes(); - const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + let endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes(); + if (endMin <= startMin) endMin = 1440; const durationMin = Math.max(15, endMin - startMin); const cal = calendars.find(c => c.id === pendingPreview.calendarId); const color = cal?.color || "hsl(var(--primary))"; diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index b6a8874d..5af93c9d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -59,10 +59,12 @@ function buildDuration(startDate: Date, endDate: Date): string { const minutes = totalMinutes % 60; let dur = "P"; if (days > 0) dur += `${days}D`; - dur += "T"; - if (hours > 0) dur += `${hours}H`; - if (minutes > 0) dur += `${minutes}M`; - if (dur === "PT") dur = "PT0M"; + if (hours > 0 || minutes > 0) { + dur += "T"; + if (hours > 0) dur += `${hours}H`; + if (minutes > 0) dur += `${minutes}M`; + } + if (dur === "P") dur = "PT0M"; return dur; } @@ -83,9 +85,9 @@ function getAlertLabel(event: CalendarEvent, t: ReturnType = { + uid: newUid, title: event.title, description: event.description, start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts index fc5acbb4..ac29bacb 100644 --- a/lib/calendar-alerts.ts +++ b/lib/calendar-alerts.ts @@ -6,6 +6,7 @@ import type { Calendar, CalendarTask, } from '@/lib/jmap/types'; +import { parseDuration } from '@/components/calendar/event-card'; export interface PendingAlert { eventId: string; @@ -17,19 +18,20 @@ export interface PendingAlert { const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes -const DURATION_RE = /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; +const DURATION_RE = /^(-?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/; export function parseAlertOffset(offset: string): number | null { const match = DURATION_RE.exec(offset); if (!match) return null; const negative = match[1] === '-'; - const days = parseInt(match[2] || '0', 10); - const hours = parseInt(match[3] || '0', 10); - const minutes = parseInt(match[4] || '0', 10); - const seconds = parseInt(match[5] || '0', 10); + const weeks = parseInt(match[2] || '0', 10); + const days = parseInt(match[3] || '0', 10); + const hours = parseInt(match[4] || '0', 10); + const minutes = parseInt(match[5] || '0', 10); + const seconds = parseInt(match[6] || '0', 10); - const ms = ((days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; + const ms = ((weeks * 7 * 24 * 60 * 60) + (days * 24 * 60 * 60) + (hours * 60 * 60) + (minutes * 60) + seconds) * 1000; return negative ? -ms : ms; } @@ -47,9 +49,15 @@ export function computeFireTime( let baseTime: number; if (trigger.relativeTo === 'end') { - baseTime = event.utcEnd - ? new Date(event.utcEnd).getTime() - : new Date(event.start).getTime(); + if (event.utcEnd) { + baseTime = new Date(event.utcEnd).getTime(); + } else { + // Compute end from start + duration + const startMs = new Date(event.start).getTime(); + if (Number.isNaN(startMs)) return null; + const durationMin = parseDuration(event.duration); + baseTime = startMs + durationMin * 60000; + } } else { baseTime = event.utcStart ? new Date(event.utcStart).getTime() @@ -68,6 +76,7 @@ export function getEffectiveAlerts( return event.alerts; } + if (!event.calendarIds) return null; const calendarId = Object.keys(event.calendarIds)[0]; if (!calendarId) return null; diff --git a/lib/calendar-invitation.ts b/lib/calendar-invitation.ts index ad454a14..5925aadf 100644 --- a/lib/calendar-invitation.ts +++ b/lib/calendar-invitation.ts @@ -251,10 +251,11 @@ function looksLikeReply(event: Partial): boolean { const participants = Object.values(event.participants); const hasOrganizer = participants.some((participant) => isOrganizerParticipant(participant)); - if (hasOrganizer) return false; + if (!hasOrganizer) return false; return participants.some((participant) => participant.roles?.attendee + && !isOrganizerParticipant(participant) && ( participant.participationStatus !== 'needs-action' || !!participant.participationComment @@ -373,6 +374,10 @@ export function getInvitationMethod( return 'cancel'; } + if (looksLikeReply(event)) { + return 'reply'; + } + if (event.participants && Object.keys(event.participants).length > 0) { const hasOrganizer = Object.values(event.participants).some( (p: CalendarParticipant) => isOrganizerParticipant(p) @@ -382,10 +387,6 @@ export function getInvitationMethod( } } - if (looksLikeReply(event)) { - return 'reply'; - } - return 'unknown'; } @@ -524,36 +525,40 @@ export function formatEventSummary(event: Partial): EventSummary } function addDurationToDate(start: string, duration: string, _timeZone?: string | null): string | null { - const match = duration.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); + const match = duration.match(/^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/); if (!match) return null; - const days = parseInt(match[1] || '0'); - const hours = parseInt(match[2] || '0'); - const minutes = parseInt(match[3] || '0'); - const seconds = parseInt(match[4] || '0'); + const weeks = parseInt(match[1] || '0'); + const days = parseInt(match[2] || '0') + weeks * 7; + const hours = parseInt(match[3] || '0'); + const minutes = parseInt(match[4] || '0'); + const seconds = parseInt(match[5] || '0'); const date = new Date(start); if (isNaN(date.getTime())) return null; + const isUTC = start.endsWith('Z') || start.includes('+'); + + if (isUTC) { + date.setUTCDate(date.getUTCDate() + days); + date.setUTCHours(date.getUTCHours() + hours); + date.setUTCMinutes(date.getUTCMinutes() + minutes); + date.setUTCSeconds(date.getUTCSeconds() + seconds); + return date.toISOString(); + } + date.setDate(date.getDate() + days); date.setHours(date.getHours() + hours); date.setMinutes(date.getMinutes() + minutes); date.setSeconds(date.getSeconds() + seconds); - // If the input is a local datetime (no UTC 'Z' suffix), return a local - // format string so that all-day date arithmetic isn't shifted by the - // browser's UTC offset (toISOString converts to UTC). - if (!start.endsWith('Z') && !start.includes('+')) { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - const h = String(date.getHours()).padStart(2, '0'); - const min = String(date.getMinutes()).padStart(2, '0'); - const s = String(date.getSeconds()).padStart(2, '0'); - return `${y}-${m}-${d}T${h}:${min}:${s}`; - } - - return date.toISOString(); + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const min = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + return `${y}-${m}-${d}T${h}:${min}:${s}`; } export function findParticipantByEmail( diff --git a/lib/calendar-participants.ts b/lib/calendar-participants.ts index a2f25364..b5cc94eb 100644 --- a/lib/calendar-participants.ts +++ b/lib/calendar-participants.ts @@ -15,11 +15,30 @@ export interface StatusCounts { 'needs-action': number; } +/** + * Check if a participant matches any of the given email addresses. + * Checks p.email, p.calendarAddress (mailto:...), and p.sendTo values. + */ +function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]): boolean { + if (p.email && lowerEmails.includes(p.email.toLowerCase())) return true; + if (p.calendarAddress) { + const addr = p.calendarAddress.replace(/^mailto:/i, '').toLowerCase(); + if (addr && lowerEmails.includes(addr)) return true; + } + if (p.sendTo) { + for (const addr of Object.values(p.sendTo)) { + const normalized = addr.replace(/^mailto:/i, '').toLowerCase(); + if (normalized && lowerEmails.includes(normalized)) return true; + } + } + return false; +} + export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean { if (!event.participants) return false; const lower = userEmails.map(e => e.toLowerCase()); return Object.values(event.participants).some(p => - p.roles?.owner && lower.includes(p.email?.toLowerCase()) + p.roles?.owner && participantMatchesEmail(p, lower) ); } @@ -27,7 +46,7 @@ export function getUserParticipantId(event: CalendarEvent, userEmails: string[]) if (!event.participants) return null; const lower = userEmails.map(e => e.toLowerCase()); for (const [id, p] of Object.entries(event.participants)) { - if (lower.includes(p.email?.toLowerCase())) return id; + if (participantMatchesEmail(p, lower)) return id; } return null; } @@ -39,20 +58,29 @@ export function getUserStatus( if (!event.participants) return null; const lower = userEmails.map(e => e.toLowerCase()); for (const p of Object.values(event.participants)) { - if (lower.includes(p.email?.toLowerCase())) return p.participationStatus; + if (participantMatchesEmail(p, lower)) return p.participationStatus; } return null; } export function getParticipantList(event: CalendarEvent): ParticipantInfo[] { if (!event.participants) return []; - return Object.entries(event.participants).map(([id, p]) => ({ - id, - name: p.name || '', - email: p.email || '', - status: p.participationStatus || 'needs-action', - isOrganizer: !!p.roles?.owner, - })); + return Object.entries(event.participants).map(([id, p]) => { + let email = p.email || ''; + if (!email && p.calendarAddress) { + email = p.calendarAddress.replace(/^mailto:/i, ''); + } + if (!email && p.sendTo?.imip) { + email = p.sendTo.imip.replace(/^mailto:/i, ''); + } + return { + id, + name: p.name || '', + email, + status: p.participationStatus || 'needs-action', + isOrganizer: !!p.roles?.owner, + }; + }); } export function getStatusCounts(event: CalendarEvent): StatusCounts { @@ -76,7 +104,11 @@ export function buildParticipantMap( ): Record> { const participants: Record> = {}; - participants['organizer'] = { + const generateId = () => typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : `p-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + participants[generateId()] = { '@type': 'Participant', name: organizer.name, email: organizer.email, @@ -88,8 +120,8 @@ export function buildParticipantMap( kind: 'individual', }; - attendees.forEach((a, i) => { - participants[`attendee-${i}`] = { + attendees.forEach((a) => { + participants[generateId()] = { '@type': 'Participant', name: a.name, email: a.email, diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts index ac1df05e..17f63209 100644 --- a/lib/calendar-utils.ts +++ b/lib/calendar-utils.ts @@ -40,9 +40,7 @@ export function normalizeAllDayDuration(duration: string | undefined): string | } export function buildAllDayDuration(start: Date, inclusiveEnd: Date): string { - const startDay = startOfDay(start); - const endDay = startOfDay(inclusiveEnd); - const dayCount = Math.max(1, Math.round((endDay.getTime() - startDay.getTime()) / 86400000) + 1); + const dayCount = Math.max(1, differenceInCalendarDays(startOfDay(inclusiveEnd), startOfDay(start)) + 1); return `P${dayCount}D`; } @@ -113,7 +111,7 @@ export function layoutOverlappingEvents( for (const event of sorted) { const start = parseISO(event.start); const startMin = start.getHours() * 60 + start.getMinutes(); - const endMin = startMin + Math.max(15, parseDuration(event.duration)); + const endMin = Math.min(1440, startMin + Math.max(15, parseDuration(event.duration))); let placed = false; for (let col = 0; col < columns.length; col++) { if (columns[col].every(e => e.end <= startMin)) { @@ -135,8 +133,9 @@ export function layoutOverlappingEvents( } export function formatSnapTime(minutes: number, timeFormat: "12h" | "24h"): string { - const h = Math.floor(minutes / 60); - const m = minutes % 60; + const clamped = Math.max(0, Math.min(1440, minutes)); + const h = Math.floor(clamped / 60) % 24; + const m = clamped % 60; if (timeFormat === "12h") { return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`; } diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 92addd39..7056dfa0 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; +import { debug } from "@/lib/debug"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { @@ -1684,7 +1685,7 @@ export class JMAPClient implements IJMAPClient { lines.push('END:VCALENDAR'); const icsContent = lines.join('\r\n') + '\r\n'; - console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent); + debug.log('[iMIP] Generated ICS:\n' + icsContent); const statusLabels: Record = { ACCEPTED: 'Accepted', @@ -1694,7 +1695,7 @@ export class JMAPClient implements IJMAPClient { const statusLabel = statusLabels[opts.status] || opts.status; const subject = `${statusLabel}: ${opts.summary || 'Event'}`; - console.log('[iMIP DEBUG] identityId:', finalIdentityId); + debug.log('[iMIP] identityId:', finalIdentityId); const emailId = `imip-reply-${Date.now()}`; const emailCreate: Record = { @@ -1727,27 +1728,27 @@ export class JMAPClient implements IJMAPClient { }, "1"], ]; - console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls'); - console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2)); + debug.log('[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); + debug.log('[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); const response = await this.request(methodCalls); - console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); + debug.log('[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { - console.error('[iMIP DEBUG] method error:', methodName, result); + debug.error('[iMIP] method error:', methodName, result); throw new Error(result.description || `iMIP reply failed: ${result.type}`); } if (result.notCreated) { const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string }; - console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2)); + debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2)); throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply'); } } } - console.log('[iMIP DEBUG] sendImipReply completed successfully'); + debug.log('[iMIP] sendImipReply completed successfully'); } /** @@ -1823,6 +1824,9 @@ export class JMAPClient implements IJMAPClient { const formatted = formatIcalDate(event.utcEnd, event.timeZone); lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`); } + } else if (event.duration) { + // Fallback: emit DURATION when utcEnd is absent (RFC 5545 §3.6.1) + lines.push(`DURATION:${event.duration}`); } if (event.title) lines.push(`SUMMARY:${event.title}`); @@ -1907,6 +1911,9 @@ export class JMAPClient implements IJMAPClient { */ async sendImipCancellation(event: CalendarEvent): Promise { if (!event.participants) return; + if (event.status && event.status !== 'cancelled') { + debug.warn('sendImipCancellation called on non-cancelled event, status:', event.status); + } const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); @@ -3202,7 +3209,7 @@ export class JMAPClient implements IJMAPClient { if (type !== 'Event' && 'progress' in obj && typeof obj.progress === 'string') return true; return false; }).map((e) => { - const task = e as unknown as CalendarTask; + const task = { ...e } as unknown as CalendarTask; // Normalize @type for tasks detected by fallback heuristic if (task['@type'] !== 'Task') { (task as unknown as Record)['@type'] = 'Task'; diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts index 27f09431..d05edc5e 100644 --- a/stores/calendar-store.ts +++ b/stores/calendar-store.ts @@ -130,14 +130,17 @@ export const useCalendarStore = create()( let targetAccountId = event.accountId; const cleanEvent = { ...event }; if (event.calendarIds) { - const calId = Object.keys(event.calendarIds)[0]; - if (calId) { + const remapped: Record = {}; + for (const calId of Object.keys(event.calendarIds)) { const cal = get().calendars.find(c => c.id === calId); if (cal?.isShared && cal.originalId) { targetAccountId = cal.accountId; - cleanEvent.calendarIds = { [cal.originalId]: true }; + remapped[cal.originalId] = true; + } else { + remapped[calId] = true; } } + cleanEvent.calendarIds = remapped; } if (event.originalCalendarIds) { cleanEvent.calendarIds = event.originalCalendarIds; @@ -185,12 +188,18 @@ export const useCalendarStore = create()( if (realEvent) { const resolvedId = realEvent.originalId || realEvent.id; if (storeEvent.recurrenceId) { - // Recurring instance: patch the master event's recurrenceOverrides - const patchUpdates: Record = {}; + // Recurring instance: patch the master event's recurrenceOverrides. + // Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1 + const escapedRecurrenceId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1'); + // Build override object with all changed properties + const overrideObj: Record = {}; for (const [key, value] of Object.entries(cleanUpdates as Record)) { if (['id', 'uid', '@type', 'calendarIds', 'recurrenceRules', 'recurrenceOverrides', 'excludedRecurrenceRules'].includes(key)) continue; - patchUpdates[`recurrenceOverrides/${storeEvent.recurrenceId}/${key}`] = value; + overrideObj[key] = value; } + const patchUpdates: Record = { + [`recurrenceOverrides/${escapedRecurrenceId}`]: overrideObj, + }; await client.updateCalendarEvent( resolvedId, patchUpdates as unknown as Partial, @@ -261,12 +270,17 @@ export const useCalendarStore = create()( const resolvedId = realEvent.originalId || realEvent.id; if (storeEvent.recurrenceId) { // Recurring instance: patch RSVP as recurrence override on master - const overridePatch: Record = { - [`recurrenceOverrides/${storeEvent.recurrenceId}/${patchKey}`]: status, + // Escape recurrenceId per RFC 6901: ~ → ~0, / → ~1 + const escapedRecId = storeEvent.recurrenceId.replace(/~/g, '~0').replace(/\//g, '~1'); + const overrideObj: Record = { + [patchKey]: status, }; if (replyTo) { - overridePatch[`recurrenceOverrides/${storeEvent.recurrenceId}/replyTo`] = replyTo; + overrideObj['replyTo'] = replyTo; } + const overridePatch: Record = { + [`recurrenceOverrides/${escapedRecId}`]: overrideObj, + }; await client.updateCalendarEvent( resolvedId, overridePatch as unknown as Partial, @@ -392,7 +406,7 @@ export const useCalendarStore = create()( imported++; } catch (error) { const msg = error instanceof Error ? error.message : ''; - if (msg.includes('already exists') && src.uid) { + if ((msg.includes('already exists') || msg.includes('duplicate') || msg.includes('conflict')) && src.uid) { const { events: storeEvents } = get(); const alreadyInStore = storeEvents.some((e) => e.uid === src.uid); if (alreadyInStore) { From ef45140d32e9927de2ca3f9bfb3c64e3628e9054 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:37:56 +0100 Subject: [PATCH 09/10] fix: enhance account settings with username and authentication method display #90 --- components/settings/account-settings.tsx | 33 +++++++++++++++++------- locales/de/common.json | 4 +++ locales/en/common.json | 4 +++ locales/es/common.json | 4 +++ locales/fr/common.json | 4 +++ locales/it/common.json | 4 +++ locales/ja/common.json | 4 +++ locales/nl/common.json | 4 +++ locales/pt/common.json | 4 +++ stores/auth-store.ts | 16 +++++++++--- 10 files changed, 68 insertions(+), 13 deletions(-) diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 1083e0cb..3e70b9cd 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -3,29 +3,44 @@ import { useTranslations } from 'next-intl'; import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; +import { useAccountStore } from '@/stores/account-store'; import { SettingsSection, SettingItem } from './settings-section'; import { formatFileSize } from '@/lib/utils'; export function AccountSettings() { const t = useTranslations('settings.account'); - const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore(); + const { username, serverUrl, isDemoMode, primaryIdentity, authMode, activeAccountId } = useAuthStore(); const { quota } = useEmailStore(); + const account = useAccountStore((s) => activeAccountId ? s.getAccountById(activeAccountId) : undefined); const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0; - const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined); + const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined); + const email = primaryIdentity?.email || account?.email || username; return ( - {/* Display Name (show in demo mode or when identity has a name) */} - {displayName && ( - - {displayName} - - )} + {/* Display Name */} + + {displayName || t('../../common.unknown')} + {/* Email Address */} - {username || t('../../common.unknown')} + {email || t('../../common.unknown')} + + + {/* Username / Login (show when it differs from email) */} + {username && username !== email && ( + + {username} + + )} + + {/* Authentication Method */} + + + {authMode === 'oauth' ? t('auth_method_oauth') : t('auth_method_basic')} + {/* Server */} diff --git a/locales/de/common.json b/locales/de/common.json index 17cb3f22..264cd1b5 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -913,7 +913,11 @@ "title": "Konto", "description": "Zeigen Sie Ihre Kontoinformationen an", "name_label": "Anzeigename", + "username_label": "Benutzername", "account_type_label": "Kontotyp", + "auth_method_label": "Authentifizierung", + "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_basic": "Passwort", "demo_account": "Demokonto", "email": { "label": "E-Mail-Adresse", diff --git a/locales/en/common.json b/locales/en/common.json index 5f6b9e79..6d84985b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -913,7 +913,11 @@ "title": "Account", "description": "View your account information", "name_label": "Display Name", + "username_label": "Username", "account_type_label": "Account Type", + "auth_method_label": "Authentication", + "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_basic": "Password", "demo_account": "Demo Account", "email": { "label": "Email Address", diff --git a/locales/es/common.json b/locales/es/common.json index be56888b..e9eba747 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -913,7 +913,11 @@ "title": "Cuenta", "description": "Vea la información de su cuenta", "name_label": "Nombre para mostrar", + "username_label": "Nombre de usuario", "account_type_label": "Tipo de cuenta", + "auth_method_label": "Autenticación", + "auth_method_oauth": "Inicio de sesión único (OAuth/OIDC)", + "auth_method_basic": "Contraseña", "demo_account": "Cuenta de demostración", "email": { "label": "Dirección de Correo", diff --git a/locales/fr/common.json b/locales/fr/common.json index d8b2c506..90961771 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -913,7 +913,11 @@ "title": "Compte", "description": "Consultez les informations de votre compte", "name_label": "Nom d'affichage", + "username_label": "Nom d'utilisateur", "account_type_label": "Type de compte", + "auth_method_label": "Authentification", + "auth_method_oauth": "Authentification unique (OAuth/OIDC)", + "auth_method_basic": "Mot de passe", "demo_account": "Compte de démonstration", "email": { "label": "Adresse email", diff --git a/locales/it/common.json b/locales/it/common.json index 580e69df..efc099b6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -913,7 +913,11 @@ "title": "Account", "description": "Visualizza le informazioni del tuo account", "name_label": "Nome visualizzato", + "username_label": "Nome utente", "account_type_label": "Tipo di account", + "auth_method_label": "Autenticazione", + "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_basic": "Password", "demo_account": "Account dimostrativo", "email": { "label": "Indirizzo email", diff --git a/locales/ja/common.json b/locales/ja/common.json index 4b7c6a8c..c5fabf87 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -913,7 +913,11 @@ "title": "アカウント", "description": "アカウント情報を表示", "name_label": "表示名", + "username_label": "ユーザー名", "account_type_label": "アカウントタイプ", + "auth_method_label": "認証方法", + "auth_method_oauth": "シングルサインオン (OAuth/OIDC)", + "auth_method_basic": "パスワード", "demo_account": "デモアカウント", "email": { "label": "メールアドレス", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0820189b..a681520b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -913,7 +913,11 @@ "title": "Account", "description": "Bekijk je accountinformatie", "name_label": "Weergavenaam", + "username_label": "Gebruikersnaam", "account_type_label": "Accounttype", + "auth_method_label": "Authenticatie", + "auth_method_oauth": "Single Sign-On (OAuth/OIDC)", + "auth_method_basic": "Wachtwoord", "demo_account": "Demoaccount", "email": { "label": "E-mailadres", diff --git a/locales/pt/common.json b/locales/pt/common.json index 9e45864c..325c5235 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -913,7 +913,11 @@ "title": "Conta", "description": "Visualize as informações da sua conta", "name_label": "Nome de exibição", + "username_label": "Nome de usuário", "account_type_label": "Tipo de conta", + "auth_method_label": "Autenticação", + "auth_method_oauth": "Login único (OAuth/OIDC)", + "auth_method_basic": "Senha", "demo_account": "Conta de demonstração", "email": { "label": "Endereço de E-mail", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 338c43c2..f5f29939 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -486,8 +486,12 @@ export const useAuthStore = create()( }); await client.connect(); - const username = client.getUsername(); - const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); + const jmapUsername = client.getUsername(); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername); + // For OAuth/OIDC, the JMAP session account name may be the + // preferred_username claim rather than the real email address. + // Prefer the email from the primary identity when available. + const username = primaryIdentity?.email || jmapUsername; initializeFeatureStores(client); // Register in account store @@ -602,8 +606,12 @@ export const useAuthStore = create()( }); await client.connect(); - const username = client.getUsername(); - const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); + const jmapUsername = client.getUsername(); + const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername); + // For SSO/OIDC, the JMAP session account name may be the + // preferred_username claim rather than the real email address. + // Prefer the email from the primary identity when available. + const username = primaryIdentity?.email || jmapUsername; initializeFeatureStores(client); const accountId = generateAccountId(username, ssoServerUrl); From 55c8d430ca36fcbb204c5124194c65ee16f37859 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:59:24 +0100 Subject: [PATCH 10/10] chore: update version to 1.4.8 --- CHANGELOG.md | 17 +++++++++++++++++ VERSION | 2 +- app/[locale]/login/page.tsx | 9 +++------ package.json | 2 +- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a38605..ca37314d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.4.8 (2026-03-23) + +### Features + +- **Email**: Add support for marking emails as answered or forwarded and display status icons in email list and thread views +- **Email**: Enhance identity selection by supporting sub-addressing (plus addressing) in email composer +- **Settings**: Add notification settings with sound picker, preview playback, and configurable alert sounds +- **Settings**: Add default mail program settings with localization support across all locales +- **Auth**: Implement path prefix handling for OAuth callbacks and login redirects, enabling reverse proxy deployments +- **Validation**: Add all multi-part TLDs for domain validation in favicon API (#81) + +### Fixes + +- **Calendar**: Fix bugs in duration parsing, RFC compliance, and event handling across calendar components +- **Calendar**: Detect tasks created by external CalDAV clients such as Thunderbird +- **Settings**: Enhance account settings with username and authentication method display (#90) + ## 1.4.7 (2026-03-21) ### Features diff --git a/VERSION b/VERSION index be05bba9..b2e46d18 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.7 +1.4.8 diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 119c71ca..53123ee7 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -15,7 +15,6 @@ import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Mon import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; -import { getPathPrefix } from "@/lib/browser-navigation"; const APP_VERSION = "1.4.7"; @@ -149,7 +148,7 @@ export default function LoginPage() { if (!serverUrl) return; const handleClickOutside = (event: MouseEvent) => { if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) && - inputRef.current && !inputRef.current.contains(event.target as Node)) { + inputRef.current && !inputRef.current.contains(event.target as Node)) { setShowSuggestions(false); } if (themeMenuRef.current && !themeMenuRef.current.contains(event.target as Node)) { @@ -181,8 +180,7 @@ export default function LoginPage() { const startServerSideSso = useCallback(async () => { setOauthLoading(true); try { - const prefix = getPathPrefix(params.locale as string); - const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; const res = await fetch('/api/auth/sso/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -360,8 +358,7 @@ export default function LoginPage() { const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); const state = generateState(); - const prefix = getPathPrefix(params.locale as string); - const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`; sessionStorage.setItem("oauth_code_verifier", verifier); sessionStorage.setItem("oauth_state", state); diff --git a/package.json b/package.json index 2ee97157..a849850b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-webmail", - "version": "1.4.7", + "version": "1.4.8", "description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server", "author": "Bulwark Webmail ", "license": "AGPL-3.0-only",