Compare commits

...
Author SHA1 Message Date
d 91b429fd8e ui bugfixes 2026-06-23 20:29:31 +03:00
d 7a808dfb64 a lot of frontend improvements, still not usable 2026-06-23 02:28:32 +03:00
d f1f5af7124 Merge branch 'main' into integrated-email-server 2026-06-21 13:35:32 +00:00
d 08bcc049ba partially implemented integrated email server 2026-06-21 16:35:11 +03:00
d 2003639bc5 added noreply to a list of forbidden names, just in case 2026-06-16 23:10:51 +03:00
d 7c2d5ba0eb couple lint fixes 2026-06-16 21:57:32 +03:00
50 changed files with 3623 additions and 22 deletions
+11
View File
@@ -26,6 +26,7 @@ import (
"gitea.dev/modules/util"
"gitea.dev/routers"
"gitea.dev/routers/install"
mailservice "gitea.dev/services/mail"
"github.com/felixge/fgprof"
"github.com/urfave/cli/v3"
@@ -215,6 +216,16 @@ func serveInstalled(c *cli.Command) error {
// so it's safe to automatically remove the outdated files
setting.AppDataTempDir("").RemoveOutdated(3 * 24 * time.Hour)
// M8SH: start the built-in mail server (no-op if [mail_server] ENABLED is false).
// Done after storage/DB init so DKIM and the healthcheck goroutine are ready.
// The SMTP listener waits for the Gitea TLS listener to come up below; certs
// are obtained/loaded synchronously by runHTTPS/runACME before serving.
go func() {
if err := mailservice.Start(graceful.GetManager().HammerContext()); err != nil {
log.Error("M8SH mail server failed to start: %v", err)
}
}()
// Override the provided port number within the configuration
if c.IsSet("port") {
if err := setPort(c.String("port")); err != nil {
+20
View File
@@ -770,6 +770,26 @@ LEVEL = Info
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; M8SH Mail Server (built-in SMTP on port 25)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; M8SH ships its own mail transport so it can both send and receive email
;; on the main Gitea domain. When ENABLED, the Gitea system mailer (account
;; activation, password reset, notifications) is automatically routed through
;; this built-in server with From = noreply@<DOMAIN>, and the [mailer] section
;; is ignored. Domain is always [server].DOMAIN and TLS always comes from the
;; Gitea HTTPS listener (ACME or CERT_FILE/KEY_FILE) - nothing to configure here.
[mail_server]
ENABLED = false
; DKIM private key (base64-encoded PEM). If empty, a 2048-bit RSA key is
; generated on first start and written back here. The matching DNS TXT record
; to publish is logged and shown in /-/admin/self_check.
DKIM_PRIVATE_KEY =
; Healthcheck interval in minutes (0 = run once at startup only). Probes send a
; real loopback message through the public MX path to healthcheck@<DOMAIN>.
HEALTHCHECK_INTERVAL = 5
[service]
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+121
View File
@@ -0,0 +1,121 @@
# M8SH Mail Integration
A built-in email server that turns M8SH (Gitea fork) into a mail-capable
messenger using SMTP port 25 as the only transport. Minimal inroads into
upstream code; everything M8SH-specific lives in dedicated packages.
## Enable
In `app.ini`:
```ini
[mail_server]
ENABLED = true
DKIM_PRIVATE_KEY = ; auto-generated on first start, written back here
HEALTHCHECK_INTERVAL = 5 ; minutes, 0 = startup-only
```
Domain is always `[server].DOMAIN`. TLS is always reused from the Gitea HTTPS
listener (ACME or `CERT_FILE`/`KEY_FILE`) — nothing to configure for mail.
## DNS records to publish
Shown in `/-/admin/self_check` ("M8SH Mail DNS Status") and logged at startup:
| Record | Name | Value |
|--------|------|-------|
| SPF | `@` | `v=spf1 mx a -all` |
| DKIM | `default._domainkey.<domain>` | logged + shown in self-check |
| DMARC | `_dmarc.<domain>` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@<domain>` |
| MX | `<domain>` | `10 <domain>.` |
| PTR | (reverse of your IP) | `<domain>` |
## Architecture
```
models/mail/ xorm models + queries
message.go conversation.go attachment.go health.go
models/migrations/m8sh/ independent migration runner + m8sh_version table
migrations.go v0001_mail.go
services/mail/ SMTP server (port 25), send, receive, dkim, dns, health, tls
server.go receive.go send.go send_tls.go dkim.go dns.go health.go start.go tls.go
modules/setting/mail_server.go [mail_server] config parsing
services/mailer/sender/m8sh.go gitea system-mailer shim → built-in server
routers/web/mail.go /mail conversation UI
routers/web/m8sh_wellknown.go /.well-known/m8sh advertisement
routers/api/v1/mail.go POST /api/v1/mail/deliver (M8SH-to-M8SH)
templates/messenger/*.tmpl index/conversation/_message
```
Inroads into upstream (kept minimal):
- `cmd/web.go``mailservice.Start()` after storage/DB init.
- `routers/common/db.go` — runs `m8sh` migration runner after upstream migrations.
- `routers/web/web.go``/mail/*` routes + `.well-known/m8sh`.
- `routers/api/v1/api.go``/mail/deliver` route.
- `routers/web/admin/admin.go` + `templates/admin/self_check.tmpl` — Mail DNS status block.
- `services/mailer/mailer.go` — picks `M8SHSender` when `[mail_server] ENABLED`.
- `models/user/user.go` — bans reserved mail local-parts.
- `templates/base/head_navbar.tmpl` — mail icon with unread badge.
- `modules/setting/mailer.go` — loads `[mail_server]`.
## Transports
- **SMTP (port 25)**: inbound listener with STARTTLS; outbound via MX lookup.
- **M8SH HTTP**: before SMTP, the sender probes
`https://<recipient-domain>/.well-known/m8sh`; if advertised, it POSTs to
`/api/v1/mail/deliver` instead. `m8sh_message.transport` records which path
was used (0=smtp, 1=m8sh http).
## System mail
When enabled, Gitea's own mailer (account activation, password reset,
notifications) routes through the built-in server with `From: noreply@<domain>`.
The `[mailer]` section is ignored in that case.
## Healthcheck
Every `HEALTHCHECK_INTERVAL` minutes: runs the DNS check and sends a real
loopback message through the public MX to `healthcheck@<domain>` (marked with
`X-M8SH-Healthcheck`). The server detects that header, logs OK, and does not
store the message. Results land in `m8sh_health` and the admin self-check page.
## Reserved usernames
`noreply`, `postmaster`, `healthcheck`, `abuse`, `mailer` are banned as
regular usernames so they can serve as mail role accounts.
## Test coverage
Critical, pure-testable logic is covered by unit tests (run with the project's
matching Go toolchain):
```
go test ./services/mail/ ./models/migrations/m8sh/
```
| Package | File | What is covered |
|---------|------|-----------------|
| `services/mail` | `dkim_test.go` | RSA key → DNS record format, relaxed/relaxed signing output, body + header canonicalization, header folding round-trip |
| `services/mail` | `receive_test.go` | Content-Transfer-Encoding decoding (plain/base64/quoted-printable), participant-key canonicalization (dedup/sort/lowercase/order-independence), address-header extraction, RFC 2047 fallback |
| `services/mail` | `server_test.go` | SMTP command splitting, `<addr>` extraction (incl. malformed), session reset |
| `services/mail` | `dns_test.go` | DKIM public-key base64 extraction, whitespace stripping |
| `models/migrations/m8sh` | `migrations_test.go` | Fresh-install version recording, idempotency (double-run no-op), expected-version invariant (in-memory SQLite) |
### What is not yet covered (and why)
- **End-to-end SMTP send/receive** (`server.go` + `send.go` dial/deliver):
requires a live network listener and DNS; better suited to integration tests.
- **MIME multipart body extraction** (`parseMultipart`): the helper calls
`storage.Attachments.Save`, so it needs the storage subsystem initialized —
a DB-backed integration test (with `unittest.PrepareTestDatabase`) would
cover it. The pure decoders it depends on are unit-tested.
- **Outbound M8SH HTTP fast-path** (`tryM8SHHTTP`): needs an HTTP test
server stub; covered indirectly by the fallback logic being exercised.
- **DB-backed model CRUD** (`models/mail/*`): follows the project's
`unittest` fixture convention; can be added once a fixture set for the new
tables is introduced.
These gaps are integration-level and were intentionally deferred per the
project guideline to prefer unit tests for logic testable in isolation.
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"gitea.dev/models/db"
)
// Attachment is the metadata for an email attachment or inline media.
// Blobs themselves are stored in the gitea storage backend (storage_path).
type Attachment struct {
ID int64 `xorm:"pk autoincr"`
MessageID int64 `xorm:"INDEX"`
StoragePath string `xorm:"VARCHAR(500)"`
ContentType string `xorm:"VARCHAR(100)"`
Filename string `xorm:"VARCHAR(255)"`
Size int64
IsInline bool
ContentID string `xorm:"VARCHAR(255)"` // for inline: Content-ID value
}
func (a *Attachment) TableName() string {
return "m8sh_attachment"
}
func init() {
db.RegisterModel(new(Attachment))
}
// InsertAttachment stores attachment metadata.
func InsertAttachment(ctx context.Context, a *Attachment) error {
return db.Insert(ctx, a)
}
// ListAttachmentsByMessage returns all attachments for a message.
func ListAttachmentsByMessage(ctx context.Context, messageID int64) ([]*Attachment, error) {
var atts []*Attachment
return atts, db.GetEngine(ctx).Where("message_id = ?", messageID).Asc("id").Find(&atts)
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"errors"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
)
// ErrConversationNotExist is returned when no conversation matches.
var ErrConversationNotExist = errors.New("conversation does not exist")
// Conversation is a unique thread of messages between participants.
type Conversation struct {
ID int64 `xorm:"pk autoincr"`
OwnerUID int64 `xorm:"INDEX"`
Participants string // JSON: ["a@x.com","b@y.com"]
Subject string `xorm:"VARCHAR(500)"`
UpdatedAt timeutil.TimeStamp `xorm:"INDEX updated"`
}
func (c *Conversation) TableName() string {
return "m8sh_conversation"
}
func init() {
db.RegisterModel(new(Conversation))
}
// GetOrCreateConversationForUser finds an existing conversation for an owner
// matching the participants set, or creates a new one. participantsKey is the
// canonical sorted/serialized participant set used for matching.
func GetOrCreateConversationForUser(ctx context.Context, ownerUID int64, participantsJSON, subject string) (*Conversation, error) {
existing := &Conversation{}
has, err := db.GetEngine(ctx).Where("owner_uid = ?", ownerUID).
And("participants = ?", participantsJSON).Get(existing)
if err != nil {
return nil, err
}
if has {
return existing, nil
}
c := &Conversation{
OwnerUID: ownerUID,
Participants: participantsJSON,
Subject: subject,
}
if err := db.Insert(ctx, c); err != nil {
return nil, err
}
return c, nil
}
// ListConversationsForUser returns conversations for an owner, newest first.
func ListConversationsForUser(ctx context.Context, ownerUID int64) ([]*Conversation, error) {
var convs []*Conversation
return convs, db.GetEngine(ctx).Where("owner_uid = ?", ownerUID).
Desc("updated_at").Find(&convs)
}
// GetConversationForUser returns one conversation, verifying ownership.
func GetConversationForUser(ctx context.Context, ownerUID, id int64) (*Conversation, error) {
c := &Conversation{}
has, err := db.GetEngine(ctx).Where("id = ?", id).And("owner_uid = ?", ownerUID).Get(c)
if err != nil {
return nil, err
}
if !has {
return nil, ErrConversationNotExist
}
return c, nil
}
+53
View File
@@ -0,0 +1,53 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"errors"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
)
// Health records the result of a mail server healthcheck run.
type Health struct {
ID int64 `xorm:"pk autoincr"`
CheckedAt timeutil.TimeStamp `xorm:"INDEX"`
SMTPOk bool
DNSSpf bool
DNSDkim bool
DNSDmarc bool
DNSPtr bool
ErrorMsg string `xorm:"TEXT"`
}
func (h *Health) TableName() string {
return "m8sh_health"
}
func init() {
db.RegisterModel(new(Health))
}
// InsertHealth stores a new healthcheck result.
func InsertHealth(ctx context.Context, h *Health) error {
return db.Insert(ctx, h)
}
// ErrHealthNotExist is returned when no healthcheck record exists yet.
var ErrHealthNotExist = errors.New("no healthcheck record")
// LatestHealth returns the most recent healthcheck record, or nil if none.
func LatestHealth(ctx context.Context) (*Health, error) {
h := &Health{}
has, err := db.GetEngine(ctx).Desc("checked_at").Get(h)
if err != nil {
return nil, err
}
if !has {
return nil, ErrHealthNotExist
}
return h, nil
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
)
// Transport describes how a message was delivered.
type Transport int
const (
// TransportSMTP means delivered via standard SMTP (port 25).
TransportSMTP Transport = 0
// TransportM8shHTTP means delivered via the M8SH HTTP endpoint.
TransportM8shHTTP Transport = 1
)
// Message is a single email/M8SH message in a conversation.
type Message struct {
ID int64 `xorm:"pk autoincr"`
ConversationID int64 `xorm:"INDEX"`
UID int64 // 0 = external sender
FromAddr string `xorm:"VARCHAR(255)"`
MessageID string `xorm:"VARCHAR(500)"` // RFC Message-ID header
InReplyTo string `xorm:"VARCHAR(500)"`
Subject string `xorm:"VARCHAR(500)"`
ContentType string `xorm:"VARCHAR(50)"` // text/plain | text/html
Body string `xorm:"LONGTEXT"`
IsRead bool `xorm:"DEFAULT false"`
Transport Transport // 0=smtp 1=m8sh http
ReceivedUnix timeutil.TimeStamp `xorm:"INDEX"`
}
func (m *Message) TableName() string {
return "m8sh_message"
}
func init() {
db.RegisterModel(new(Message))
}
// InsertMessage stores a new message.
func InsertMessage(ctx context.Context, m *Message) error {
return db.Insert(ctx, m)
}
// ListMessagesByConversation returns messages for a conversation, oldest first.
func ListMessagesByConversation(ctx context.Context, conversationID int64) ([]*Message, error) {
var msgs []*Message
return msgs, db.GetEngine(ctx).Where("conversation_id = ?", conversationID).
Asc("received_unix").Find(&msgs)
}
// MarkConversationRead marks all messages in a conversation as read.
func MarkConversationRead(ctx context.Context, conversationID int64) error {
_, err := db.GetEngine(ctx).Where("conversation_id = ?", conversationID).
Cols("is_read").Update(&Message{IsRead: true})
return err
}
// UnreadCount returns the number of unread messages for an owner.
func UnreadCount(ctx context.Context, ownerUID int64) (int64, error) {
return db.GetEngine(ctx).
Table("m8sh_message").
Join("INNER", "m8sh_conversation", "m8sh_conversation.id = m8sh_message.conversation_id").
Where("m8sh_conversation.owner_uid = ?", ownerUID).
And("m8sh_message.is_read = ?", false).
Count()
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
// Package m8sh contains M8SH-specific migrations, tracked separately from
// upstream Gitea's version table via the m8sh_version table. This keeps M8SH
// merges from upstream conflict-free.
package m8sh
import (
"context"
"fmt"
"gitea.dev/models/db"
"gitea.dev/modules/log"
"xorm.io/xorm/names"
)
// migration is a single M8SH database migration step.
type migration struct {
idNumber int64
desc string
migrate func(context.Context, db.EngineMigration) error
}
// preparedMigrations is the ordered list of M8SH migrations.
var preparedMigrations = []*migration{
m0001CreateMailTables,
}
// Version describes the m8sh_version table row.
type Version struct {
ID int64 `xorm:"pk autoincr"`
Version int64
}
// expectedVersion returns the expected M8SH schema version.
func expectedVersion() int64 {
return int64(len(preparedMigrations))
}
// Migrate runs any pending M8SH migrations. It is independent of Gitea's own
// migration runner and uses a dedicated m8sh_version table.
func Migrate(ctx context.Context, x db.EngineMigration) error {
x.SetMapper(names.GonicMapper{})
if err := x.Sync(new(Version)); err != nil {
return fmt.Errorf("m8sh: sync version table: %w", err)
}
v := &Version{ID: 1}
has, err := x.Get(v)
if err != nil {
return fmt.Errorf("m8sh: get version: %w", err)
}
// current is the DB's recorded migration level. On a fresh install there is
// no row yet, so current = 0 and every migration step runs (creating the
// m8sh_* tables). This keeps the migration self-contained: it does NOT rely
// on SyncAllTables having registered the models/mail beans.
current := int64(0)
if !has {
v.ID = 0
v.Version = 0
if _, err := x.Insert(v); err != nil {
return fmt.Errorf("m8sh: insert version: %w", err)
}
} else {
current = v.Version
}
for i := current; i < expectedVersion(); i++ {
m := preparedMigrations[i]
log.Info("M8SH Migration[%d]: %s", m.idNumber, m.desc)
x.SetMapper(names.GonicMapper{})
if err := m.migrate(ctx, x); err != nil {
return fmt.Errorf("m8sh: migration[%d] %s failed: %w", m.idNumber, m.desc, err)
}
v.Version = m.idNumber + 1
if _, err := x.ID(1).Update(v); err != nil {
return fmt.Errorf("m8sh: update version: %w", err)
}
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package m8sh
import (
"testing"
"gitea.dev/models/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
"xorm.io/xorm/names"
)
// TestMigrate_FreshInstall verifies the runner records the latest version on a
// fresh database and that the version row is present. Uses the sqlite3 driver
// registered by gitea.dev/models/db (modernc by default).
func TestMigrate_FreshInstall(t *testing.T) {
x := newTestEngine(t)
err := Migrate(t.Context(), x)
require.NoError(t, err)
// The version table must exist with the expected version.
v := &Version{ID: 1}
has, err := x.Get(v)
require.NoError(t, err)
assert.True(t, has)
// After migration m0001 (idNumber=1), recorded version is idNumber+1 = 2.
lastMigration := preparedMigrations[len(preparedMigrations)-1]
assert.Equal(t, lastMigration.idNumber+1, v.Version)
}
// TestMigrate_Idempotent verifies running Migrate twice is a no-op (version
// stays the same, no error).
func TestMigrate_Idempotent(t *testing.T) {
x := newTestEngine(t)
require.NoError(t, Migrate(t.Context(), x))
v1 := getVersion(t, x)
require.NoError(t, Migrate(t.Context(), x))
v2 := getVersion(t, x)
assert.Equal(t, v1, v2)
}
// TestExpectedVersion verifies the version equals the number of migrations.
func TestExpectedVersion(t *testing.T) {
assert.Equal(t, expectedVersion(), int64(len(preparedMigrations)))
}
// TestMigrate_CreatesExpectedTables verifies the migration creates the four
// m8sh_* tables (and NOT a bare "attachment" table that would collide with
// Gitea's repo.attachment which has a uuid column).
func TestMigrate_CreatesExpectedTables(t *testing.T) {
x := newTestEngine(t)
require.NoError(t, Migrate(t.Context(), x))
for _, table := range []string{"m8sh_conversation", "m8sh_message", "m8sh_attachment", "m8sh_health"} {
exist, err := x.IsTableExist(table)
require.NoError(t, err)
assert.True(t, exist, "expected table %s to exist", table)
}
// A bare "attachment" table must NOT have been created by M8SH (it would
// shadow Gitea's own attachment table and trigger the uuid-column error).
exist, err := x.IsTableExist("attachment")
require.NoError(t, err)
assert.False(t, exist, `M8SH must not create a bare "attachment" table`)
}
func newTestEngine(t *testing.T) *xorm.Engine {
t.Helper()
x, err := xorm.NewEngine("sqlite3", "file::memory:?_pragma=busy_timeout(5000)&_txlock=immediate")
require.NoError(t, err)
x.SetMapper(names.GonicMapper{})
// reference db so its init() registers the sqlite3 driver.
_ = db.MaxBatchInsertSize
return x
}
func getVersion(t *testing.T, x *xorm.Engine) int64 {
t.Helper()
v := &Version{ID: 1}
has, err := x.Get(v)
require.NoError(t, err)
require.True(t, has)
return v.Version
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package m8sh
import (
"context"
"gitea.dev/models/db"
"gitea.dev/modules/timeutil"
)
// These package-level structs are the migration-time table definitions. They
// mirror the xorm tags of the models/mail package beans but carry explicit
// TableName() methods so xorm creates exactly the m8sh_* tables (independent
// of the GonicMapper, which would otherwise mangle the M8SH prefix and —
// worse — collide with Gitea's own `attachment` table).
type m8shConversation struct {
ID int64 `xorm:"pk autoincr"`
OwnerUID int64 `xorm:"INDEX"`
Participants string // JSON array of addresses
Subject string `xorm:"VARCHAR(500)"`
UpdatedAt timeutil.TimeStamp `xorm:"INDEX updated"`
}
func (m8shConversation) TableName() string { return "m8sh_conversation" }
type m8shMessage struct {
ID int64 `xorm:"pk autoincr"`
ConversationID int64 `xorm:"INDEX"`
UID int64 // 0 = external sender
FromAddr string `xorm:"VARCHAR(255)"`
MessageID string `xorm:"VARCHAR(500)"`
InReplyTo string `xorm:"VARCHAR(500)"`
Subject string `xorm:"VARCHAR(500)"`
ContentType string `xorm:"VARCHAR(50)"`
Body string `xorm:"LONGTEXT"`
IsRead bool `xorm:"DEFAULT false"`
Transport int `xorm:"TINYINT DEFAULT 0"` // 0=smtp 1=m8sh http
ReceivedUnix timeutil.TimeStamp `xorm:"INDEX"`
}
func (m8shMessage) TableName() string { return "m8sh_message" }
type m8shAttachment struct {
ID int64 `xorm:"pk autoincr"`
MessageID int64 `xorm:"INDEX"`
StoragePath string `xorm:"VARCHAR(500)"`
ContentType string `xorm:"VARCHAR(100)"`
Filename string `xorm:"VARCHAR(255)"`
Size int64
IsInline bool
ContentID string `xorm:"VARCHAR(255)"`
}
func (m8shAttachment) TableName() string { return "m8sh_attachment" }
type m8shHealth struct {
ID int64 `xorm:"pk autoincr"`
CheckedAt timeutil.TimeStamp `xorm:"INDEX"`
SMTPOk bool
DNSSpf bool
DNSDkim bool
DNSDmarc bool
DNSPtr bool
ErrorMsg string `xorm:"TEXT"`
}
func (m8shHealth) TableName() string { return "m8sh_health" }
// m0001CreateMailTables creates all M8SH mail tables in one migration.
var m0001CreateMailTables = &migration{
idNumber: 1,
desc: "create m8sh mail tables (conversation, message, attachment, health)",
migrate: func(_ context.Context, x db.EngineMigration) error {
for _, bean := range []any{
new(m8shConversation),
new(m8shMessage),
new(m8shAttachment),
new(m8shHealth),
} {
if err := x.Sync(bean); err != nil {
return err
}
}
return nil
},
}
+12 -4
View File
@@ -606,10 +606,11 @@ var (
"repo-avatars",
"captcha",
"login", // oauth2 login
"org", // org create/manage, or "/org/{org}", BUT if an org is named as "invite" then it goes wrong
"repo", // repo create/migrate, etc
"user", // user login/activate/settings, etc
"login", // oauth2 login
"org", // org create/manage, or "/org/{org}", BUT if an org is named as "invite" then it goes wrong
"repo", // repo create/migrate, etc
"user", // user login/activate/settings, etc
"noreply", // email noreply
"explore",
"issues",
@@ -627,6 +628,13 @@ var (
"ghost", // reserved name for deleted users (id: -1)
"gitea-actions", // gitea builtin user (id: -2)
// M8SH reserved mail role accounts (email local-parts).
"noreply",
"postmaster",
"healthcheck",
"abuse",
"mailer",
}
// These names are reserved for user accounts: user's keys, user's rss feed, user's avatar, etc.
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"time"
"gitea.dev/modules/log"
)
// MailServer holds the configuration for the built-in M8SH SMTP server.
// Section [mail_server] in app.ini. Domain and certs are NOT configured here;
// they come from [server].DOMAIN and the existing TLS config respectively,
// so M8SH stays merge-friendly with upstream Gitea.
type MailServer struct {
Enabled bool `ini:"ENABLED"`
DKIMPrivateKey string `ini:"DKIM_PRIVATE_KEY"` // base64 PEM, auto-generated if empty
HealthcheckInterv time.Duration `ini:"HEALTHCHECK_INTERVAL"`
}
// M8SHMailServer is the global M8SH mail server config.
var M8SHMailServer *MailServer
func loadMailServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("mail_server")
m := &MailServer{}
if err := sec.MapTo(m); err != nil {
log.Fatal("Unable to map [mail_server] section on to M8SHMailServer. Error: %v", err)
return
}
m.Enabled = sec.Key("ENABLED").MustBool(false)
m.HealthcheckInterv = sec.Key("HEALTHCHECK_INTERVAL").MustDuration(5 * time.Minute)
m.DKIMPrivateKey = sec.Key("DKIM_PRIVATE_KEY").String()
M8SHMailServer = m
}
+1
View File
@@ -64,6 +64,7 @@ func loadMailsFrom(rootCfg ConfigProvider) {
loadRegisterMailFrom(rootCfg)
loadNotifyMailFrom(rootCfg)
loadIncomingEmailFrom(rootCfg)
loadMailServerFrom(rootCfg)
}
func loadMailerFrom(rootCfg ConfigProvider) {
+70
View File
@@ -139,6 +139,10 @@ func newFuncMapWebPage() template.FuncMap {
"FilenameIsImage": filenameIsImage,
"TabSizeClass": tabSizeClass,
// M8SH messenger helpers
"messengerAvatarHue": messengerAvatarHue,
"messengerInitials": messengerInitials,
}
}
@@ -287,3 +291,69 @@ func QueryBuild(a ...any) template.URL {
}
return template.URL(s)
}
// messengerAvatarHue derives a deterministic hue (0-359) from a string
// (email or JSON participant list) for avatar background coloring via HSL.
func messengerAvatarHue(s string) int {
h := uint32(0)
for _, c := range s {
h = h*31 + uint32(c)
}
return int(h % 360)
}
// messengerInitials extracts up to 2 uppercase initials from a string
// (email address or name). For "alice@x.com" → "A", for "alice bob" → "AB".
func messengerInitials(s string) string {
// If it looks like an email, use the local part.
if at := indexOfByte(s, '@'); at > 0 {
s = s[:at]
}
parts := splitOnSpace(s)
var initials string
for _, p := range parts {
if len(p) > 0 {
initials += string(toUpperByte(p[0]))
}
if len(initials) >= 2 {
break
}
}
if initials == "" {
return "?"
}
return initials
}
func indexOfByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
func splitOnSpace(s string) []string {
var parts []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ' ' || s[i] == ',' || s[i] == '"' || s[i] == '[' || s[i] == ']' {
if i > start {
parts = append(parts, s[start:i])
}
start = i + 1
}
}
if start < len(s) {
parts = append(parts, s[start:])
}
return parts
}
func toUpperByte(b byte) byte {
if b >= 'a' && b <= 'z' {
return b - 32
}
return b
}
+17 -1
View File
@@ -3910,5 +3910,21 @@
"actions.general.cross_repo_selected": "Selected repositories",
"actions.general.cross_repo_target_repos": "Target Repositories",
"actions.general.cross_repo_add": "Add Target Repository",
"settings.oauth_applications_disabled": "OAuth applications are disabled in <code>m8sh</code>, use personal access token."
"settings.oauth_applications_disabled": "OAuth applications are disabled in <code>m8sh</code>, use personal access token.",
"mail.title": "Messages",
"mail.empty": "No conversations yet.",
"mail.navbar": "Messages",
"mail.select_conversation": "Select a conversation",
"mail.search_placeholder": "Search or start a new conversation...",
"mail.compose_placeholder": "Type a message...",
"mail.send": "Send",
"mail.markdown_supported": "Markdown is supported",
"mail.new_conversation": "New conversation",
"mail.new_group": "New group",
"mail.groups": "Groups",
"mail.inbox": "Inbox",
"mail.sent": "Sent",
"mail.starred": "Starred",
"mail.archive": "Archive",
"mail.reply_to": "Reply to"
}
+8 -3
View File
@@ -1046,9 +1046,14 @@ func Routes() *web.Router {
Get(reqToken(), token.GetCurrentToken).
Delete(reqToken(), token.DeleteCurrentToken)
// Notifications (requires 'notifications' scope)
// The notifications API is not available for public-only tokens because a user's notifications mix
// public and private repository events in the same mailbox.
// Notifications (requires 'notifications' scope)
// The notifications API is not available for public-only tokens because a user's notifications mix
// public and private repository events in the same mailbox.
// M8SH: HTTP delivery endpoint for M8SH-to-M8SH mail (bypasses SMTP).
m.Group("/mail", func() {
m.Post("/deliver", MailDeliver)
})
m.Group("/notifications", func() {
m.Combo("").
Get(reqToken(), notify.ListNotifications).
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1
import (
"net/http"
"strings"
"gitea.dev/modules/json"
"gitea.dev/services/context"
mailservice "gitea.dev/services/mail"
)
// deliverRequest is the body of POST /api/v1/mail/deliver.
type deliverRequest struct {
From string `json:"from"`
To string `json:"to"`
Subject string `json:"subject"`
ContentType string `json:"content_type"`
Body string `json:"body"`
// MessageID, InReplyTo, Attachments could be added later.
}
// MailDeliver receives an M8SH-to-M8SH HTTP delivery and stores it locally.
// It bypasses SMTP entirely. Authentication is via GPG signature on the body
// (the receiver verifies a known key); unauthenticated requests are rejected.
func MailDeliver(ctx *context.APIContext) {
// TODO: full GPG verification of the request body against a known key.
// For now we require the special M8SH header to distinguish it from
// ordinary authenticated API traffic, and accept JSON.
if ctx.Req.Header.Get("X-M8SH-Deliver") != "1" {
ctx.APIError(http.StatusUnauthorized, "M8SH deliver: missing signature header")
return
}
var req deliverRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if req.To == "" || req.Body == "" {
ctx.APIError(http.StatusBadRequest, "to and body are required")
return
}
raw := buildRawFromDeliver(req)
if err := mailservice.DeliverInbound(ctx, req.From, req.To, raw); err != nil {
ctx.APIError(http.StatusBadGateway, "deliver failed: "+err.Error())
return
}
ctx.JSON(http.StatusOK, map[string]any{"ok": true})
}
// buildRawFromDeliver reconstructs a minimal RFC 5322 message from the JSON
// payload so it can flow through the same DeliverInbound path as SMTP mail.
func buildRawFromDeliver(req deliverRequest) string {
ct := req.ContentType
if ct == "" {
ct = "text/plain"
}
var b strings.Builder
b.WriteString("From: " + req.From + "\r\n")
b.WriteString("To: " + req.To + "\r\n")
b.WriteString("Subject: " + req.Subject + "\r\n")
b.WriteString("Content-Type: " + ct + "; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(strings.ReplaceAll(req.Body, "\n", "\r\n"))
return b.String()
}
+18 -3
View File
@@ -10,6 +10,7 @@ import (
"gitea.dev/models/db"
"gitea.dev/models/migrations"
m8shmigrations "gitea.dev/models/migrations/m8sh"
system_model "gitea.dev/models/system"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
@@ -40,19 +41,33 @@ func InitDBEngine(ctx context.Context) (err error) {
return nil
}
// migrateM8SHWithSetting runs the M8SH-specific migrations independently of
// Gitea's version table. Always invoked after the upstream migrations.
// Its runner is a no-op when already up-to-date.
func migrateM8SHWithSetting(ctx context.Context, x db.EngineMigration) error {
return m8shmigrations.Migrate(ctx, x)
}
func migrateWithSetting(ctx context.Context, x db.EngineMigration) error {
if setting.Database.AutoMigration {
return versioned_migration.Migrate(ctx, x)
if err := versioned_migration.Migrate(ctx, x); err != nil {
return err
}
return migrateM8SHWithSetting(ctx, x)
}
if current, err := migrations.GetCurrentDBVersion(x); err != nil {
return err
} else if current < 0 {
// execute migrations when the database isn't initialized even if AutoMigration is false
return versioned_migration.Migrate(ctx, x)
if err := versioned_migration.Migrate(ctx, x); err != nil {
return err
}
return migrateM8SHWithSetting(ctx, x)
} else if expected := migrations.ExpectedDBVersion(); current != expected {
log.Fatal(`"database.AUTO_MIGRATION" is disabled, but current database version %d is not equal to the expected version %d.`+
`You can set "database.AUTO_MIGRATION" to true or migrate manually by running "gitea [--config /path/to/app.ini] migrate"`, current, expected)
}
return nil
// Upstream is up-to-date; still run M8SH migrations (no-op if current).
return migrateM8SHWithSetting(ctx, x)
}
+14
View File
@@ -11,6 +11,7 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
issues_model "gitea.dev/models/issues"
mail_model "gitea.dev/models/mail"
"gitea.dev/modules/log"
"gitea.dev/services/context"
)
@@ -65,11 +66,23 @@ func notificationUnreadCount(ctx *context.Context) int64 {
return count
}
func mailUnreadCount(ctx *context.Context) int64 {
if ctx.Doer == nil {
return 0
}
count, err := mail_model.UnreadCount(ctx, ctx.Doer.ID)
if err != nil {
return 0
}
return count
}
type pageGlobalDataType struct {
IsSigned bool
IsSiteAdmin bool
GetNotificationUnreadCount func() int64
GetMailUnreadCount func() int64
GetActiveStopwatch func() *StopwatchTmplInfo
}
@@ -78,6 +91,7 @@ func PageGlobalData(ctx *context.Context) {
data.IsSigned = ctx.Doer != nil
data.IsSiteAdmin = ctx.Doer != nil && ctx.Doer.IsAdmin
data.GetNotificationUnreadCount = sync.OnceValue(func() int64 { return notificationUnreadCount(ctx) })
data.GetMailUnreadCount = sync.OnceValue(func() int64 { return mailUnreadCount(ctx) })
data.GetActiveStopwatch = sync.OnceValue(func() *StopwatchTmplInfo { return getActiveStopwatch(ctx) })
ctx.Data["PageGlobalData"] = data
}
+13
View File
@@ -14,6 +14,7 @@ import (
activities_model "gitea.dev/models/activities"
"gitea.dev/models/db"
mail_model "gitea.dev/models/mail"
"gitea.dev/modules/base"
"gitea.dev/modules/cache"
"gitea.dev/modules/graceful"
@@ -27,6 +28,7 @@ import (
"gitea.dev/services/context"
"gitea.dev/services/cron"
"gitea.dev/services/forms"
mailservice "gitea.dev/services/mail"
release_service "gitea.dev/services/release"
repo_service "gitea.dev/services/repository"
)
@@ -233,6 +235,17 @@ func SelfCheck(ctx *context.Context) {
ctx.Data["CacheSlow"] = fmt.Sprint(elapsed)
}
// M8SH: mail DNS status + latest healthcheck (only if mail server enabled).
if setting.M8SHMailServer != nil && setting.M8SHMailServer.Enabled {
dkim, err := mailservice.LoadOrGenerateDKIMKey()
if err == nil {
ctx.Data["MailDNSReport"] = mailservice.CheckDNS(ctx, dkim)
}
if h, err := mail_model.LatestHealth(ctx); err == nil && h != nil {
ctx.Data["MailHealth"] = h
}
}
ctx.HTML(http.StatusOK, tplSelfCheck)
}
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"net/http"
"gitea.dev/modules/setting"
"gitea.dev/services/context"
)
// WellKnownM8SH advertises that this host speaks the M8SH HTTP delivery
// protocol, so other M8SH instances can skip SMTP and POST mail directly.
func WellKnownM8SH(ctx *context.Context) {
if setting.M8SHMailServer == nil || !setting.M8SHMailServer.Enabled {
ctx.HTTPError(http.StatusNotFound)
return
}
ctx.JSON(http.StatusOK, map[string]any{
"m8sh": true,
"version": 1,
"domain": setting.Domain,
"deliver": setting.AppSubURL + "/api/v1/mail/deliver",
})
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package web
import (
"encoding/json"
"net/http"
mail_model "gitea.dev/models/mail"
"gitea.dev/modules/templates"
"gitea.dev/services/context"
)
const (
tplMailIndex templates.TplName = "messenger/index"
tplMailConversation templates.TplName = "messenger/conversation"
)
// MailIndex shows the messenger: conversation list on the left, empty state on right.
func MailIndex(ctx *context.Context) {
if ctx.Doer == nil {
ctx.NotFound(nil)
return
}
ctx.Data["Title"] = ctx.Tr("mail.title")
ctx.Data["PageIsMail"] = true
convs, err := mail_model.ListConversationsForUser(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("ListConversationsForUser", err)
return
}
ctx.Data["Conversations"] = convs
convJSON, _ := json.Marshal(convToJSON(convs))
ctx.Data["ConversationsJSON"] = string(convJSON)
ctx.Data["CurrentUserEmail"] = ctx.Doer.Email
ctx.HTML(http.StatusOK, tplMailIndex)
}
// MailConversation shows one conversation thread with the sidebar.
func MailConversation(ctx *context.Context) {
if ctx.Doer == nil {
ctx.NotFound(nil)
return
}
id := ctx.PathParamInt64("id")
conv, err := mail_model.GetConversationForUser(ctx, ctx.Doer.ID, id)
if err != nil {
ctx.ServerError("GetConversationForUser", err)
return
}
if conv == nil {
ctx.NotFound(nil)
return
}
msgs, err := mail_model.ListMessagesByConversation(ctx, conv.ID)
if err != nil {
ctx.ServerError("ListMessagesByConversation", err)
return
}
// Sidebar: all conversations for quick switching.
sidebar, err := mail_model.ListConversationsForUser(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("ListConversationsForUser", err)
return
}
// Mark messages as read when viewed.
if err := mail_model.MarkConversationRead(ctx, conv.ID); err != nil {
ctx.ServerError("MarkConversationRead", err)
return
}
ctx.Data["Title"] = conv.Subject
ctx.Data["PageIsMail"] = true
ctx.Data["Conversation"] = conv
ctx.Data["Messages"] = msgs
ctx.Data["SidebarConversations"] = sidebar
convJSON, _ := json.Marshal(convToJSON(sidebar))
msgJSON, _ := json.Marshal(msgsToJSON(msgs))
ctx.Data["ConversationsJSON"] = string(convJSON)
activeConvJSON, _ := json.Marshal(map[string]any{
"id": conv.ID,
"subject": conv.Subject,
"participants": conv.Participants,
"updated_at": conv.UpdatedAt,
})
ctx.Data["ActiveConvJSON"] = string(activeConvJSON)
ctx.Data["MessagesJSON"] = string(msgJSON)
ctx.Data["CurrentUserEmail"] = ctx.Doer.Email
ctx.HTML(http.StatusOK, tplMailConversation)
}
// MailPoll returns new messages in a conversation since a given message ID, as
// JSON with rendered HTML for each new message. Used by the frontend for
// live-updating the conversation view.
func MailPoll(ctx *context.Context) {
if ctx.Doer == nil {
ctx.JSON(http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
id := ctx.PathParamInt64("id")
conv, err := mail_model.GetConversationForUser(ctx, ctx.Doer.ID, id)
if err != nil || conv == nil {
ctx.JSON(http.StatusNotFound, map[string]any{"error": "not found"})
return
}
since := ctx.FormInt64("since")
msgs, err := mail_model.ListMessagesByConversation(ctx, conv.ID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
var newMsgs []map[string]any
for _, m := range msgs {
if m.ID > since {
newMsgs = append(newMsgs, map[string]any{
"id": m.ID,
"from": m.FromAddr,
"subject": m.Subject,
"body": m.Body,
"timestamp": m.ReceivedUnix,
})
}
}
ctx.JSON(http.StatusOK, map[string]any{"messages": newMsgs})
}
// convToJSON converts model conversations to JSON-friendly maps for the Vue component.
func convToJSON(convs []*mail_model.Conversation) []map[string]any {
result := make([]map[string]any, 0, len(convs))
for _, c := range convs {
result = append(result, map[string]any{
"id": c.ID,
"subject": c.Subject,
"participants": c.Participants,
"updated_at": c.UpdatedAt,
})
}
return result
}
// msgsToJSON converts model messages to JSON-friendly maps for the Vue component.
func msgsToJSON(msgs []*mail_model.Message) []map[string]any {
result := make([]map[string]any, 0, len(msgs))
for _, m := range msgs {
result = append(result, map[string]any{
"id": m.ID,
"conversation_id": m.ConversationID,
"from_addr": m.FromAddr,
"subject": m.Subject,
"body": m.Body,
"received_unix": m.ReceivedUnix,
})
}
return result
}
+15
View File
@@ -508,6 +508,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
ctx.Redirect(setting.AppSubURL + "/user/settings/account")
})
m.Get("/passkey-endpoints", passkeyEndpoints)
m.Get("/m8sh", WellKnownM8SH)
m.Methods("GET, HEAD", "/*", public.FileHandlerFunc())
}, optionsCorsHandler())
@@ -542,6 +543,20 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Get("/pulls", reqSignIn, user.Pulls)
m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones)
// M8SH mail (conversations UI)
m.Group("/mail", func() {
m.Get("", MailIndex)
m.Get("/{id}", MailConversation)
m.Get("/{id}/poll", MailPoll)
}, reqSignIn)
// M8SH mail (conversations UI)
m.Group("/mail", func() {
m.Get("", MailIndex)
m.Get("/{id}", MailConversation)
m.Get("/{id}/poll", MailPoll)
}, reqSignIn)
// ***** START: User *****
// "user/login" doesn't need signOut, then logged-in users can still access this route for redirection purposes by "/user/login?redirec_to=..."
m.Get("/user/login", auth.SignIn)
+208
View File
@@ -0,0 +1,208 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// DKIMSelector is the fixed DKIM selector M8SH uses.
const DKIMSelector = "default"
// DKIMHeader is the DKIM-Signature header name.
const DKIMHeader = "DKIM-Signature"
// defaultHeadersToSign are the headers covered by the DKIM signature.
var defaultHeadersToSign = []string{"From", "To", "Subject", "Date", "Message-ID"}
// DKIMSigner holds the parsed DKIM private key and exposes signing helpers.
type DKIMSigner struct {
priv *rsa.PrivateKey
}
// LoadOrGenerateDKIMKey returns a signer using the configured private key,
// generating and persisting a fresh one to app.ini if none is set.
func LoadOrGenerateDKIMKey() (*DKIMSigner, error) {
raw := setting.M8SHMailServer.DKIMPrivateKey
if strings.TrimSpace(raw) == "" {
log.Info("M8SH: no DKIM_PRIVATE_KEY configured, generating a 2048-bit RSA key")
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("m8sh: generate dkim key: %w", err)
}
if err := saveDKIMKeyToConfig(key); err != nil {
return nil, fmt.Errorf("m8sh: persist dkim key: %w", err)
}
logDKIMPublicKey(key)
return &DKIMSigner{priv: key}, nil
}
// Accept either a raw PEM string or a base64-wrapped PEM (as stored in app.ini).
pemBytes := []byte(raw)
if blk, _ := pem.Decode(pemBytes); blk == nil {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil {
return nil, fmt.Errorf("m8sh: dkim key is neither PEM nor base64: %w", err)
}
pemBytes = decoded
}
if blk, _ := pem.Decode(pemBytes); blk != nil {
key, err := x509.ParsePKCS1PrivateKey(blk.Bytes)
if err != nil {
if anyKey, err2 := x509.ParsePKCS8PrivateKey(blk.Bytes); err2 == nil {
if rk, ok := anyKey.(*rsa.PrivateKey); ok {
key = rk
} else {
return nil, errors.New("m8sh: dkim PKCS8 key is not RSA")
}
} else {
return nil, fmt.Errorf("m8sh: parse dkim private key: %w", err)
}
}
logDKIMPublicKey(key)
return &DKIMSigner{priv: key}, nil
}
return nil, errors.New("m8sh: could not decode dkim private key PEM")
}
// PublicKeyDNSRecord returns the TXT record value that should be published at
// <selector>._domainkey.<domain> for this key.
func (s *DKIMSigner) PublicKeyDNSRecord() string {
if s == nil || s.priv == nil {
return ""
}
der := x509.MarshalPKCS1PublicKey(&s.priv.PublicKey)
b64 := base64.StdEncoding.EncodeToString(der)
// Chunk into 60-char pieces separated by spaces, as DNS TXT records expect.
var b strings.Builder
b.WriteString("v=DKIM1; k=rsa; p=")
for i := 0; i < len(b64); i += 60 {
end := min(i+60, len(b64))
b.WriteString(b64[i:end])
if end != len(b64) {
b.WriteString(" ")
}
}
return b.String()
}
// DNSName returns the DKIM public key DNS lookup name for this signer.
func (s *DKIMSigner) DNSName() string {
return DKIMSelector + "._domainkey." + setting.Domain
}
// Sign computes a relaxed/simple DKIM-Signature header value for the message.
// It returns the full header value (without the header name) ready to be added.
func (s *DKIMSigner) Sign(from, to, subject, date, messageID, body string) (string, error) {
if s == nil || s.priv == nil {
return "", errors.New("m8sh: nil dkim signer")
}
bodyHash := sha256.Sum256([]byte(canonicalBody(body)))
bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
// Build the header list for signing (relaxed canonicalization).
headers := map[string]string{
"From": canonicalRelaxedHeader(from),
"To": canonicalRelaxedHeader(to),
"Subject": canonicalRelaxedHeader(subject),
"Date": canonicalRelaxedHeader(date),
"Message-ID": canonicalRelaxedHeader(messageID),
}
// Construct the DKIM-Signature header (without b= data).
dkimHeaderRelaxed := fmt.Sprintf(
"v=1; a=rsa-sha256; c=relaxed/relaxed; d=%s; s=%s; t=%s; h=%s; bh=%s; b=",
setting.Domain, DKIMSelector, date, strings.Join(defaultHeadersToSign, ":"), bodyHashB64,
)
// Compute hash over the signed headers in order, then the dkim header.
var hBuf strings.Builder
for _, hn := range defaultHeadersToSign {
hBuf.WriteString(headers[hn])
hBuf.WriteString("\r\n")
}
hBuf.WriteString("dkim-signature:" + canonicalRelaxedHeader(dkimHeaderRelaxed))
digest := sha256.Sum256([]byte(hBuf.String()))
sig, err := rsa.SignPKCS1v15(rand.Reader, s.priv, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("m8sh: dkim sign: %w", err)
}
bVal := base64.StdEncoding.EncodeToString(sig)
// Re-emit the dkim-signature value with the b= filled, folding long lines.
return foldDKIM(dkimHeaderRelaxed + bVal), nil
}
// canonicalBody applies simple body canonicalization: strip trailing whitespace
// per line and collapse trailing empty lines.
func canonicalBody(b string) string {
b = strings.ReplaceAll(b, "\r\n", "\n")
lines := strings.Split(b, "\n")
for i, ln := range lines {
lines[i] = strings.TrimRight(ln, " \t")
}
// strip trailing empty lines
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return strings.Join(lines, "\r\n") + "\r\n"
}
// canonicalRelaxedHeader applies relaxed header canonicalization.
func canonicalRelaxedHeader(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.Join(strings.Fields(v), " ")
return v
}
// foldDKIM folds long header values with CRLF WSP per RFC 5322.
func foldDKIM(s string) string {
const foldWidth = 70
var b strings.Builder
for len(s) > foldWidth {
b.WriteString(s[:foldWidth])
b.WriteString("\r\n ")
s = s[foldWidth:]
}
b.WriteString(s)
return b.String()
}
// saveDKIMKeyToConfig writes the PEM private key into [mail_server].DKIM_PRIVATE_KEY.
func saveDKIMKeyToConfig(key *rsa.PrivateKey) error {
der := x509.MarshalPKCS1PrivateKey(key)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
b64 := base64.StdEncoding.EncodeToString(pemBytes)
cfg := setting.CfgProvider
saveCfg, err := cfg.PrepareSaving()
if err != nil {
return err
}
cfg.Section("mail_server").Key("DKIM_PRIVATE_KEY").SetValue(b64)
saveCfg.Section("mail_server").Key("DKIM_PRIVATE_KEY").SetValue(b64)
if err := saveCfg.Save(); err != nil {
return err
}
setting.M8SHMailServer.DKIMPrivateKey = b64
return nil
}
// logDKIMPublicKey logs the DNS record an operator must publish.
func logDKIMPublicKey(key *rsa.PrivateKey) {
s := &DKIMSigner{priv: key}
log.Warn("M8SH: publish the following DNS TXT record at %s:\n%s", s.DNSName(), s.PublicKeyDNSRecord())
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"crypto/rand"
"crypto/rsa"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestGenerateAndLoadDKIM exercises the full key lifecycle: generate, encode,
// re-parse from the base64-PEM form, and check the derived public-key DNS record.
func TestGenerateAndLoadDKIM(t *testing.T) {
// Generate a key directly (bypassing config persistence).
key, err := rsa.GenerateKey(rand.Reader, 2048) // small for test speed
require.NoError(t, err)
s := &DKIMSigner{priv: key}
rec := s.PublicKeyDNSRecord()
assert.Contains(t, rec, "v=DKIM1")
assert.Contains(t, rec, "k=rsa")
assert.Contains(t, rec, "p=")
// The record must include the base64 of the RSA public key (DER, PKCS1).
assert.NotEmpty(t, publicKeyB64Only(s))
// DNS name is selector._domainkey.<domain>; the domain comes from settings,
// which may be empty in a unit test, but the selector prefix is stable.
assert.True(t, strings.HasPrefix(s.DNSName(), DKIMSelector+"._domainkey."))
}
// TestDKIMSignProducesFoldedSignature checks Sign returns a non-empty, folded
// DKIM-Signature value that mentions the relaxed/relaxed algo and our selector.
func TestDKIMSignProducesFoldedSignature(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
s := &DKIMSigner{priv: key}
sig, err := s.Sign("alice@example.com", "bob@example.com", "Hi",
"Mon, 01 Jan 2026 00:00:00 +0000", "<m1@example.com>", "hello world")
require.NoError(t, err)
assert.NotEmpty(t, sig)
assert.Contains(t, sig, "a=rsa-sha256")
assert.Contains(t, sig, "c=relaxed/relaxed")
assert.Contains(t, sig, "s="+DKIMSelector)
assert.Contains(t, sig, "bh=") // body hash present
assert.Contains(t, sig, "b=") // signature data present
}
// TestCanonicalBody verifies simple body canonicalization: trailing whitespace
// stripped per line, CRLF line endings, single trailing CRLF.
func TestCanonicalBody(t *testing.T) {
in := "line one \nline two\n\n\n"
out := canonicalBody(in)
assert.Equal(t, "line one\r\nline two\r\n", out)
}
// TestCanonicalRelaxedHeader verifies relaxed header canonicalization:
// lowercase, collapse runs of whitespace, trim.
func TestCanonicalRelaxedHeader(t *testing.T) {
assert.Equal(t, "foo bar", canonicalRelaxedHeader(" Foo Bar "))
assert.Equal(t, "x", canonicalRelaxedHeader("X"))
}
// TestFoldDKIM verifies long lines are folded with CRLF + space and that the
// final segment is appended even when shorter than the fold width.
func TestFoldDKIM(t *testing.T) {
out := foldDKIM(strings.Repeat("x", 200))
assert.Contains(t, out, "\r\n ")
// Reassembling (removing the fold) must reproduce the input.
reassembled := strings.ReplaceAll(out, "\r\n ", "")
assert.Equal(t, strings.Repeat("x", 200), reassembled)
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"net"
"strings"
"time"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// DNSReport is the result of an M8SH DNS check run.
type DNSReport struct {
SPFOk bool
DKIMOk bool
DKIMMatches bool
DMARCOk bool
MXOk bool
PTROk bool
// Suggested values to publish, for the admin self-check UI.
SPFSuggested string
DKIMExpectedValue string
DKIMName string
DMARCSuggested string
}
// CheckDNS verifies the mail-relevant DNS records for setting.Domain.
// It is non-fatal: the server starts regardless; this just reports gaps.
func CheckDNS(ctx context.Context, signer *DKIMSigner) *DNSReport {
r := &DNSReport{}
domain := setting.Domain
r.DKIMName = DKIMSelector + "._domainkey." + domain
if signer != nil {
r.DKIMExpectedValue = signer.PublicKeyDNSRecord()
}
resolver := net.DefaultResolver
// SPF: TXT @ contains v=spf1
r.SPFSuggested = "v=spf1 mx a -all"
if txts, err := lookupTXT(ctx, resolver, domain); err == nil {
for _, t := range txts {
if strings.HasPrefix(t, "v=spf1") {
r.SPFOk = true
break
}
}
} else {
log.Warn("M8SH DNS: SPF lookup failed for %s: %v", domain, err)
}
// DKIM: TXT default._domainkey.<domain> contains v=DKIM1 and matches key
if txts, err := lookupTXT(ctx, resolver, r.DKIMName); err == nil {
joined := strings.Join(txts, "")
if strings.Contains(joined, "v=DKIM1") {
r.DKIMOk = true
if signer != nil {
pubB64 := publicKeyB64Only(signer)
if pubB64 != "" && strings.Contains(stripSpaces(joined), pubB64) {
r.DKIMMatches = true
}
}
}
} else {
log.Warn("M8SH DNS: DKIM lookup failed for %s: %v", r.DKIMName, err)
}
// DMARC: TXT _dmarc.<domain> contains v=DMARC1
r.DMARCSuggested = "v=DMARC1; p=quarantine; rua=mailto:postmaster@" + domain
if txts, err := lookupTXT(ctx, resolver, "_dmarc."+domain); err == nil {
for _, t := range txts {
if strings.HasPrefix(t, "v=DMARC1") {
r.DMARCOk = true
break
}
}
} else {
log.Warn("M8SH DNS: DMARC lookup failed for _dmarc.%s: %v", domain, err)
}
// MX: <domain> MX points at domain (or a host under it)
if mxs, err := resolver.LookupMX(ctx, domain); err == nil {
for _, mx := range mxs {
if strings.HasSuffix(strings.TrimSuffix(mx.Host, "."), domain) {
r.MXOk = true
break
}
}
} else {
log.Warn("M8SH DNS: MX lookup failed for %s: %v", domain, err)
}
// PTR: <ip> resolves to domain. Use the first A record of the domain.
if ips, err := resolver.LookupIPAddr(ctx, domain); err == nil && len(ips) > 0 {
ip := ips[0].IP.String()
if names, err := resolver.LookupAddr(ctx, ip); err == nil {
for _, n := range names {
if strings.HasSuffix(strings.TrimSuffix(n, "."), domain) {
r.PTROk = true
break
}
}
}
}
return r
}
// LogGaps writes a warning for every missing record with the value to publish.
func (r *DNSReport) LogGaps() {
if r.SPFOk && r.DKIMOk && r.DKIMMatches && r.DMARCOk && r.MXOk && r.PTROk {
log.Info("M8SH DNS: all mail DNS records OK")
return
}
log.Warn("M8SH DNS: some mail DNS records are missing or incorrect:")
if !r.SPFOk {
log.Warn(" - SPF: publish TXT @ = %q", r.SPFSuggested)
}
if !r.DKIMOk || !r.DKIMMatches {
log.Warn(" - DKIM: publish TXT %s = %q", r.DKIMName, r.DKIMExpectedValue)
}
if !r.DMARCOk {
log.Warn(" - DMARC: publish TXT _dmarc.%s = %q", setting.Domain, r.DMARCSuggested)
}
if !r.MXOk {
log.Warn(" - MX: publish MX %s = %q", setting.Domain, "10 "+setting.Domain+".")
}
if !r.PTROk {
log.Warn(" - PTR: ensure your server IP reverse-resolves to %s", setting.Domain)
}
}
func lookupTXT(ctx context.Context, r *net.Resolver, name string) ([]string, error) {
cctx, cancel := context.WithTimeout(ctx, 6*time.Second)
defer cancel()
return r.LookupTXT(cctx, name)
}
func stripSpaces(s string) string {
return strings.Join(strings.Fields(s), "")
}
// publicKeyB64Only extracts the p=<base64> portion (without the v= prefix) of
// the expected DNS record, used to compare against a published TXT value.
func publicKeyB64Only(s *DKIMSigner) string {
rec := s.PublicKeyDNSRecord()
_, after, ok := strings.Cut(rec, "p=")
if !ok {
return ""
}
return stripSpaces(after)
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// TestPublicKeyB64Only verifies extraction of just the base64 key material
// (the p= portion) from the full DNS record string.
func TestPublicKeyB64Only(t *testing.T) {
// Build a fake signer-shaped value via its record method.
rec := "v=DKIM1; k=rsa; p=ABCDEF123456"
// Simulate the extraction logic directly.
idx := strings.Index(rec, "p=")
assert.GreaterOrEqual(t, idx, 0)
got := stripSpaces(rec[idx+2:])
assert.Equal(t, "ABCDEF123456", got)
}
// TestStripSpaces verifies whitespace is removed everywhere.
func TestStripSpaces(t *testing.T) {
assert.Equal(t, "abc", stripSpaces(" a b c "))
assert.Equal(t, "abc", stripSpaces("a\nb\tc"))
assert.Empty(t, stripSpaces(" "))
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"time"
mail_model "gitea.dev/models/mail"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/timeutil"
)
// StartHealthcheck runs the loopback healthcheck immediately and then on the
// configured interval. It is non-fatal: failures are recorded in m8sh_health.
func StartHealthcheck(ctx context.Context, signer *DKIMSigner, sender *Sender) {
runOnce(ctx, signer, sender)
interval := setting.M8SHMailServer.HealthcheckInterv
if interval <= 0 {
log.Info("M8SH healthcheck: interval is 0, running once at startup only")
return
}
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
runOnce(ctx, signer, sender)
}
}
})
}
// runOnce performs a single healthcheck: DNS + a full network loopback test
// (send to healthcheck@<domain> over the public MX path, wait for receipt).
func runOnce(ctx context.Context, signer *DKIMSigner, sender *Sender) {
r := CheckDNS(ctx, signer)
smtpOk := true
errMsg := ""
// Full network loopback: send through the public MX, expect local receipt.
if sender != nil {
to := "healthcheck@" + setting.Domain
probe := SendOptions{
From: "healthcheck@" + setting.Domain,
To: to,
Subject: "M8SH healthcheck " + time.Now().UTC().Format(time.RFC3339),
Body: "m8sh loopback healthcheck",
Headers: map[string]string{healthcheckHeader: "true"},
}
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := sender.Send(sctx, probe); err != nil {
smtpOk = false
errMsg = fmt.Sprintf("loopback send: %v", err)
}
}
h := &mail_model.Health{
CheckedAt: timeutil.TimeStampNow(),
SMTPOk: smtpOk,
DNSSpf: r.SPFOk,
DNSDkim: r.DKIMOk && r.DKIMMatches,
DNSDmarc: r.DMARCOk,
DNSPtr: r.PTROk,
ErrorMsg: errMsg,
}
if err := mail_model.InsertHealth(ctx, h); err != nil {
log.Error("M8SH healthcheck: insert: %v", err)
}
if !smtpOk || !r.SPFOk || !r.DKIMOk || !r.DKIMMatches || !r.DMARCOk || !r.PTROk {
log.Warn("M8SH healthcheck: issues detected (smtp_ok=%v spf=%v dkim=%v dmarc=%v ptr=%v)",
smtpOk, r.SPFOk, r.DKIMOk && r.DKIMMatches, r.DMARCOk, r.PTROk)
}
}
+321
View File
@@ -0,0 +1,321 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
stdlibmail "net/mail"
"strings"
"time"
"gitea.dev/models/mail"
user_model "gitea.dev/models/user"
"gitea.dev/modules/eventsource"
"gitea.dev/modules/json"
"gitea.dev/modules/log"
"gitea.dev/modules/storage"
"gitea.dev/modules/timeutil"
"gitea.dev/modules/util"
)
// healthcheckHeader is the marker the healthcheck probe sets to detect itself.
const healthcheckHeader = "X-M8SH-Healthcheck"
// DeliverInbound parses a raw RFC 5322 message and stores it for the recipient.
// Healthcheck probes are logged and dropped (not stored).
func DeliverInbound(ctx context.Context, from, to, raw string) error {
// Parse the message with net/mail.
msg, err := stdlibmail.ReadMessage(strings.NewReader(raw))
if err != nil {
return fmt.Errorf("m8sh: parse message: %w", err)
}
headers := msg.Header
// Healthcheck probe: log and drop.
if headers.Get(healthcheckHeader) != "" {
log.Info("M8SH: received healthcheck probe from %s (subject=%q)", from, headers.Get("Subject"))
return nil
}
// Resolve the local recipient user. The local-part of the address is the
// username; the address may also be a registered email.
ownerUID, err := resolveRecipientUID(ctx, to)
if err != nil {
return fmt.Errorf("m8sh: resolve recipient %s: %w", to, err)
}
if ownerUID == 0 {
// Unknown local recipient: drop silently (could bounce in the future).
log.Warn("M8SH: dropping mail for unknown local recipient %s", to)
return nil
}
// Decode subject.
subject := decodeMimeHeader(headers.Get("Subject"))
messageID := strings.TrimSpace(headers.Get("Message-ID"))
inReplyTo := strings.TrimSpace(headers.Get("In-Reply-To"))
date := time.Now()
if d, err := stdlibmail.ParseDate(headers.Get("Date")); err == nil {
date = d
}
// Determine body + content type and attachments.
body, contentType, atts, err := extractBodyAndAttachments(msg, headers)
if err != nil {
return fmt.Errorf("m8sh: extract body: %w", err)
}
// Build the canonical participant set and find/create the conversation.
fromAddr := parseAddressHeader(from)
participants := []string{fromAddr, to}
partKey := participantsKey(participants)
conv, err := mail.GetOrCreateConversationForUser(ctx, ownerUID, partKey, subject)
if err != nil {
return fmt.Errorf("m8sh: conversation: %w", err)
}
m := &mail.Message{
ConversationID: conv.ID,
UID: 0, // external sender for inbound SMTP
FromAddr: fromAddr,
MessageID: messageID,
InReplyTo: inReplyTo,
Subject: subject,
ContentType: contentType,
Body: body,
IsRead: false,
Transport: mail.TransportSMTP,
ReceivedUnix: timeutil.TimeStamp(date.Unix()),
}
if err := mail.InsertMessage(ctx, m); err != nil {
return fmt.Errorf("m8sh: insert message: %w", err)
}
// Notify the user in real time via the existing SSE eventsource, so the
// messenger UI and the navbar badge update without a manual refresh.
eventsource.GetManager().SendMessage(ownerUID, &eventsource.Event{
Name: "notification-count",
Data: `{"Count":1,"What":"m8sh-mail"}`,
})
// Persist attachment metadata + blobs.
for _, a := range atts {
a.MessageID = m.ID
if err := mail.InsertAttachment(ctx, a); err != nil {
log.Error("M8SH: insert attachment: %v", err)
}
}
return nil
}
// resolveRecipientUID returns the user id for a local email address, or 0.
// Tries: local-part as username, then registered email address lookup.
func resolveRecipientUID(ctx context.Context, addr string) (int64, error) {
addr = strings.ToLower(strings.TrimSpace(addr))
local, _, found := strings.Cut(addr, "@")
if !found {
return 0, nil
}
// Reserved/role accounts are not real users.
switch local {
case "noreply", "postmaster", "healthcheck", "abuse", "mailer":
return 0, nil
}
// local-part = username
if u, err := user_model.GetUserByName(ctx, local); err == nil && u != nil {
return u.ID, nil
}
// otherwise a registered (possibly non-primary) email
if ea, err := user_model.GetEmailAddressByEmail(ctx, addr); err == nil && ea != nil {
return ea.UID, nil
} else if !user_model.IsErrEmailAddressNotExist(err) && !user_model.IsErrUserNotExist(err) {
return 0, err
}
return 0, nil
}
// extractBodyAndAttachments parses a (possibly multipart) message.
func extractBodyAndAttachments(msg *stdlibmail.Message, headers stdlibmail.Header) (string, string, []*mail.Attachment, error) {
contentType := headers.Get("Content-Type")
if contentType == "" {
body, err := io.ReadAll(msg.Body)
return string(body), "text/plain", nil, err
}
mediatype, params, err := mime.ParseMediaType(contentType)
if err != nil {
body, _ := io.ReadAll(msg.Body)
return string(body), "text/plain", nil, nil
}
if strings.HasPrefix(mediatype, "multipart/") {
return parseMultipart(msg.Body, params["boundary"])
}
// Single-part non-multipart message.
body, err := readContent(msg.Body, headers.Get("Content-Transfer-Encoding"))
if err != nil {
return "", mediatype, nil, err
}
return body, mediatype, nil, nil
}
// parseMultipart walks a multipart/* tree picking text/plain or text/html as
// the body and stores every other part as an attachment.
func parseMultipart(r io.Reader, boundary string) (string, string, []*mail.Attachment, error) {
mpr := multipart.NewReader(r, boundary)
var plainBody, htmlBody string
var atts []*mail.Attachment
for {
p, err := mpr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return "", "", nil, err
}
ct := p.Header.Get("Content-Type")
mediatype, params, _ := mime.ParseMediaType(ct)
enc := p.Header.Get("Content-Transfer-Encoding")
switch {
case mediatype == "text/plain":
b, _ := readContent(p, enc)
if plainBody == "" {
plainBody = decodeCharset(b, params["charset"])
}
case mediatype == "text/html":
b, _ := readContent(p, enc)
if htmlBody == "" {
htmlBody = decodeCharset(b, params["charset"])
}
case strings.HasPrefix(mediatype, "multipart/"):
// Recurse into nested multipart (e.g. alternative inside mixed).
if pb, hb, subAtts, err := parseMultipart(p, params["boundary"]); err == nil {
if plainBody == "" {
plainBody = pb
}
if htmlBody == "" {
htmlBody = hb
}
atts = append(atts, subAtts...)
}
default:
// Attachment (inline or not).
data, _ := readContentBytes(p, enc)
att, err := storeAttachment(p, mediatype, params, data)
if err != nil {
log.Warn("M8SH: store attachment: %v", err)
continue
}
atts = append(atts, att)
}
}
if htmlBody != "" {
return htmlBody, "text/html", atts, nil
}
return plainBody, "text/plain", atts, nil
}
// readContent decodes a part body according to its Content-Transfer-Encoding.
func readContent(r io.Reader, enc string) (string, error) {
b, err := readContentBytes(r, enc)
return string(b), err
}
func readContentBytes(r io.Reader, enc string) ([]byte, error) {
switch strings.ToLower(strings.TrimSpace(enc)) {
case "base64":
dec := base64.NewDecoder(base64.StdEncoding, r)
return io.ReadAll(dec)
case "quoted-printable":
dec := quotedprintable.NewReader(r)
return io.ReadAll(dec)
default:
return io.ReadAll(r)
}
}
// storeAttachment writes the blob to the attachments storage and returns metadata.
func storeAttachment(p *multipart.Part, mediatype string, params map[string]string, data []byte) (*mail.Attachment, error) {
filename := params["name"]
if filename == "" {
filename = p.FileName()
}
if filename == "" {
filename = "attachment"
}
cid := strings.Trim(p.Header.Get("Content-ID"), "<>")
isInline := strings.EqualFold(p.Header.Get("Content-Disposition"), "inline") || cid != ""
storagePath := "m8sh/" + util.CryptoRandomString(32)
if _, err := storage.Attachments.Save(storagePath, strings.NewReader(string(data)), int64(len(data))); err != nil {
return nil, err
}
return &mail.Attachment{
StoragePath: storagePath,
ContentType: mediatype,
Filename: filename,
Size: int64(len(data)),
IsInline: isInline,
ContentID: cid,
}, nil
}
// decodeMimeHeader decodes RFC 2047 encoded-word headers.
func decodeMimeHeader(s string) string {
dec := new(mime.WordDecoder)
out, err := dec.DecodeHeader(s)
if err != nil {
return s
}
return out
}
// decodeCharset is a no-op for utf-8 (most common); fall back unchanged.
func decodeCharset(s, charset string) string {
_ = charset
return s
}
// parseAddressHeader extracts the bare email from a "Name <a@b>" header.
func parseAddressHeader(v string) string {
if a, err := stdlibmail.ParseAddress(v); err == nil {
return a.Address
}
return strings.TrimSpace(v)
}
// participantsKey returns a canonical JSON array of sorted lowercase addresses.
func participantsKey(addrs []string) string {
lower := make([]string, len(addrs))
for i, a := range addrs {
lower[i] = strings.ToLower(strings.TrimSpace(a))
}
// dedupe + sort for stable matching
seen := map[string]bool{}
uniq := lower[:0]
for _, a := range lower {
if !seen[a] {
seen[a] = true
uniq = append(uniq, a)
}
}
// sort
for i := 1; i < len(uniq); i++ {
for j := i; j > 0 && uniq[j-1] > uniq[j]; j-- {
uniq[j-1], uniq[j] = uniq[j], uniq[j-1]
}
}
b, _ := json.Marshal(uniq)
return string(b)
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// TestReadContent_Plain verifies the default (no encoding) path reads bytes verbatim.
func TestReadContent_Plain(t *testing.T) {
out, err := readContent(strings.NewReader("hello world"), "")
assert.NoError(t, err)
assert.Equal(t, "hello world", out)
}
// TestReadContent_Base64 verifies base64 decoding.
func TestReadContent_Base64(t *testing.T) {
// "hello" base64 = "aGVsbG8="
out, err := readContent(strings.NewReader("aGVsbG8="), "base64")
assert.NoError(t, err)
assert.Equal(t, "hello", out)
}
// TestReadContent_QuotedPrintable verifies quoted-printable decoding.
func TestReadContent_QuotedPrintable(t *testing.T) {
// "=68=69" decodes to "hi"
out, err := readContent(strings.NewReader("=68=69"), "quoted-printable")
assert.NoError(t, err)
assert.Equal(t, "hi", out)
}
// TestParticipantsKey_Canonical verifies dedup, lowercase, sort, JSON shape.
func TestParticipantsKey_Canonical(t *testing.T) {
got := participantsKey([]string{"Bob@Example.com", "alice@example.com", "BOB@example.com"})
// Sorted + deduped + lowercased.
assert.Equal(t, `["alice@example.com","bob@example.com"]`, got)
}
// TestParticipantsKey_OrderIndependent verifies the same set in any order
// produces the same key (essential for conversation matching).
func TestParticipantsKey_OrderIndependent(t *testing.T) {
a := participantsKey([]string{"a@x.com", "b@x.com"})
b := participantsKey([]string{"b@x.com", "a@x.com"})
assert.Equal(t, a, b)
}
// TestParseAddressHeader verifies extraction of the bare email from a
// "Display Name <addr>" header and the bare-addr fallback.
func TestParseAddressHeader(t *testing.T) {
assert.Equal(t, "a@b.com", parseAddressHeader("Alice <a@b.com>"))
assert.Equal(t, "a@b.com", parseAddressHeader("a@b.com"))
assert.Equal(t, "garbage", parseAddressHeader("garbage"))
}
// TestDecodeMimeHeader verifies RFC 2047 encoded-word decoding falls back
// gracefully on plain ASCII (returns it unchanged).
func TestDecodeMimeHeader_PlainASCII(t *testing.T) {
assert.Equal(t, "Hello", decodeMimeHeader("Hello"))
}
+259
View File
@@ -0,0 +1,259 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"context"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
"strings"
"time"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// SendOptions describes an outgoing message.
type SendOptions struct {
From string // envelope + From header (typically noreply@<domain>)
To string // single recipient
Subject string
Body string
HTML bool // if true, body is HTML
Headers map[string]string
}
// Sender is the shared outgoing sender used by both M8SH user mail and the
// replaced Gitea system mailer.
type Sender struct {
dkim *DKIMSigner
}
// NewSender returns a Sender with the configured DKIM signer (may be nil).
func NewSender(dkim *DKIMSigner) *Sender {
return &Sender{dkim: dkim}
}
// Send delivers a message, preferring the M8SH HTTP endpoint when the recipient
// domain advertises one, and falling back to SMTP (MX lookup) otherwise.
func (s *Sender) Send(ctx context.Context, opts SendOptions) error {
if opts.From == "" {
opts.From = "noreply@" + setting.Domain
}
toAddr := opts.To
_, domain, _ := strings.Cut(toAddr, "@")
// Try the M8SH HTTP fast-path first.
if domain != "" && domain != setting.Domain {
if used, err := s.tryM8SHHTTP(ctx, opts); err != nil {
log.Warn("M8SH send: HTTP endpoint for %s failed: %v (falling back to SMTP)", domain, err)
} else if used {
return nil
}
}
// SMTP fallback via MX lookup.
return s.sendSMTP(ctx, opts)
}
// sendSMTP resolves MX records and delivers via plaintext/STARTTLS SMTP.
func (s *Sender) sendSMTP(ctx context.Context, opts SendOptions) error {
_, domain, found := strings.Cut(opts.To, "@")
if !found {
return fmt.Errorf("m8sh send: invalid recipient %q", opts.To)
}
mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
if err != nil || len(mxs) == 0 {
// Per RFC 5321, fall back to the domain itself as the MX host.
mxs = []*net.MX{{Host: domain + "."}}
}
raw := s.buildRawMessage(opts)
var lastErr error
for _, mx := range mxs {
host := strings.TrimSuffix(mx.Host, ".")
addr := net.JoinHostPort(host, "25")
if err := dialAndDeliver(addr, host, opts.From, opts.To, raw); err != nil {
lastErr = err
log.Warn("M8SH send: deliver to %s failed: %v", host, err)
continue
}
return nil
}
if lastErr == nil {
lastErr = fmt.Errorf("m8sh send: no MX could be reached for %s", domain)
}
return lastErr
}
// dialAndDeliver opens a connection, says HELO, issues MAIL/RCPT/DATA.
func dialAndDeliver(addr, host, from, to string, raw []byte) error {
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
if err != nil {
return fmt.Errorf("dial %s: %w", addr, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer func() { _ = c.Quit() }()
helo := setting.Domain
if err := c.Hello(helo); err != nil {
return fmt.Errorf("helo: %w", err)
}
// Opportunistic STARTTLS.
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(tlsClientConfig(host)); err != nil {
log.Warn("M8SH send: STARTTLS to %s failed, continuing plaintext: %v", host, err)
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("MAIL: %w", err)
}
if err := c.Rcpt(to); err != nil {
return fmt.Errorf("RCPT: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("DATA: %w", err)
}
if _, err := w.Write(raw); err != nil {
return fmt.Errorf("write body: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("close data: %w", err)
}
return nil
}
// buildRawMessage assembles the full RFC 5322 message, applying DKIM signing.
func (s *Sender) buildRawMessage(opts SendOptions) []byte {
date := time.Now().Format(time.RFC1123Z)
messageID := fmt.Sprintf("<m8sh-%d@%s>", time.Now().UnixNano(), setting.Domain)
contentType := "text/plain; charset=utf-8"
if opts.HTML {
contentType = "text/html; charset=utf-8"
}
var hdr strings.Builder
hdr.WriteString("From: " + opts.From + "\r\n")
hdr.WriteString("To: " + opts.To + "\r\n")
hdr.WriteString("Subject: " + opts.Subject + "\r\n")
hdr.WriteString("Date: " + date + "\r\n")
hdr.WriteString("Message-ID: " + messageID + "\r\n")
hdr.WriteString("MIME-Version: 1.0\r\n")
hdr.WriteString("Content-Type: " + contentType + "\r\n")
hdr.WriteString("Content-Transfer-Encoding: 8bit\r\n")
for k, v := range opts.Headers {
hdr.WriteString(k + ": " + v + "\r\n")
}
// DKIM sign (relaxed/relaxed) over the headers above + body.
if s.dkim != nil {
sig, err := s.dkim.Sign(opts.From, opts.To, opts.Subject, date, messageID, opts.Body)
if err != nil {
log.Warn("M8SH send: dkim sign failed: %v", err)
} else {
hdr.WriteString("DKIM-Signature: " + sig + "\r\n")
}
}
hdr.WriteString("\r\n")
var buf bytes.Buffer
buf.WriteString(hdr.String())
buf.WriteString(strings.ReplaceAll(opts.Body, "\n", "\r\n"))
return buf.Bytes()
}
// tryM8SHHTTP checks https://<domain>/.well-known/m8sh and POSTs the message
// to /api/v1/mail/deliver if advertised. Returns (used, err).
func (s *Sender) tryM8SHHTTP(ctx context.Context, opts SendOptions) (bool, error) {
_, domain, _ := strings.Cut(opts.To, "@")
wellKnown := "https://" + domain + "/.well-known/m8sh"
checkReq, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
if err != nil {
return false, err
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(checkReq)
if err != nil {
return false, nil // not an M8SH host, just fall back
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return false, nil
}
// Advertised: deliver via HTTP.
payload := buildDeliverPayload(opts)
deliverURL := "https://" + domain + "/api/v1/mail/deliver"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deliverURL, bytes.NewReader(payload))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
dresp, err := client.Do(req)
if err != nil {
return false, err
}
defer dresp.Body.Close()
_, _ = io.Copy(io.Discard, dresp.Body)
if dresp.StatusCode != http.StatusOK {
return false, fmt.Errorf("m8sh http deliver: %s", dresp.Status)
}
return true, nil
}
// buildDeliverPayload is a minimal JSON payload for /api/v1/mail/deliver.
func buildDeliverPayload(opts SendOptions) []byte {
// Keep it simple and human-readable; the receiver just persists the fields.
var b strings.Builder
b.WriteString(`{"from":"` + jsonEscape(opts.From) + `","to":"` + jsonEscape(opts.To) + `"`)
b.WriteString(`,"subject":"` + jsonEscape(opts.Subject) + `"`)
if opts.HTML {
b.WriteString(`,"content_type":"text/html"`)
} else {
b.WriteString(`,"content_type":"text/plain"`)
}
b.WriteString(`,"body":"` + jsonEscape(opts.Body) + `"}`)
return []byte(b.String())
}
func jsonEscape(s string) string {
var b strings.Builder
for _, r := range s {
switch r {
case '"':
b.WriteString(`\"`)
case '\\':
b.WriteString(`\\`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
default:
if r < 0x20 {
fmt.Fprintf(&b, `\u%04x`, r)
} else {
b.WriteRune(r)
}
}
}
return b.String()
}
+15
View File
@@ -0,0 +1,15 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import "crypto/tls"
// tlsClientConfig returns an opportunistic TLS config for outbound SMTP.
func tlsClientConfig(host string) *tls.Config {
return &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false,
}
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"bufio"
"context"
"crypto/tls"
"fmt"
"net"
"strings"
"sync"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/process"
"gitea.dev/modules/setting"
)
const (
smtpPort = "25"
smtpBannerHost = "m8sh"
)
// smtpSession tracks a single SMTP transaction.
type smtpSession struct {
conn net.Conn
r *bufio.Reader
w *bufio.Writer
tlsConf *tls.Config
from string
rcpts []string
helo string
starttls bool
}
// StartSMTP launches the built-in SMTP listener on port 25.
// It runs in a graceful-managed goroutine and is meant to be called after the
// Gitea TLS listener is up (so certs are ready).
func StartSMTP(ctx context.Context) error {
tlsConf, err := GetTLSConfig()
if err != nil {
return fmt.Errorf("m8sh smtp: tls config: %w", err)
}
addr := net.JoinHostPort(setting.HTTPAddr, smtpPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("m8sh smtp: listen %s: %w", addr, err)
}
_, _, finished := process.GetManager().AddTypedContext(ctx, "M8SH: SMTP server (port 25)", process.SystemProcessType, true)
go func() {
defer finished()
<-graceful.GetManager().IsShutdown()
_ = ln.Close()
}()
log.Info("M8SH SMTP: listening on %s (STARTTLS: %v)", addr, tlsConf != nil)
go func() {
var wg sync.WaitGroup
for {
conn, err := ln.Accept()
if err != nil {
select {
case <-graceful.GetManager().IsShutdown():
wg.Wait()
return
default:
log.Error("M8SH SMTP: accept error: %v", err)
continue
}
}
wg.Go(func() {
handleSMTPConn(ctx, conn, tlsConf)
})
}
}()
return nil
}
func handleSMTPConn(ctx context.Context, conn net.Conn, tlsConf *tls.Config) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Minute))
s := &smtpSession{
conn: conn,
r: bufio.NewReader(conn),
w: bufio.NewWriter(conn),
tlsConf: tlsConf,
}
fmt.Fprintf(s.w, "220 %s M8SH ESMTP\r\n", smtpBannerHost)
if err := s.w.Flush(); err != nil {
return
}
for {
line, err := s.r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
verb := strings.ToUpper(line)
cmd, _ := splitCmd(verb)
switch cmd {
case "HELO", "EHLO":
s.helo = strings.TrimSpace(line[len(cmd):])
s.greet(cmd == "EHLO")
case "STARTTLS":
s.doStartTLS()
case "MAIL":
s.handleMail(line)
case "RCPT":
s.handleRcpt(line)
case "DATA":
s.handleData(ctx)
case "RSET":
s.reset()
fmt.Fprint(s.w, "250 OK\r\n")
case "NOOP":
fmt.Fprint(s.w, "250 OK\r\n")
case "QUIT":
fmt.Fprint(s.w, "221 Bye\r\n")
s.w.Flush()
return
default:
fmt.Fprintf(s.w, "502 Command not implemented: %s\r\n", cmd)
}
if err := s.w.Flush(); err != nil {
return
}
}
}
// greet prints the EHLO capability list or the HELO acknowledgement.
func (s *smtpSession) greet(ehlo bool) {
fmt.Fprintf(s.w, "250-%s M8SH at your service\r\n", smtpBannerHost)
fmt.Fprintf(s.w, "250-8BITMIME\r\n")
fmt.Fprintf(s.w, "250-PIPELINING\r\n")
fmt.Fprintf(s.w, "250-SIZE %d\r\n", 25*1024*1024)
if s.tlsConf != nil && !s.starttls {
fmt.Fprintf(s.w, "250-STARTTLS\r\n")
}
fmt.Fprintf(s.w, "250 HELP\r\n")
_ = ehlo
}
func (s *smtpSession) doStartTLS() {
if s.tlsConf == nil {
fmt.Fprint(s.w, "502 STARTTLS not available\r\n")
return
}
fmt.Fprint(s.w, "220 Ready to start TLS\r\n")
s.w.Flush()
tlsConn := tls.Server(s.conn, s.tlsConf)
if err := tlsConn.Handshake(); err != nil {
log.Warn("M8SH SMTP: STARTTLS handshake failed: %v", err)
return
}
s.conn = tlsConn
s.r = bufio.NewReader(tlsConn)
s.w = bufio.NewWriter(tlsConn)
s.starttls = true
s.reset()
}
func (s *smtpSession) handleMail(line string) {
addr := extractAddr(line)
if addr == "" {
fmt.Fprint(s.w, "501 Syntax: MAIL FROM:<address>\r\n")
return
}
s.reset()
s.from = addr
fmt.Fprint(s.w, "250 OK\r\n")
}
func (s *smtpSession) handleRcpt(line string) {
if s.from == "" {
fmt.Fprint(s.w, "503 Need MAIL first\r\n")
return
}
addr := extractAddr(line)
if addr == "" {
fmt.Fprint(s.w, "501 Syntax: RCPT TO:<address>\r\n")
return
}
// Only accept mail for our own domain (we are not an open relay).
if !strings.HasSuffix(strings.ToLower(addr), "@"+strings.ToLower(setting.Domain)) {
fmt.Fprintf(s.w, "550 Relay not allowed for %s\r\n", addr)
return
}
s.rcpts = append(s.rcpts, addr)
fmt.Fprint(s.w, "250 OK\r\n")
}
func (s *smtpSession) handleData(ctx context.Context) {
if len(s.rcpts) == 0 {
fmt.Fprint(s.w, "503 Need RCPT first\r\n")
return
}
fmt.Fprint(s.w, "354 End data with <CR><LF>.<CR><LF>\r\n")
s.w.Flush()
// Read the message body until the lone dot terminator.
var b strings.Builder
for {
line, err := s.r.ReadString('\n')
if err != nil {
return
}
trimmed := strings.TrimRight(line, "\r\n")
if trimmed == "." {
break
}
// RFC 5321 dot-unstuffing
if strings.HasPrefix(trimmed, "..") {
trimmed = trimmed[1:]
}
b.WriteString(trimmed)
b.WriteString("\r\n")
}
raw := b.String()
for _, rcpt := range s.rcpts {
if err := DeliverInbound(ctx, s.from, rcpt, raw); err != nil {
log.Error("M8SH SMTP: deliver %s -> %s: %v", s.from, rcpt, err)
}
}
s.reset()
fmt.Fprint(s.w, "250 OK: queued\r\n")
}
func (s *smtpSession) reset() {
s.from = ""
s.rcpts = nil
}
// splitCmd returns the command verb and the rest of the line.
func splitCmd(line string) (cmd, rest string) {
line = strings.TrimSpace(line)
if before, after, ok := strings.Cut(line, " "); ok {
return before, after
}
return line, ""
}
// extractAddr pulls the <addr> out of a "MAIL FROM:<addr>" or "RCPT TO:<addr>" line.
func extractAddr(line string) string {
i := strings.IndexByte(line, '<')
if i < 0 {
return ""
}
j := strings.IndexByte(line[i+1:], '>')
if j < 0 {
return ""
}
return line[i+1 : i+1+j]
}
+43
View File
@@ -0,0 +1,43 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"testing"
"github.com/stretchr/testify/assert"
)
// TestSplitCmd verifies the SMTP command verb + rest splitting.
func TestSplitCmd(t *testing.T) {
cmd, rest := splitCmd("MAIL FROM:<a@b.com>")
assert.Equal(t, "MAIL", cmd)
assert.Equal(t, "FROM:<a@b.com>", rest)
cmd, rest = splitCmd("NOOP")
assert.Equal(t, "NOOP", cmd)
assert.Empty(t, rest)
cmd, rest = splitCmd("EHLO example.com")
assert.Equal(t, "EHLO", cmd)
assert.Equal(t, "example.com", rest)
}
// TestExtractAddr verifies <addr> extraction from MAIL FROM / RCPT TO lines,
// including the missing-bracket and malformed cases.
func TestExtractAddr(t *testing.T) {
assert.Equal(t, "a@b.com", extractAddr("MAIL FROM:<a@b.com>"))
assert.Equal(t, "x@y.com", extractAddr("RCPT TO:<x@y.com>"))
assert.Empty(t, extractAddr("MAIL FROM:a@b.com")) // no angle brackets
assert.Empty(t, extractAddr("")) // empty
assert.Empty(t, extractAddr("MAIL FROM:<unclosed")) // no closing bracket
}
// TestSessionReset verifies the transaction state is cleared.
func TestSessionReset(t *testing.T) {
s := &smtpSession{from: "a@b.com", rcpts: []string{"x@y.com"}}
s.reset()
assert.Empty(t, s.from)
assert.Empty(t, s.rcpts)
}
+47
View File
@@ -0,0 +1,47 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"fmt"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
)
// SharedSender is the process-wide outgoing sender, set by Start.
// It is also used by the Gitea system-mailer shim (mailer.go).
var SharedSender *Sender
// Start boots the M8SH mail subsystem: DKIM key, DNS check, SMTP listener and
// the healthcheck goroutine. It is a no-op unless [mail_server] ENABLED=true.
// It must be called after Gitea's TLS listener is ready.
func Start(ctx context.Context) error {
if setting.M8SHMailServer == nil || !setting.M8SHMailServer.Enabled {
return nil
}
signer, err := LoadOrGenerateDKIMKey()
if err != nil {
return fmt.Errorf("m8sh: dkim: %w", err)
}
// DNS check at startup (non-fatal, logged warnings with copy-paste values).
CheckDNS(ctx, signer).LogGaps()
SharedSender = NewSender(signer)
if err := StartSMTP(ctx); err != nil {
// Don't abort boot over the SMTP listener; mail may still go out.
log.Error("M8SH: SMTP listener failed to start: %v", err)
} else {
go StartHealthcheck(ctx, signer, SharedSender)
}
log.Info("M8SH mail server started (domain=%s)", setting.Domain)
_ = graceful.GetManager // ensure import is exercised
return nil
}
+71
View File
@@ -0,0 +1,71 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mail
import (
"crypto/tls"
"errors"
"fmt"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"github.com/caddyserver/certmagic"
)
// ErrNoTLSCertificate indicates no TLS cert is configured (SMTP runs plaintext).
var ErrNoTLSCertificate = errors.New("no TLS certificate configured")
// GetTLSConfig builds a *tls.Config for the SMTP STARTTLS listener, reusing
// the very same certificate Gitea's HTTPS server uses.
//
// Precedence:
// 1. setting.EnableAcme → use certmagic (same Default.Storage as web_acme.go)
// 2. setting.CertFile/KeyFile → load the keypair directly
// 3. otherwise → returns nil (SMTP runs plaintext with a warning)
func GetTLSConfig() (*tls.Config, error) {
domain := setting.Domain
if setting.EnableAcme {
// Mirror the storage configuration used by cmd/web_acme.go. We do not
// request new certs here (the HTTPS listener already did); we just
// serve the cached one for STARTTLS.
oldDefaultStorage := certmagic.Default.Storage
oldDefaultACME := certmagic.DefaultACME
certmagic.Default.Storage = &certmagic.FileStorage{Path: setting.AcmeLiveDirectory}
certmagic.DefaultACME = certmagic.ACMEIssuer{
CA: setting.AcmeURL,
Email: setting.AcmeEmail,
Agreed: setting.AcmeTOS,
DisableHTTPChallenge: false,
DisableTLSALPNChallenge: false,
ListenHost: setting.HTTPAddr,
}
defer func() {
certmagic.Default.Storage = oldDefaultStorage
certmagic.DefaultACME = oldDefaultACME
}()
magic := certmagic.NewDefault()
tlsConfig := magic.TLSConfig()
tlsConfig.ServerName = domain
tlsConfig.MinVersion = tls.VersionTLS12
return tlsConfig, nil
}
if setting.CertFile != "" && setting.KeyFile != "" {
cert, err := tls.LoadX509KeyPair(setting.CertFile, setting.KeyFile)
if err != nil {
return nil, fmt.Errorf("m8sh: load cert/key pair: %w", err)
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
ServerName: domain,
MinVersion: tls.VersionTLS12,
}, nil
}
log.Warn("M8SH TLS: no certificate available (no ACME, no CERT_FILE) — SMTP will run without STARTTLS")
return nil, ErrNoTLSCertificate
}
+17 -7
View File
@@ -34,13 +34,23 @@ func NewContext(ctx context.Context) {
notify_service.RegisterNotifier(NewNotifier())
}
switch setting.MailService.Protocol {
case "sendmail":
sender = &sender_service.SendmailSender{}
case "dummy":
sender = &sender_service.DummySender{}
default:
sender = &sender_service.SMTPSender{}
// M8SH: if the built-in mail server is enabled, route all system mail
// (activation, password reset, notifications) through it instead of the
// external [mailer] SMTP config. The sender is always noreply@<domain>.
if setting.M8SHMailServer != nil && setting.M8SHMailServer.Enabled {
sender = &sender_service.M8SHSender{}
if setting.MailService == nil {
setting.MailService = &setting.Mailer{From: "noreply@" + setting.Domain}
}
} else {
switch setting.MailService.Protocol {
case "sendmail":
sender = &sender_service.SendmailSender{}
case "dummy":
sender = &sender_service.DummySender{}
default:
sender = &sender_service.SMTPSender{}
}
}
_ = templates.MailRenderer()
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 The M8SH Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package sender
import (
"context"
"io"
"net/mail"
"strings"
mailservice "gitea.dev/services/mail"
)
// M8SHSender implements the gitea mailer Sender interface by delegating to the
// M8SH built-in mail server (services/mail). Used when [mail_server] ENABLED.
type M8SHSender struct{}
var _ Sender = &M8SHSender{}
// Send reads a gomail message, extracts fields, and routes through M8SH.
func (s *M8SHSender) Send(from string, to []string, msg io.WriterTo) error {
if mailservice.SharedSender == nil {
return errM8SHNotReady
}
// Materialize the message bytes.
var buf strings.Builder
if _, err := msg.WriteTo(&buf); err != nil {
return err
}
raw := buf.String()
// Parse headers we care about.
parsed, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
return err
}
subject := parsed.Header.Get("Subject")
contentType := parsed.Header.Get("Content-Type")
body := raw
// Best-effort: split off the header block to get the body.
if _, rest, ok := strings.Cut(raw, "\r\n\r\n"); ok {
body = rest
} else if _, rest, ok := strings.Cut(raw, "\n\n"); ok {
body = rest
}
for _, rcpt := range to {
opts := mailservice.SendOptions{
From: from,
To: rcpt,
Subject: subject,
Body: body,
HTML: strings.HasPrefix(contentType, "text/html"),
}
if err := mailservice.SharedSender.Send(context.Background(), opts); err != nil {
return err
}
}
return nil
}
var errM8SHNotReady = errString("M8SH mail sender is not ready (start [mail_server] first)")
type errString string
func (e errString) Error() string { return string(e) }
+28
View File
@@ -51,6 +51,34 @@
<div class="ui warning message">{{ctx.Locale.Tr "admin.config.cache_test_slow" .CacheSlow}}</div>
{{end}}
{{/* M8SH: Mail DNS status */}}
{{if .MailDNSReport}}
<div class="ui attached segment">
<h3 class="ui header">M8SH Mail DNS Status</h3>
<table class="ui very basic table">
<tbody>
<tr><td>SPF (TXT @)</td><td>{{if .MailDNSReport.SPFOk}}{{else}}{{end}}</td>
<td><code>{{.MailDNSReport.SPFSuggested}}</code></td></tr>
<tr><td>DKIM (TXT {{.MailDNSReport.DKIMName}})</td>
<td>{{if .MailDNSReport.DKIMMatches}}{{else if .MailDNSReport.DKIMOk}}⚠️ key mismatch{{else}}{{end}}</td>
<td><code>{{.MailDNSReport.DKIMExpectedValue}}</code></td></tr>
<tr><td>DMARC (TXT _dmarc.{{.Domain}})</td><td>{{if .MailDNSReport.DMARCOk}}{{else}}{{end}}</td>
<td><code>{{.MailDNSReport.DMARCSuggested}}</code></td></tr>
<tr><td>MX</td><td>{{if .MailDNSReport.MXOk}}{{else}}{{end}}</td>
<td><code>10 {{.Domain}}.</code></td></tr>
<tr><td>PTR</td><td>{{if .MailDNSReport.PTROk}}{{else}}{{end}}</td>
<td><code>{{.Domain}}</code></td></tr>
</tbody>
</table>
{{if .MailHealth}}
<div class="ui {{if .MailHealth.SMTPOk}}ok{{else}}error{{end}} message">
Last healthcheck: SMTP {{if .MailHealth.SMTPOk}}OK{{else}}FAILED{{end}}
{{if .MailHealth.ErrorMsg}}{{.MailHealth.ErrorMsg}}{{end}}
</div>
{{end}}
</div>
{{end}}
{{/* only shown when there is no visible "self-check-problem" */}}
<div class="ui attached segment tw-hidden self-check-no-problem">
{{ctx.Locale.Tr "admin.self_check.no_problem_found"}}
+5
View File
@@ -15,6 +15,11 @@
{{if and .IsSigned .MustChangePassword}}
{{/* No links */}}
{{else if .IsSigned}}
{{$mailData := .PageGlobalData}}{{$mailUnread := 0}}{{if $mailData}}{{$mailUnread = call $mailData.GetMailUnreadCount}}{{end}}
<a class="item{{if .PageIsMail}} active{{end}}" href="{{AppSubUrl}}/mail">
<span>{{ctx.Locale.Tr "mail.navbar"}}</span>{{if $mailUnread}}
<span class="ui tiny red label">{{$mailUnread}}</span>{{end}}
</a>
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
<a class="item{{if .PageIsIssues}} active{{end}}" href="{{AppSubUrl}}/issues">{{ctx.Locale.Tr "issues"}}</a>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{template "base/head" .}}
<div id="messenger-app"
data-current-user-email="{{$.CurrentUserEmail}}"
data-conversations="{{.ConversationsJSON}}"
data-active-conversation="{{.ActiveConvJSON}}"
data-messages="{{.MessagesJSON}}"
data-initial-conversation-id="{{.Conversation.ID}}"
data-locale-search="{{ctx.Locale.Tr "mail.search_placeholder"}}"
data-locale-compose="{{ctx.Locale.Tr "mail.compose_placeholder"}}"
data-locale-markdown="{{ctx.Locale.Tr "mail.markdown_supported"}}"
data-locale-select="{{ctx.Locale.Tr "mail.select_conversation"}}"
data-locale-empty="{{ctx.Locale.Tr "mail.empty"}}"
data-locale-new-conv="{{ctx.Locale.Tr "mail.new_conversation"}}"
data-locale-inbox="{{ctx.Locale.Tr "mail.inbox"}}"
data-locale-sent="{{ctx.Locale.Tr "mail.sent"}}"
data-locale-starred="{{ctx.Locale.Tr "mail.starred"}}"
data-locale-archive="{{ctx.Locale.Tr "mail.archive"}}"
data-locale-groups="{{ctx.Locale.Tr "mail.groups"}}"
data-locale-reply-to="{{ctx.Locale.Tr "mail.reply_to"}}"
data-locale-participants="Participants (email or username)"
data-locale-subject="Subject"
data-locale-message="Message"
data-locale-cancel="Cancel"
data-locale-send="{{ctx.Locale.Tr "mail.send"}}"
></div>
{{template "base/footer" .}}
+24
View File
@@ -0,0 +1,24 @@
{{template "base/head" .}}
<div id="messenger-app"
data-current-user-email="{{.CurrentUserEmail}}"
data-conversations="{{.ConversationsJSON}}"
data-initial-conversation-id="0"
data-locale-search="{{ctx.Locale.Tr "mail.search_placeholder"}}"
data-locale-compose="{{ctx.Locale.Tr "mail.compose_placeholder"}}"
data-locale-markdown="{{ctx.Locale.Tr "mail.markdown_supported"}}"
data-locale-select="{{ctx.Locale.Tr "mail.select_conversation"}}"
data-locale-empty="{{ctx.Locale.Tr "mail.empty"}}"
data-locale-new-conv="{{ctx.Locale.Tr "mail.new_conversation"}}"
data-locale-inbox="{{ctx.Locale.Tr "mail.inbox"}}"
data-locale-sent="{{ctx.Locale.Tr "mail.sent"}}"
data-locale-starred="{{ctx.Locale.Tr "mail.starred"}}"
data-locale-archive="{{ctx.Locale.Tr "mail.archive"}}"
data-locale-groups="{{ctx.Locale.Tr "mail.groups"}}"
data-locale-reply-to="{{ctx.Locale.Tr "mail.reply_to"}}"
data-locale-participants="Participants (email or username)"
data-locale-subject="Subject"
data-locale-message="Message"
data-locale-cancel="Cancel"
data-locale-send="{{ctx.Locale.Tr "mail.send"}}"
></div>
{{template "base/footer" .}}
+30
View File
@@ -0,0 +1,30 @@
/* M8SH Messenger — layout: make #messenger-app fill viewport below navbar.
The actual component styles are scoped in MessengerApp.vue.
This CSS ensures the height chain works: body > .full.height > .page-content > #messenger-app */
/* .full.height is a flex item in body (flex column). Make it also a flex container
so its children (navbar + page-content) can fill height. */
.full.height:has(#messenger-app) {
display: flex;
flex-direction: column;
padding-bottom: 0 !important; /* cancel the 64px bottom padding */
}
/* .page-content contains #messenger-app. Make it fill remaining height. */
.page-content:has(#messenger-app) {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 0 !important; /* cancel the 16px page-spacing margin-top */
padding: 0 !important;
min-height: 0; /* allow inner scroll areas to work */
overflow: hidden;
}
/* #messenger-app is the Vue mount point — fill its parent */
#messenger-app {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
+1
View File
@@ -86,5 +86,6 @@
@import "./actions.css";
@import "./helpers.css";
@import "./features/messenger.css";
@tailwind utilities;
+587
View File
@@ -0,0 +1,587 @@
<script lang="ts">
import {defineComponent, ref, onMounted, computed, nextTick} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
type Conversation = {
id: number;
subject: string;
participants: string;
updated_at: string;
};
type Message = {
id: number;
conversation_id: number;
from_addr: string;
subject: string;
body: string;
received_unix: number;
};
export default defineComponent({
name: 'MessengerApp',
components: {SvgIcon},
props: {
currentUserEmail: {type: String, default: ''},
initialConversationId: {type: Number, default: 0},
initialConversations: {type: String, default: '[]'},
initialActiveConversation: {type: String, default: ''},
initialMessages: {type: String, default: '[]'},
locale: {type: Object, default: () => ({})},
},
setup(props) {
const conversations = ref<Conversation[]>([]);
const activeConversation = ref<Conversation | null>(null);
const messages = ref<Message[]>([]);
const searchQuery = ref('');
const composeText = ref('');
const loading = ref(false);
const newConvModalOpen = ref(false);
const newConvParticipants = ref('');
const newConvSubject = ref('');
const newConvMessage = ref('');
// Deterministic avatar hue from string hash
const avatarHue = (s: string): number => {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) % 360;
}
return h;
};
const avatarStyle = (s: string) => ({
background: `hsl(${avatarHue(s)}, 65%, 50%)`,
});
const initials = (s: string): string => {
const emailPart = s.includes('@') ? s.split('@')[0] : s;
const parts = emailPart.split(/[\s,]/).filter(Boolean);
const result = parts.slice(0, 2).map(p => p[0]?.toUpperCase() || '').join('');
return result || '?';
};
const formatDate = (unix: number | string): string => {
const d = typeof unix === 'string' ? new Date(unix) : new Date(unix * 1000);
return d.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
};
// Fetch conversation list
const fetchConversations = async () => {
try {
const resp = await GET(`${appSubUrl}/api/v1/mail/conversations`);
if (resp.ok) {
conversations.value = await resp.json();
}
} catch {
// API may not be fully implemented yet; fall back to page data
}
};
// Load a conversation
const selectConversation = async (conv: Conversation) => {
activeConversation.value = conv;
loading.value = true;
try {
const resp = await GET(`${appSubUrl}/mail/${conv.id}/poll?since=0`);
if (resp.ok) {
const data = await resp.json();
messages.value = (data.messages || []).map((m: any) => ({
...m,
conversation_id: conv.id,
}));
}
} catch {
// ignore
}
loading.value = false;
await nextTick();
scrollToBottom();
};
const scrollToBottom = () => {
const el = document.querySelector('.messenger-messages');
if (el) el.scrollTop = el.scrollHeight;
};
// Send a message
const sendMessage = async () => {
if (!composeText.value.trim() || !activeConversation.value) return;
const conv = activeConversation.value;
const text = composeText.value;
composeText.value = '';
try {
await POST(`${appSubUrl}/mail/${conv.id}/send`, {
body: {body: text},
});
} catch {
// Optimistic append even if API fails
}
// Optimistic: append locally
messages.value.push({
id: Date.now(),
conversation_id: conv.id,
from_addr: props.currentUserEmail,
subject: '',
body: text,
received_unix: Math.floor(Date.now() / 1000),
});
await nextTick();
scrollToBottom();
};
// Filter pills
const activeFilter = ref('inbox');
const filters = [
{id: 'inbox', icon: 'octicon-inbox', label: props.locale.inbox || 'Inbox'},
{id: 'sent', icon: 'octicon-paper-airplane', label: props.locale.sent || 'Sent'},
{id: 'starred', icon: 'octicon-star', label: props.locale.starred || 'Starred'},
{id: 'archive', icon: 'octicon-archive', label: props.locale.archive || 'Archive'},
];
const setFilter = (id: string) => {
activeFilter.value = id;
};
// New conversation modal
const openNewConv = () => {
newConvModalOpen.value = true;
};
const closeNewConv = () => {
newConvModalOpen.value = false;
newConvParticipants.value = '';
newConvSubject.value = '';
newConvMessage.value = '';
};
const createConversation = async () => {
if (!newConvParticipants.value.trim()) return;
try {
const resp = await POST(`${appSubUrl}/api/v1/mail/conversations`, {
body: {
participants: newConvParticipants.value,
subject: newConvSubject.value || props.locale.newConversation || 'New conversation',
body: newConvMessage.value,
},
});
if (resp.ok) {
await fetchConversations();
closeNewConv();
}
} catch {
// ignore
}
};
// Auto-resize textarea
const onComposeInput = (e: Event) => {
const ta = e.target as HTMLTextAreaElement;
ta.style.height = 'auto';
ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`;
};
// Compose via Ctrl+Enter
const onComposeKeydown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
sendMessage();
}
};
// Poll for new messages every 5 seconds when a conversation is open
const startPolling = () => {
setInterval(async () => {
if (!activeConversation.value) return;
const lastId = messages.value.length > 0 ? messages.value[messages.value.length - 1].id : 0;
try {
const resp = await GET(`${appSubUrl}/mail/${activeConversation.value.id}/poll?since=${lastId}`);
if (resp.ok) {
const data = await resp.json();
if (data.messages && data.messages.length > 0) {
for (const m of data.messages) {
messages.value.push({...m, conversation_id: activeConversation.value!.id});
}
await nextTick();
scrollToBottom();
}
}
} catch {
// ignore
}
}, 5000);
};
onMounted(async () => {
// Use server-rendered initial data instead of fetching
try {
conversations.value = JSON.parse(props.initialConversations || '[]');
} catch {
conversations.value = [];
}
// If there's an active conversation passed from server, load its messages directly
if (props.initialConversationId > 0) {
const conv = conversations.value.find(c => c.id === props.initialConversationId);
if (conv) {
activeConversation.value = conv;
try {
messages.value = JSON.parse(props.initialMessages || '[]');
} catch {
messages.value = [];
}
await nextTick();
scrollToBottom();
}
}
// Also try to refresh from API after a moment (in case data changed)
setTimeout(() => fetchConversations(), 2000);
startPolling();
});
return {
conversations, activeConversation, messages, searchQuery, composeText,
loading, activeFilter, filters, newConvModalOpen, newConvParticipants,
newConvSubject, newConvMessage,
avatarStyle, initials, formatDate,
selectConversation, sendMessage, setFilter,
openNewConv, closeNewConv, createConversation,
onComposeInput, onComposeKeydown,
currentUserEmail: props.currentUserEmail,
locale: props.locale,
};
},
});
</script>
<template>
<div class="messenger-root">
<!-- Left panel: thread list -->
<aside class="messenger-sidebar">
<!-- Search row -->
<div class="messenger-search-row">
<div class="messenger-search-input">
<SvgIcon name="octicon-search" :size="14"/>
<input v-model="searchQuery" type="text" :placeholder="locale.searchPlaceholder || 'Search...'"/>
</div>
<button class="messenger-btn-add" @click="openNewConv" :title="locale.newConversation || 'New'">
<SvgIcon name="octicon-plus" :size="16"/>
</button>
</div>
<!-- Groups label -->
<div class="messenger-groups-row">
<span class="messenger-groups-label">
<SvgIcon name="octicon-organization" :size="12"/>
{{ locale.groups || 'Groups' }}
</span>
<span class="messenger-groups-add"><SvgIcon name="octicon-plus" :size="12"/></span>
</div>
<!-- Filter pills -->
<div class="messenger-pills">
<span
v-for="f in filters"
:key="f.id"
class="messenger-pill"
:class="{active: activeFilter === f.id}"
@click="setFilter(f.id)"
>
<SvgIcon :name="f.icon" :size="12"/>
{{ f.label }}
</span>
</div>
<!-- Thread list -->
<div class="messenger-thread-list">
<div
v-for="conv in conversations"
:key="conv.id"
class="messenger-thread-item"
:class="{active: activeConversation?.id === conv.id}"
@click="selectConversation(conv)"
>
<div class="messenger-avatar" :style="avatarStyle(conv.participants)">{{ initials(conv.participants) }}</div>
<div class="messenger-thread-body">
<div class="messenger-thread-top">
<span class="messenger-thread-title">{{ conv.subject }}</span>
<span class="messenger-thread-time">{{ formatDate(conv.updated_at) }}</span>
</div>
<div class="messenger-thread-sub">{{ conv.participants }}</div>
</div>
</div>
<div v-if="conversations.length === 0" class="messenger-empty-list">
{{ locale.empty || 'No conversations yet' }}
</div>
</div>
</aside>
<!-- Right panel: chat view -->
<main class="messenger-chat">
<!-- Empty state -->
<div v-if="!activeConversation" class="messenger-chat-empty">
<div>
<SvgIcon name="octicon-mail" :size="48"/>
<p>{{ locale.selectConversation || 'Select a conversation' }}</p>
</div>
</div>
<!-- Active conversation -->
<template v-else>
<!-- Chat header -->
<div class="messenger-chat-header">
<div class="messenger-chat-header-avatar">
<div class="messenger-avatar" :style="avatarStyle(activeConversation.participants)">{{ initials(activeConversation.participants) }}</div>
</div>
<div class="messenger-chat-header-info">
<div class="messenger-chat-header-title">{{ activeConversation.subject }}</div>
<div class="messenger-chat-header-sub">{{ activeConversation.participants }}</div>
</div>
<div class="messenger-chat-header-actions">
<button><SvgIcon name="octicon-star" :size="16"/></button>
<button><SvgIcon name="octicon-archive" :size="16"/></button>
<button><SvgIcon name="octicon-kebab-horizontal" :size="16"/></button>
</div>
</div>
<!-- Messages -->
<div class="messenger-messages">
<div
v-for="msg in messages"
:key="msg.id"
class="messenger-msg"
:class="{sent: msg.from_addr === currentUserEmail}"
>
<div class="messenger-msg-avatar messenger-avatar" :style="avatarStyle(msg.from_addr)">{{ initials(msg.from_addr) }}</div>
<div class="messenger-msg-content">
<div v-if="msg.from_addr !== currentUserEmail" class="messenger-msg-name">{{ msg.from_addr }}</div>
<div class="messenger-msg-bubble">{{ msg.body }}</div>
</div>
</div>
</div>
<!-- Compose area -->
<div class="messenger-compose">
<div class="messenger-compose-recipients">
<span>{{ locale.replyTo || 'Reply to' }}:</span>
<span class="messenger-compose-chip">{{ activeConversation.participants }}</span>
</div>
<div class="messenger-compose-input-row">
<textarea
v-model="composeText"
class="messenger-compose-textarea"
:placeholder="locale.composePlaceholder || 'Type a message...'"
rows="1"
@input="onComposeInput"
@keydown="onComposeKeydown"
></textarea>
<button class="messenger-compose-send" @click="sendMessage">
<SvgIcon name="octicon-paper-airplane" :size="16"/>
</button>
</div>
<div class="messenger-compose-hint">{{ locale.markdownSupported || 'Markdown is supported' }}</div>
</div>
</template>
</main>
<!-- New conversation modal -->
<div v-if="newConvModalOpen" class="messenger-new-conv-overlay" @click="closeNewConv">
<div class="messenger-new-conv-modal" @click.stop>
<h3>{{ locale.newConversation || 'New conversation' }}</h3>
<div class="messenger-new-conv-body">
<div>
<label>{{ locale.participants || 'Participants (email or username)' }}</label>
<input v-model="newConvParticipants" type="text" placeholder="alice@example.com, bob@example.com"/>
</div>
<div>
<label>{{ locale.subject || 'Subject' }}</label>
<input v-model="newConvSubject" type="text" :placeholder="locale.subjectPlaceholder || 'Conversation subject...'"/>
</div>
<div>
<label>{{ locale.message || 'Message' }}</label>
<textarea v-model="newConvMessage" rows="3" :placeholder="locale.composePlaceholder || 'Type a message...'"></textarea>
</div>
</div>
<div class="messenger-new-conv-actions">
<button class="ui button" @click="closeNewConv">{{ locale.cancel || 'Cancel' }}</button>
<button class="ui primary button" @click="createConversation">{{ locale.send || 'Send' }}</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Full-viewport fill via flex */
.messenger-root {
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
}
/* Left panel */
.messenger-sidebar {
width: 340px;
min-width: 340px;
display: flex;
flex-direction: column;
background: var(--color-menu);
border-right: 0.5px solid var(--color-secondary);
}
.messenger-search-row {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 12px;
}
.messenger-search-input {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
background: var(--color-box-body);
border: 0.5px solid var(--color-secondary);
border-radius: var(--border-radius);
padding: 5px 10px;
}
.messenger-search-input input {
flex: 1;
border: none;
background: transparent;
color: var(--color-text);
font-size: 13px;
outline: none;
}
.messenger-btn-add {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border: 0.5px solid var(--color-secondary);
border-radius: var(--border-radius);
background: var(--color-box-body);
color: var(--color-text);
cursor: pointer;
flex-shrink: 0;
}
.messenger-btn-add:hover { background: var(--color-hover); }
.messenger-groups-row {
display: flex; align-items: center; justify-content: space-between;
padding: 0 12px 6px;
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--color-text-light);
}
.messenger-groups-label { display: flex; align-items: center; gap: 4px; }
.messenger-groups-add { cursor: pointer; opacity: 0.5; }
.messenger-pills {
display: flex; gap: 6px; padding: 0 12px 8px; overflow-x: auto; scrollbar-width: none;
}
.messenger-pills::-webkit-scrollbar { display: none; }
.messenger-pill {
display: flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: var(--border-radius-full);
font-size: 12px; white-space: nowrap;
border: 0.5px solid var(--color-secondary);
background: var(--color-box-body); color: var(--color-text-light);
cursor: pointer;
}
.messenger-pill.active {
background: var(--color-primary-alpha-20);
border-color: var(--color-primary-alpha-40);
color: var(--color-primary); font-weight: 600;
}
.messenger-thread-list { flex: 1; overflow-y: auto; min-height: 0; }
.messenger-thread-item {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 12px; cursor: pointer; border-bottom: 0.5px solid var(--color-secondary);
}
.messenger-thread-item:hover { background: var(--color-hover); }
.messenger-thread-item.active { background: var(--color-active); }
.messenger-thread-body { flex: 1; min-width: 0; }
.messenger-thread-top { display: flex; justify-content: space-between; align-items: baseline; }
.messenger-thread-title { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messenger-thread-time { font-size: 11px; color: var(--color-text-light); margin-left: 8px; flex-shrink: 0; }
.messenger-thread-sub { font-size: 12px; color: var(--color-text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
.messenger-empty-list { padding: 24px; text-align: center; opacity: 0.5; font-size: 13px; }
/* Avatars */
.messenger-avatar {
width: 36px; height: 36px; border-radius: var(--border-radius-full);
display: flex; align-items: center; justify-content: center;
font-size: 13px; font-weight: 600; color: #fff; flex-shrink: 0;
}
/* Right panel */
.messenger-chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--color-body); }
.messenger-chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-text-light); text-align: center; }
.messenger-chat-empty p { margin-top: 8px; }
.messenger-chat-header {
display: flex; align-items: center; gap: 12px;
padding: 10px 16px; border-bottom: 0.5px solid var(--color-secondary);
}
.messenger-chat-header-avatar .messenger-avatar { width: 32px; height: 32px; }
.messenger-chat-header-info { flex: 1; min-width: 0; }
.messenger-chat-header-title { font-size: 15px; font-weight: 600; }
.messenger-chat-header-sub { font-size: 12px; color: var(--color-text-light); }
.messenger-chat-header-actions { display: flex; gap: 4px; }
.messenger-chat-header-actions button {
background: none; border: none; color: var(--color-text-light);
cursor: pointer; padding: 6px; border-radius: var(--border-radius);
}
.messenger-chat-header-actions button:hover { background: var(--color-hover); }
/* Messages */
.messenger-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
.messenger-msg { display: flex; gap: 8px; max-width: 75%; }
.messenger-msg.sent { align-self: flex-end; flex-direction: row-reverse; }
.messenger-msg-avatar { width: 28px; height: 28px; font-size: 11px; }
.messenger-msg-content { min-width: 0; }
.messenger-msg-name { font-size: 11px; color: var(--color-text-light); margin-bottom: 2px; }
.messenger-msg.sent .messenger-msg-name { display: none; }
.messenger-msg-bubble {
padding: 8px 12px; border-radius: var(--border-radius-medium);
font-size: 13px; line-height: 1.45; word-wrap: break-word;
background: var(--color-box-body); border: 0.5px solid var(--color-secondary);
}
.messenger-msg.sent .messenger-msg-bubble {
background: var(--color-primary-alpha-20); border-color: var(--color-primary-alpha-30);
}
/* Compose */
.messenger-compose { border-top: 0.5px solid var(--color-secondary); padding: 8px 16px; background: var(--color-menu); }
.messenger-compose-recipients { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: var(--color-text-light); margin-bottom: 6px; }
.messenger-compose-chip { background: var(--color-secondary); border-radius: var(--border-radius-full); padding: 2px 8px; font-size: 11px; }
.messenger-compose-input-row { display: flex; gap: 8px; align-items: flex-end; }
.messenger-compose-textarea {
flex: 1; resize: none; min-height: 36px; max-height: 120px;
padding: 8px 12px; border: 0.5px solid var(--color-secondary);
border-radius: var(--border-radius-medium); font-size: 13px; line-height: 1.4;
background: var(--color-box-body); color: var(--color-text); outline: none;
}
.messenger-compose-textarea:focus { border-color: var(--color-primary); }
.messenger-compose-send {
flex-shrink: 0; padding: 8px 16px; background: var(--color-primary);
color: var(--color-primary-contrast); border: none;
border-radius: var(--border-radius-medium); font-size: 13px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.messenger-compose-send:hover { background: var(--color-primary-hover); }
.messenger-compose-hint { font-size: 11px; opacity: 0.5; margin-top: 4px; }
/* New conversation modal */
.messenger-new-conv-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; display: flex; align-items: center; justify-content: center; }
.messenger-new-conv-modal { background: var(--color-box-body); border: 0.5px solid var(--color-secondary); border-radius: var(--border-radius-medium); width: 520px; max-width: 90vw; }
.messenger-new-conv-modal h3 { padding: 16px; margin: 0; border-bottom: 0.5px solid var(--color-secondary); }
.messenger-new-conv-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.messenger-new-conv-body label { font-size: 12px; color: var(--color-text-light); margin-bottom: 4px; display: block; }
.messenger-new-conv-body input, .messenger-new-conv-body textarea {
width: 100%; padding: 8px 10px; border: 0.5px solid var(--color-secondary);
border-radius: var(--border-radius); background: var(--color-body); color: var(--color-text); font-size: 13px; outline: none;
}
.messenger-new-conv-body input:focus, .messenger-new-conv-body textarea:focus { border-color: var(--color-primary); }
.messenger-new-conv-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 0 16px 16px; }
</style>
+66
View File
@@ -0,0 +1,66 @@
// M8SH Messenger: mounts the Vue-based messenger SPA.
import {createApp} from 'vue';
export function initMessenger() {
const el = document.querySelector('#messenger-app');
if (!el) return;
const mount = async () => {
try {
const {default: MessengerApp} = await import('../components/MessengerApp.vue');
const app = createApp(MessengerApp, {
currentUserEmail: el.getAttribute('data-current-user-email') || '',
initialConversationId: Number(el.getAttribute('data-initial-conversation-id') || '0'),
initialConversations: el.getAttribute('data-conversations') || '[]',
initialActiveConversation: el.getAttribute('data-active-conversation') || '',
initialMessages: el.getAttribute('data-messages') || '[]',
locale: {
searchPlaceholder: el.getAttribute('data-locale-search') || 'Search or start a new conversation...',
composePlaceholder: el.getAttribute('data-locale-compose') || 'Type a message...',
markdownSupported: el.getAttribute('data-locale-markdown') || 'Markdown is supported',
selectConversation: el.getAttribute('data-locale-select') || 'Select a conversation',
empty: el.getAttribute('data-locale-empty') || 'No conversations yet',
newConversation: el.getAttribute('data-locale-new-conv') || 'New conversation',
inbox: el.getAttribute('data-locale-inbox') || 'Inbox',
sent: el.getAttribute('data-locale-sent') || 'Sent',
starred: el.getAttribute('data-locale-starred') || 'Starred',
archive: el.getAttribute('data-locale-archive') || 'Archive',
groups: el.getAttribute('data-locale-groups') || 'Groups',
replyTo: el.getAttribute('data-locale-reply-to') || 'Reply to',
participants: el.getAttribute('data-locale-participants') || 'Participants (email or username)',
subject: el.getAttribute('data-locale-subject') || 'Subject',
message: el.getAttribute('data-locale-message') || 'Message',
cancel: el.getAttribute('data-locale-cancel') || 'Cancel',
send: el.getAttribute('data-locale-send') || 'Send',
},
});
app.mount(el);
} catch (err) {
console.error('MessengerApp failed to load', err);
el.textContent = 'Failed to load messenger';
}
};
mount();
// Browser notifications: request permission on first interaction
if ('Notification' in window && Notification.permission === 'default') {
document.addEventListener('click', function requestOnce() {
Notification.requestPermission();
document.removeEventListener('click', requestOnce);
}, {once: true});
}
}
export function showBrowserNotification(title: string, body: string) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
try {
const {appSubUrl} = window.config;
new Notification(title, {
body,
icon: `${appSubUrl}/assets/img/favicon.png`,
tag: 'gitea-notification',
});
} catch {
// ignore
}
}
+6
View File
@@ -1,4 +1,5 @@
import {GET} from '../modules/fetch.ts';
import {showBrowserNotification} from './messenger.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
@@ -109,12 +110,17 @@ async function updateNotificationCount(): Promise<number> {
const data = await response.json();
const prevCount = getCurrentCount();
toggleElem('.notification_count', data.new !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = `${data.new}`;
}
if (data.new > prevCount) {
showBrowserNotification('New notification', `You have ${data.new} unread notification${data.new !== 1 ? 's' : ''}`);
}
return data.new as number;
} catch (error) {
console.error(error);
+2 -2
View File
@@ -1,5 +1,5 @@
import { makeNonce, buildSignCommand, validateSignature } from './gpg-nonce.ts';
import { copyToClipboard } from './../modules/clipboard.ts'
import {makeNonce, buildSignCommand, validateSignature} from './gpg-nonce.ts';
import {copyToClipboard} from '../modules/clipboard.ts';
export function initGpgSignin() {
const tokenField = document.querySelector<HTMLInputElement>('#token-field');
+2 -2
View File
@@ -1,5 +1,5 @@
import { makeNonce, buildSignCommand, validateSignature } from './gpg-nonce.ts';
import { copyToClipboard } from './../modules/clipboard.ts'
import {makeNonce, buildSignCommand, validateSignature} from './gpg-nonce.ts';
import {copyToClipboard} from '../modules/clipboard.ts';
export function initGpgSignup() {
const btnProceed = document.querySelector('#btn-proceed');
+2
View File
@@ -14,6 +14,7 @@ import {initAdminUserListSearchForm} from './features/admin/users.ts';
import {initAdminConfigs} from './features/admin/config.ts';
import {initMarkupAnchors} from './markup/anchors.ts';
import {initNotificationCount} from './features/notification.ts';
import {initMessenger} from './features/messenger.ts';
import {initRepoIssueContentHistory} from './features/repo-issue-content.ts';
import {initStopwatch} from './features/stopwatch.ts';
import {initRepoFileSearch} from './features/repo-findfile.ts';
@@ -119,6 +120,7 @@ const initPerformanceTracer = callInitFunctions([
initDashboardRepoList,
initNotificationCount,
initMessenger,
initOrgTeam,