diff --git a/cmd/web.go b/cmd/web.go index 5644f37d23..6acffe2e0c 100644 --- a/cmd/web.go +++ b/cmd/web.go @@ -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 { diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index fffd6eeb72..c3911aac75 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -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@, 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@. +HEALTHCHECK_INTERVAL = 5 + [service] ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/m8sh-mail.md b/docs/m8sh-mail.md new file mode 100644 index 0000000000..d1281d88fd --- /dev/null +++ b/docs/m8sh-mail.md @@ -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.` | logged + shown in self-check | +| DMARC | `_dmarc.` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@` | +| MX | `` | `10 .` | +| PTR | (reverse of your IP) | `` | + +## 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:///.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@`. +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@` (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, `` 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. diff --git a/models/mail/attachment.go b/models/mail/attachment.go new file mode 100644 index 0000000000..2b99e8deca --- /dev/null +++ b/models/mail/attachment.go @@ -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) +} diff --git a/models/mail/conversation.go b/models/mail/conversation.go new file mode 100644 index 0000000000..d3d6ce0ba1 --- /dev/null +++ b/models/mail/conversation.go @@ -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 +} diff --git a/models/mail/health.go b/models/mail/health.go new file mode 100644 index 0000000000..6a0381a068 --- /dev/null +++ b/models/mail/health.go @@ -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 +} diff --git a/models/mail/message.go b/models/mail/message.go new file mode 100644 index 0000000000..8338bf08ac --- /dev/null +++ b/models/mail/message.go @@ -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() +} diff --git a/models/migrations/m8sh/migrations.go b/models/migrations/m8sh/migrations.go new file mode 100644 index 0000000000..f8ff3a4a39 --- /dev/null +++ b/models/migrations/m8sh/migrations.go @@ -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 +} diff --git a/models/migrations/m8sh/migrations_test.go b/models/migrations/m8sh/migrations_test.go new file mode 100644 index 0000000000..1255db0fa6 --- /dev/null +++ b/models/migrations/m8sh/migrations_test.go @@ -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 +} diff --git a/models/migrations/m8sh/v0001_mail.go b/models/migrations/m8sh/v0001_mail.go new file mode 100644 index 0000000000..e0f08882eb --- /dev/null +++ b/models/migrations/m8sh/v0001_mail.go @@ -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 + }, +} diff --git a/models/user/user.go b/models/user/user.go index 4e6227de9e..9396dcb933 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -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. diff --git a/modules/setting/mail_server.go b/modules/setting/mail_server.go new file mode 100644 index 0000000000..6bb035eadc --- /dev/null +++ b/modules/setting/mail_server.go @@ -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 +} diff --git a/modules/setting/mailer.go b/modules/setting/mailer.go index 0fa259225a..2cc3ce4afa 100644 --- a/modules/setting/mailer.go +++ b/modules/setting/mailer.go @@ -64,6 +64,7 @@ func loadMailsFrom(rootCfg ConfigProvider) { loadRegisterMailFrom(rootCfg) loadNotifyMailFrom(rootCfg) loadIncomingEmailFrom(rootCfg) + loadMailServerFrom(rootCfg) } func loadMailerFrom(rootCfg ConfigProvider) { diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 662b89998e..e581f39224 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -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 +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 81c939fb4c..761960ff8c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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 m8sh, use personal access token." + "settings.oauth_applications_disabled": "OAuth applications are disabled in m8sh, 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" } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 5b126e551a..fd9188734e 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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). diff --git a/routers/api/v1/mail.go b/routers/api/v1/mail.go new file mode 100644 index 0000000000..3701981dba --- /dev/null +++ b/routers/api/v1/mail.go @@ -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() +} diff --git a/routers/common/db.go b/routers/common/db.go index 30ab0fcd71..7c8534be24 100644 --- a/routers/common/db.go +++ b/routers/common/db.go @@ -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) } diff --git a/routers/common/pagetmpl.go b/routers/common/pagetmpl.go index 09c7a04bde..7db9ce8ded 100644 --- a/routers/common/pagetmpl.go +++ b/routers/common/pagetmpl.go @@ -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 } diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index d4e3015aac..147db1bcd9 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -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) } diff --git a/routers/web/m8sh_wellknown.go b/routers/web/m8sh_wellknown.go new file mode 100644 index 0000000000..4d3e16bab1 --- /dev/null +++ b/routers/web/m8sh_wellknown.go @@ -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", + }) +} diff --git a/routers/web/mail.go b/routers/web/mail.go new file mode 100644 index 0000000000..f71d1aae47 --- /dev/null +++ b/routers/web/mail.go @@ -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 +} diff --git a/routers/web/web.go b/routers/web/web.go index ca350b5415..c83884faff 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/services/mail/dkim.go b/services/mail/dkim.go new file mode 100644 index 0000000000..428e1468d6 --- /dev/null +++ b/services/mail/dkim.go @@ -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 +// ._domainkey. 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()) +} diff --git a/services/mail/dkim_test.go b/services/mail/dkim_test.go new file mode 100644 index 0000000000..815d12cf3f --- /dev/null +++ b/services/mail/dkim_test.go @@ -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.; 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", "", "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) +} diff --git a/services/mail/dns.go b/services/mail/dns.go new file mode 100644 index 0000000000..06514795fa --- /dev/null +++ b/services/mail/dns.go @@ -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. 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. 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: 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: 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= 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) +} diff --git a/services/mail/dns_test.go b/services/mail/dns_test.go new file mode 100644 index 0000000000..6e506d0a25 --- /dev/null +++ b/services/mail/dns_test.go @@ -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(" ")) +} diff --git a/services/mail/health.go b/services/mail/health.go new file mode 100644 index 0000000000..8af2020bcd --- /dev/null +++ b/services/mail/health.go @@ -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@ 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) + } +} diff --git a/services/mail/receive.go b/services/mail/receive.go new file mode 100644 index 0000000000..461b7a4544 --- /dev/null +++ b/services/mail/receive.go @@ -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 " 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) +} diff --git a/services/mail/receive_test.go b/services/mail/receive_test.go new file mode 100644 index 0000000000..f5ffe6a5c4 --- /dev/null +++ b/services/mail/receive_test.go @@ -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 " header and the bare-addr fallback. +func TestParseAddressHeader(t *testing.T) { + assert.Equal(t, "a@b.com", parseAddressHeader("Alice ")) + 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")) +} diff --git a/services/mail/send.go b/services/mail/send.go new file mode 100644 index 0000000000..e2194fd8da --- /dev/null +++ b/services/mail/send.go @@ -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@) + 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("", 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:///.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() +} diff --git a/services/mail/send_tls.go b/services/mail/send_tls.go new file mode 100644 index 0000000000..8348b80e77 --- /dev/null +++ b/services/mail/send_tls.go @@ -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, + } +} diff --git a/services/mail/server.go b/services/mail/server.go new file mode 100644 index 0000000000..e4629d4afb --- /dev/null +++ b/services/mail/server.go @@ -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:
\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:
\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 .\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 out of a "MAIL FROM:" or "RCPT TO:" 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] +} diff --git a/services/mail/server_test.go b/services/mail/server_test.go new file mode 100644 index 0000000000..1a0a2cce9b --- /dev/null +++ b/services/mail/server_test.go @@ -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:") + assert.Equal(t, "MAIL", cmd) + assert.Equal(t, "FROM:", 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 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:")) + assert.Equal(t, "x@y.com", extractAddr("RCPT TO:")) + assert.Empty(t, extractAddr("MAIL FROM:a@b.com")) // no angle brackets + assert.Empty(t, extractAddr("")) // empty + assert.Empty(t, extractAddr("MAIL FROM:. + 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() diff --git a/services/mailer/sender/m8sh.go b/services/mailer/sender/m8sh.go new file mode 100644 index 0000000000..5e5bfe5ade --- /dev/null +++ b/services/mailer/sender/m8sh.go @@ -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) } diff --git a/templates/admin/self_check.tmpl b/templates/admin/self_check.tmpl index 31d6a5d516..57e2a90bd5 100644 --- a/templates/admin/self_check.tmpl +++ b/templates/admin/self_check.tmpl @@ -51,6 +51,34 @@
{{ctx.Locale.Tr "admin.config.cache_test_slow" .CacheSlow}}
{{end}} + {{/* M8SH: Mail DNS status */}} + {{if .MailDNSReport}} +
+

M8SH Mail DNS Status

+ + + + + + + + + + + + + + +
SPF (TXT @){{if .MailDNSReport.SPFOk}}✅{{else}}❌{{end}}{{.MailDNSReport.SPFSuggested}}
DKIM (TXT {{.MailDNSReport.DKIMName}}){{if .MailDNSReport.DKIMMatches}}✅{{else if .MailDNSReport.DKIMOk}}⚠️ key mismatch{{else}}❌{{end}}{{.MailDNSReport.DKIMExpectedValue}}
DMARC (TXT _dmarc.{{.Domain}}){{if .MailDNSReport.DMARCOk}}✅{{else}}❌{{end}}{{.MailDNSReport.DMARCSuggested}}
MX{{if .MailDNSReport.MXOk}}✅{{else}}❌{{end}}10 {{.Domain}}.
PTR{{if .MailDNSReport.PTROk}}✅{{else}}❌{{end}}{{.Domain}}
+ {{if .MailHealth}} +
+ Last healthcheck: SMTP {{if .MailHealth.SMTPOk}}OK{{else}}FAILED{{end}} + {{if .MailHealth.ErrorMsg}}— {{.MailHealth.ErrorMsg}}{{end}} +
+ {{end}} +
+ {{end}} + {{/* only shown when there is no visible "self-check-problem" */}}
{{ctx.Locale.Tr "admin.self_check.no_problem_found"}} diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl index bf79d44021..6496fb585c 100644 --- a/templates/base/head_navbar.tmpl +++ b/templates/base/head_navbar.tmpl @@ -15,6 +15,11 @@ {{if and .IsSigned .MustChangePassword}} {{/* No links */}} {{else if .IsSigned}} + {{$mailData := .PageGlobalData}}{{$mailUnread := 0}}{{if $mailData}}{{$mailUnread = call $mailData.GetMailUnreadCount}}{{end}} + + {{ctx.Locale.Tr "mail.navbar"}}{{if $mailUnread}} + {{$mailUnread}}{{end}} + {{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}} {{ctx.Locale.Tr "issues"}} {{end}} diff --git a/templates/messenger/conversation.tmpl b/templates/messenger/conversation.tmpl new file mode 100644 index 0000000000..e08a1ff4c0 --- /dev/null +++ b/templates/messenger/conversation.tmpl @@ -0,0 +1,26 @@ +{{template "base/head" .}} +
+{{template "base/footer" .}} diff --git a/templates/messenger/index.tmpl b/templates/messenger/index.tmpl new file mode 100644 index 0000000000..9a63465c08 --- /dev/null +++ b/templates/messenger/index.tmpl @@ -0,0 +1,24 @@ +{{template "base/head" .}} +
+{{template "base/footer" .}} diff --git a/web_src/css/features/messenger.css b/web_src/css/features/messenger.css new file mode 100644 index 0000000000..3afe55c585 --- /dev/null +++ b/web_src/css/features/messenger.css @@ -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; +} diff --git a/web_src/css/index.css b/web_src/css/index.css index 71e58e7c8e..52b164144f 100644 --- a/web_src/css/index.css +++ b/web_src/css/index.css @@ -86,5 +86,6 @@ @import "./actions.css"; @import "./helpers.css"; +@import "./features/messenger.css"; @tailwind utilities; diff --git a/web_src/js/components/MessengerApp.vue b/web_src/js/components/MessengerApp.vue new file mode 100644 index 0000000000..1307b1accd --- /dev/null +++ b/web_src/js/components/MessengerApp.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/web_src/js/features/messenger.ts b/web_src/js/features/messenger.ts new file mode 100644 index 0000000000..b93b79e849 --- /dev/null +++ b/web_src/js/features/messenger.ts @@ -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 + } +} diff --git a/web_src/js/features/notification.ts b/web_src/js/features/notification.ts index ba0ca5203c..88c6b0e92f 100644 --- a/web_src/js/features/notification.ts +++ b/web_src/js/features/notification.ts @@ -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 { 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); diff --git a/web_src/js/features/user-auth-gpg-signin.ts b/web_src/js/features/user-auth-gpg-signin.ts index cc96f90679..59f6ab80d1 100644 --- a/web_src/js/features/user-auth-gpg-signin.ts +++ b/web_src/js/features/user-auth-gpg-signin.ts @@ -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('#token-field'); diff --git a/web_src/js/features/user-auth-gpg-signup.ts b/web_src/js/features/user-auth-gpg-signup.ts index c18f2e2fab..0282983920 100644 --- a/web_src/js/features/user-auth-gpg-signup.ts +++ b/web_src/js/features/user-auth-gpg-signup.ts @@ -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'); diff --git a/web_src/js/index.ts b/web_src/js/index.ts index d0e1368c09..0ed2ad738e 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -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,