Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
722b7b9a73
|
-11
@@ -26,7 +26,6 @@ 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"
|
||||
@@ -216,16 +215,6 @@ 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 {
|
||||
|
||||
@@ -770,26 +770,6 @@ LEVEL = Info
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; M8SH Mail Server (built-in SMTP on port 25)
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; M8SH ships its own mail transport so it can both send and receive email
|
||||
;; on the main Gitea domain. When ENABLED, the Gitea system mailer (account
|
||||
;; activation, password reset, notifications) is automatically routed through
|
||||
;; this built-in server with From = noreply@<DOMAIN>, and the [mailer] section
|
||||
;; is ignored. Domain is always [server].DOMAIN and TLS always comes from the
|
||||
;; Gitea HTTPS listener (ACME or CERT_FILE/KEY_FILE) - nothing to configure here.
|
||||
|
||||
[mail_server]
|
||||
ENABLED = false
|
||||
; DKIM private key (base64-encoded PEM). If empty, a 2048-bit RSA key is
|
||||
; generated on first start and written back here. The matching DNS TXT record
|
||||
; to publish is logged and shown in /-/admin/self_check.
|
||||
DKIM_PRIVATE_KEY =
|
||||
; Healthcheck interval in minutes (0 = run once at startup only). Probes send a
|
||||
; real loopback message through the public MX path to healthcheck@<DOMAIN>.
|
||||
HEALTHCHECK_INTERVAL = 5
|
||||
|
||||
[service]
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# M8SH Mail Integration
|
||||
|
||||
A built-in email server that turns M8SH (Gitea fork) into a mail-capable
|
||||
messenger using SMTP port 25 as the only transport. Minimal inroads into
|
||||
upstream code; everything M8SH-specific lives in dedicated packages.
|
||||
|
||||
## Enable
|
||||
|
||||
In `app.ini`:
|
||||
|
||||
```ini
|
||||
[mail_server]
|
||||
ENABLED = true
|
||||
DKIM_PRIVATE_KEY = ; auto-generated on first start, written back here
|
||||
HEALTHCHECK_INTERVAL = 5 ; minutes, 0 = startup-only
|
||||
```
|
||||
|
||||
Domain is always `[server].DOMAIN`. TLS is always reused from the Gitea HTTPS
|
||||
listener (ACME or `CERT_FILE`/`KEY_FILE`) — nothing to configure for mail.
|
||||
|
||||
## DNS records to publish
|
||||
|
||||
Shown in `/-/admin/self_check` ("M8SH Mail DNS Status") and logged at startup:
|
||||
|
||||
| Record | Name | Value |
|
||||
|--------|------|-------|
|
||||
| SPF | `@` | `v=spf1 mx a -all` |
|
||||
| DKIM | `default._domainkey.<domain>` | logged + shown in self-check |
|
||||
| DMARC | `_dmarc.<domain>` | `v=DMARC1; p=quarantine; rua=mailto:postmaster@<domain>` |
|
||||
| MX | `<domain>` | `10 <domain>.` |
|
||||
| PTR | (reverse of your IP) | `<domain>` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
models/mail/ xorm models + queries
|
||||
message.go conversation.go attachment.go health.go
|
||||
models/migrations/m8sh/ independent migration runner + m8sh_version table
|
||||
migrations.go v0001_mail.go
|
||||
services/mail/ SMTP server (port 25), send, receive, dkim, dns, health, tls
|
||||
server.go receive.go send.go send_tls.go dkim.go dns.go health.go start.go tls.go
|
||||
modules/setting/mail_server.go [mail_server] config parsing
|
||||
services/mailer/sender/m8sh.go gitea system-mailer shim → built-in server
|
||||
routers/web/mail.go /mail conversation UI
|
||||
routers/web/m8sh_wellknown.go /.well-known/m8sh advertisement
|
||||
routers/api/v1/mail.go POST /api/v1/mail/deliver (M8SH-to-M8SH)
|
||||
templates/messenger/*.tmpl index/conversation/_message
|
||||
```
|
||||
|
||||
Inroads into upstream (kept minimal):
|
||||
|
||||
- `cmd/web.go` — `mailservice.Start()` after storage/DB init.
|
||||
- `routers/common/db.go` — runs `m8sh` migration runner after upstream migrations.
|
||||
- `routers/web/web.go` — `/mail/*` routes + `.well-known/m8sh`.
|
||||
- `routers/api/v1/api.go` — `/mail/deliver` route.
|
||||
- `routers/web/admin/admin.go` + `templates/admin/self_check.tmpl` — Mail DNS status block.
|
||||
- `services/mailer/mailer.go` — picks `M8SHSender` when `[mail_server] ENABLED`.
|
||||
- `models/user/user.go` — bans reserved mail local-parts.
|
||||
- `templates/base/head_navbar.tmpl` — mail icon with unread badge.
|
||||
- `modules/setting/mailer.go` — loads `[mail_server]`.
|
||||
|
||||
## Transports
|
||||
|
||||
- **SMTP (port 25)**: inbound listener with STARTTLS; outbound via MX lookup.
|
||||
- **M8SH HTTP**: before SMTP, the sender probes
|
||||
`https://<recipient-domain>/.well-known/m8sh`; if advertised, it POSTs to
|
||||
`/api/v1/mail/deliver` instead. `m8sh_message.transport` records which path
|
||||
was used (0=smtp, 1=m8sh http).
|
||||
|
||||
## System mail
|
||||
|
||||
When enabled, Gitea's own mailer (account activation, password reset,
|
||||
notifications) routes through the built-in server with `From: noreply@<domain>`.
|
||||
The `[mailer]` section is ignored in that case.
|
||||
|
||||
## Healthcheck
|
||||
|
||||
Every `HEALTHCHECK_INTERVAL` minutes: runs the DNS check and sends a real
|
||||
loopback message through the public MX to `healthcheck@<domain>` (marked with
|
||||
`X-M8SH-Healthcheck`). The server detects that header, logs OK, and does not
|
||||
store the message. Results land in `m8sh_health` and the admin self-check page.
|
||||
|
||||
## Reserved usernames
|
||||
|
||||
`noreply`, `postmaster`, `healthcheck`, `abuse`, `mailer` are banned as
|
||||
regular usernames so they can serve as mail role accounts.
|
||||
|
||||
|
||||
## Test coverage
|
||||
|
||||
Critical, pure-testable logic is covered by unit tests (run with the project's
|
||||
matching Go toolchain):
|
||||
|
||||
```
|
||||
go test ./services/mail/ ./models/migrations/m8sh/
|
||||
```
|
||||
|
||||
| Package | File | What is covered |
|
||||
|---------|------|-----------------|
|
||||
| `services/mail` | `dkim_test.go` | RSA key → DNS record format, relaxed/relaxed signing output, body + header canonicalization, header folding round-trip |
|
||||
| `services/mail` | `receive_test.go` | Content-Transfer-Encoding decoding (plain/base64/quoted-printable), participant-key canonicalization (dedup/sort/lowercase/order-independence), address-header extraction, RFC 2047 fallback |
|
||||
| `services/mail` | `server_test.go` | SMTP command splitting, `<addr>` extraction (incl. malformed), session reset |
|
||||
| `services/mail` | `dns_test.go` | DKIM public-key base64 extraction, whitespace stripping |
|
||||
| `models/migrations/m8sh` | `migrations_test.go` | Fresh-install version recording, idempotency (double-run no-op), expected-version invariant (in-memory SQLite) |
|
||||
|
||||
### What is not yet covered (and why)
|
||||
|
||||
- **End-to-end SMTP send/receive** (`server.go` + `send.go` dial/deliver):
|
||||
requires a live network listener and DNS; better suited to integration tests.
|
||||
- **MIME multipart body extraction** (`parseMultipart`): the helper calls
|
||||
`storage.Attachments.Save`, so it needs the storage subsystem initialized —
|
||||
a DB-backed integration test (with `unittest.PrepareTestDatabase`) would
|
||||
cover it. The pure decoders it depends on are unit-tested.
|
||||
- **Outbound M8SH HTTP fast-path** (`tryM8SHHTTP`): needs an HTTP test
|
||||
server stub; covered indirectly by the fallback logic being exercised.
|
||||
- **DB-backed model CRUD** (`models/mail/*`): follows the project's
|
||||
`unittest` fixture convention; can be added once a fixture set for the new
|
||||
tables is introduced.
|
||||
|
||||
These gaps are integration-level and were intentionally deferred per the
|
||||
project guideline to prefer unit tests for logic testable in isolation.
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
+4
-12
@@ -606,11 +606,10 @@ 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
|
||||
"noreply", // email noreply
|
||||
"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
|
||||
|
||||
"explore",
|
||||
"issues",
|
||||
@@ -628,13 +627,6 @@ 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.
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -64,7 +64,6 @@ func loadMailsFrom(rootCfg ConfigProvider) {
|
||||
loadRegisterMailFrom(rootCfg)
|
||||
loadNotifyMailFrom(rootCfg)
|
||||
loadIncomingEmailFrom(rootCfg)
|
||||
loadMailServerFrom(rootCfg)
|
||||
}
|
||||
|
||||
func loadMailerFrom(rootCfg ConfigProvider) {
|
||||
|
||||
@@ -139,10 +139,6 @@ func newFuncMapWebPage() template.FuncMap {
|
||||
|
||||
"FilenameIsImage": filenameIsImage,
|
||||
"TabSizeClass": tabSizeClass,
|
||||
|
||||
// M8SH messenger helpers
|
||||
"messengerAvatarHue": messengerAvatarHue,
|
||||
"messengerInitials": messengerInitials,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,69 +287,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -3910,21 +3910,5 @@
|
||||
"actions.general.cross_repo_selected": "Selected repositories",
|
||||
"actions.general.cross_repo_target_repos": "Target Repositories",
|
||||
"actions.general.cross_repo_add": "Add Target Repository",
|
||||
"settings.oauth_applications_disabled": "OAuth applications are disabled in <code>m8sh</code>, use personal access token.",
|
||||
"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"
|
||||
"settings.oauth_applications_disabled": "OAuth applications are disabled in <code>m8sh</code>, use personal access token."
|
||||
}
|
||||
|
||||
@@ -1046,14 +1046,9 @@ 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.
|
||||
// M8SH: HTTP delivery endpoint for M8SH-to-M8SH mail (bypasses SMTP).
|
||||
m.Group("/mail", func() {
|
||||
m.Post("/deliver", MailDeliver)
|
||||
})
|
||||
|
||||
// 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.
|
||||
m.Group("/notifications", func() {
|
||||
m.Combo("").
|
||||
Get(reqToken(), notify.ListNotifications).
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
+3
-18
@@ -10,7 +10,6 @@ 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"
|
||||
@@ -41,33 +40,19 @@ 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 {
|
||||
if err := versioned_migration.Migrate(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateM8SHWithSetting(ctx, x)
|
||||
return versioned_migration.Migrate(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
|
||||
if err := versioned_migration.Migrate(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateM8SHWithSetting(ctx, x)
|
||||
return versioned_migration.Migrate(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)
|
||||
}
|
||||
// Upstream is up-to-date; still run M8SH migrations (no-op if current).
|
||||
return migrateM8SHWithSetting(ctx, x)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
)
|
||||
@@ -66,23 +65,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -91,7 +78,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -28,7 +27,6 @@ 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"
|
||||
)
|
||||
@@ -235,17 +233,6 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
+7
-148
@@ -1,162 +1,21 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-License-Identifier: GPL
|
||||
|
||||
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"
|
||||
)
|
||||
const tplMail templates.TplName = "mail"
|
||||
|
||||
// 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")
|
||||
// Mail renders the M8SH Mail frontend page. The page itself is a thin shell;
|
||||
// all UI and mock state live client-side in the Vue MailApp component.
|
||||
func Mail(ctx *context.Context) {
|
||||
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
|
||||
ctx.HTML(http.StatusOK, tplMail)
|
||||
}
|
||||
|
||||
+2
-14
@@ -508,7 +508,6 @@ 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())
|
||||
|
||||
@@ -543,19 +542,8 @@ 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)
|
||||
// M8SH Mail: messenger-style mail UI, mock-only frontend, requires sign-in.
|
||||
m.Get("/mail", reqSignIn, Mail)
|
||||
|
||||
// ***** 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=..."
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// DKIMSelector is the fixed DKIM selector M8SH uses.
|
||||
const DKIMSelector = "default"
|
||||
|
||||
// DKIMHeader is the DKIM-Signature header name.
|
||||
const DKIMHeader = "DKIM-Signature"
|
||||
|
||||
// defaultHeadersToSign are the headers covered by the DKIM signature.
|
||||
var defaultHeadersToSign = []string{"From", "To", "Subject", "Date", "Message-ID"}
|
||||
|
||||
// DKIMSigner holds the parsed DKIM private key and exposes signing helpers.
|
||||
type DKIMSigner struct {
|
||||
priv *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// LoadOrGenerateDKIMKey returns a signer using the configured private key,
|
||||
// generating and persisting a fresh one to app.ini if none is set.
|
||||
func LoadOrGenerateDKIMKey() (*DKIMSigner, error) {
|
||||
raw := setting.M8SHMailServer.DKIMPrivateKey
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
log.Info("M8SH: no DKIM_PRIVATE_KEY configured, generating a 2048-bit RSA key")
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m8sh: generate dkim key: %w", err)
|
||||
}
|
||||
if err := saveDKIMKeyToConfig(key); err != nil {
|
||||
return nil, fmt.Errorf("m8sh: persist dkim key: %w", err)
|
||||
}
|
||||
logDKIMPublicKey(key)
|
||||
return &DKIMSigner{priv: key}, nil
|
||||
}
|
||||
|
||||
// Accept either a raw PEM string or a base64-wrapped PEM (as stored in app.ini).
|
||||
pemBytes := []byte(raw)
|
||||
if blk, _ := pem.Decode(pemBytes); blk == nil {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m8sh: dkim key is neither PEM nor base64: %w", err)
|
||||
}
|
||||
pemBytes = decoded
|
||||
}
|
||||
if blk, _ := pem.Decode(pemBytes); blk != nil {
|
||||
key, err := x509.ParsePKCS1PrivateKey(blk.Bytes)
|
||||
if err != nil {
|
||||
if anyKey, err2 := x509.ParsePKCS8PrivateKey(blk.Bytes); err2 == nil {
|
||||
if rk, ok := anyKey.(*rsa.PrivateKey); ok {
|
||||
key = rk
|
||||
} else {
|
||||
return nil, errors.New("m8sh: dkim PKCS8 key is not RSA")
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("m8sh: parse dkim private key: %w", err)
|
||||
}
|
||||
}
|
||||
logDKIMPublicKey(key)
|
||||
return &DKIMSigner{priv: key}, nil
|
||||
}
|
||||
return nil, errors.New("m8sh: could not decode dkim private key PEM")
|
||||
}
|
||||
|
||||
// PublicKeyDNSRecord returns the TXT record value that should be published at
|
||||
// <selector>._domainkey.<domain> for this key.
|
||||
func (s *DKIMSigner) PublicKeyDNSRecord() string {
|
||||
if s == nil || s.priv == nil {
|
||||
return ""
|
||||
}
|
||||
der := x509.MarshalPKCS1PublicKey(&s.priv.PublicKey)
|
||||
b64 := base64.StdEncoding.EncodeToString(der)
|
||||
// Chunk into 60-char pieces separated by spaces, as DNS TXT records expect.
|
||||
var b strings.Builder
|
||||
b.WriteString("v=DKIM1; k=rsa; p=")
|
||||
for i := 0; i < len(b64); i += 60 {
|
||||
end := min(i+60, len(b64))
|
||||
b.WriteString(b64[i:end])
|
||||
if end != len(b64) {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// DNSName returns the DKIM public key DNS lookup name for this signer.
|
||||
func (s *DKIMSigner) DNSName() string {
|
||||
return DKIMSelector + "._domainkey." + setting.Domain
|
||||
}
|
||||
|
||||
// Sign computes a relaxed/simple DKIM-Signature header value for the message.
|
||||
// It returns the full header value (without the header name) ready to be added.
|
||||
func (s *DKIMSigner) Sign(from, to, subject, date, messageID, body string) (string, error) {
|
||||
if s == nil || s.priv == nil {
|
||||
return "", errors.New("m8sh: nil dkim signer")
|
||||
}
|
||||
bodyHash := sha256.Sum256([]byte(canonicalBody(body)))
|
||||
bodyHashB64 := base64.StdEncoding.EncodeToString(bodyHash[:])
|
||||
|
||||
// Build the header list for signing (relaxed canonicalization).
|
||||
headers := map[string]string{
|
||||
"From": canonicalRelaxedHeader(from),
|
||||
"To": canonicalRelaxedHeader(to),
|
||||
"Subject": canonicalRelaxedHeader(subject),
|
||||
"Date": canonicalRelaxedHeader(date),
|
||||
"Message-ID": canonicalRelaxedHeader(messageID),
|
||||
}
|
||||
|
||||
// Construct the DKIM-Signature header (without b= data).
|
||||
dkimHeaderRelaxed := fmt.Sprintf(
|
||||
"v=1; a=rsa-sha256; c=relaxed/relaxed; d=%s; s=%s; t=%s; h=%s; bh=%s; b=",
|
||||
setting.Domain, DKIMSelector, date, strings.Join(defaultHeadersToSign, ":"), bodyHashB64,
|
||||
)
|
||||
|
||||
// Compute hash over the signed headers in order, then the dkim header.
|
||||
var hBuf strings.Builder
|
||||
for _, hn := range defaultHeadersToSign {
|
||||
hBuf.WriteString(headers[hn])
|
||||
hBuf.WriteString("\r\n")
|
||||
}
|
||||
hBuf.WriteString("dkim-signature:" + canonicalRelaxedHeader(dkimHeaderRelaxed))
|
||||
digest := sha256.Sum256([]byte(hBuf.String()))
|
||||
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, s.priv, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("m8sh: dkim sign: %w", err)
|
||||
}
|
||||
bVal := base64.StdEncoding.EncodeToString(sig)
|
||||
// Re-emit the dkim-signature value with the b= filled, folding long lines.
|
||||
return foldDKIM(dkimHeaderRelaxed + bVal), nil
|
||||
}
|
||||
|
||||
// canonicalBody applies simple body canonicalization: strip trailing whitespace
|
||||
// per line and collapse trailing empty lines.
|
||||
func canonicalBody(b string) string {
|
||||
b = strings.ReplaceAll(b, "\r\n", "\n")
|
||||
lines := strings.Split(b, "\n")
|
||||
for i, ln := range lines {
|
||||
lines[i] = strings.TrimRight(ln, " \t")
|
||||
}
|
||||
// strip trailing empty lines
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
return strings.Join(lines, "\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
// canonicalRelaxedHeader applies relaxed header canonicalization.
|
||||
func canonicalRelaxedHeader(v string) string {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
v = strings.Join(strings.Fields(v), " ")
|
||||
return v
|
||||
}
|
||||
|
||||
// foldDKIM folds long header values with CRLF WSP per RFC 5322.
|
||||
func foldDKIM(s string) string {
|
||||
const foldWidth = 70
|
||||
var b strings.Builder
|
||||
for len(s) > foldWidth {
|
||||
b.WriteString(s[:foldWidth])
|
||||
b.WriteString("\r\n ")
|
||||
s = s[foldWidth:]
|
||||
}
|
||||
b.WriteString(s)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// saveDKIMKeyToConfig writes the PEM private key into [mail_server].DKIM_PRIVATE_KEY.
|
||||
func saveDKIMKeyToConfig(key *rsa.PrivateKey) error {
|
||||
der := x509.MarshalPKCS1PrivateKey(key)
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
|
||||
b64 := base64.StdEncoding.EncodeToString(pemBytes)
|
||||
|
||||
cfg := setting.CfgProvider
|
||||
saveCfg, err := cfg.PrepareSaving()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Section("mail_server").Key("DKIM_PRIVATE_KEY").SetValue(b64)
|
||||
saveCfg.Section("mail_server").Key("DKIM_PRIVATE_KEY").SetValue(b64)
|
||||
if err := saveCfg.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
setting.M8SHMailServer.DKIMPrivateKey = b64
|
||||
return nil
|
||||
}
|
||||
|
||||
// logDKIMPublicKey logs the DNS record an operator must publish.
|
||||
func logDKIMPublicKey(key *rsa.PrivateKey) {
|
||||
s := &DKIMSigner{priv: key}
|
||||
log.Warn("M8SH: publish the following DNS TXT record at %s:\n%s", s.DNSName(), s.PublicKeyDNSRecord())
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGenerateAndLoadDKIM exercises the full key lifecycle: generate, encode,
|
||||
// re-parse from the base64-PEM form, and check the derived public-key DNS record.
|
||||
func TestGenerateAndLoadDKIM(t *testing.T) {
|
||||
// Generate a key directly (bypassing config persistence).
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048) // small for test speed
|
||||
require.NoError(t, err)
|
||||
s := &DKIMSigner{priv: key}
|
||||
|
||||
rec := s.PublicKeyDNSRecord()
|
||||
assert.Contains(t, rec, "v=DKIM1")
|
||||
assert.Contains(t, rec, "k=rsa")
|
||||
assert.Contains(t, rec, "p=")
|
||||
// The record must include the base64 of the RSA public key (DER, PKCS1).
|
||||
assert.NotEmpty(t, publicKeyB64Only(s))
|
||||
|
||||
// DNS name is selector._domainkey.<domain>; the domain comes from settings,
|
||||
// which may be empty in a unit test, but the selector prefix is stable.
|
||||
assert.True(t, strings.HasPrefix(s.DNSName(), DKIMSelector+"._domainkey."))
|
||||
}
|
||||
|
||||
// TestDKIMSignProducesFoldedSignature checks Sign returns a non-empty, folded
|
||||
// DKIM-Signature value that mentions the relaxed/relaxed algo and our selector.
|
||||
func TestDKIMSignProducesFoldedSignature(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
s := &DKIMSigner{priv: key}
|
||||
|
||||
sig, err := s.Sign("alice@example.com", "bob@example.com", "Hi",
|
||||
"Mon, 01 Jan 2026 00:00:00 +0000", "<m1@example.com>", "hello world")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, sig)
|
||||
assert.Contains(t, sig, "a=rsa-sha256")
|
||||
assert.Contains(t, sig, "c=relaxed/relaxed")
|
||||
assert.Contains(t, sig, "s="+DKIMSelector)
|
||||
assert.Contains(t, sig, "bh=") // body hash present
|
||||
assert.Contains(t, sig, "b=") // signature data present
|
||||
}
|
||||
|
||||
// TestCanonicalBody verifies simple body canonicalization: trailing whitespace
|
||||
// stripped per line, CRLF line endings, single trailing CRLF.
|
||||
func TestCanonicalBody(t *testing.T) {
|
||||
in := "line one \nline two\n\n\n"
|
||||
out := canonicalBody(in)
|
||||
assert.Equal(t, "line one\r\nline two\r\n", out)
|
||||
}
|
||||
|
||||
// TestCanonicalRelaxedHeader verifies relaxed header canonicalization:
|
||||
// lowercase, collapse runs of whitespace, trim.
|
||||
func TestCanonicalRelaxedHeader(t *testing.T) {
|
||||
assert.Equal(t, "foo bar", canonicalRelaxedHeader(" Foo Bar "))
|
||||
assert.Equal(t, "x", canonicalRelaxedHeader("X"))
|
||||
}
|
||||
|
||||
// TestFoldDKIM verifies long lines are folded with CRLF + space and that the
|
||||
// final segment is appended even when shorter than the fold width.
|
||||
func TestFoldDKIM(t *testing.T) {
|
||||
out := foldDKIM(strings.Repeat("x", 200))
|
||||
assert.Contains(t, out, "\r\n ")
|
||||
// Reassembling (removing the fold) must reproduce the input.
|
||||
reassembled := strings.ReplaceAll(out, "\r\n ", "")
|
||||
assert.Equal(t, strings.Repeat("x", 200), reassembled)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// DNSReport is the result of an M8SH DNS check run.
|
||||
type DNSReport struct {
|
||||
SPFOk bool
|
||||
DKIMOk bool
|
||||
DKIMMatches bool
|
||||
DMARCOk bool
|
||||
MXOk bool
|
||||
PTROk bool
|
||||
|
||||
// Suggested values to publish, for the admin self-check UI.
|
||||
SPFSuggested string
|
||||
DKIMExpectedValue string
|
||||
DKIMName string
|
||||
DMARCSuggested string
|
||||
}
|
||||
|
||||
// CheckDNS verifies the mail-relevant DNS records for setting.Domain.
|
||||
// It is non-fatal: the server starts regardless; this just reports gaps.
|
||||
func CheckDNS(ctx context.Context, signer *DKIMSigner) *DNSReport {
|
||||
r := &DNSReport{}
|
||||
domain := setting.Domain
|
||||
|
||||
r.DKIMName = DKIMSelector + "._domainkey." + domain
|
||||
if signer != nil {
|
||||
r.DKIMExpectedValue = signer.PublicKeyDNSRecord()
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
|
||||
// SPF: TXT @ contains v=spf1
|
||||
r.SPFSuggested = "v=spf1 mx a -all"
|
||||
if txts, err := lookupTXT(ctx, resolver, domain); err == nil {
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(t, "v=spf1") {
|
||||
r.SPFOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("M8SH DNS: SPF lookup failed for %s: %v", domain, err)
|
||||
}
|
||||
|
||||
// DKIM: TXT default._domainkey.<domain> contains v=DKIM1 and matches key
|
||||
if txts, err := lookupTXT(ctx, resolver, r.DKIMName); err == nil {
|
||||
joined := strings.Join(txts, "")
|
||||
if strings.Contains(joined, "v=DKIM1") {
|
||||
r.DKIMOk = true
|
||||
if signer != nil {
|
||||
pubB64 := publicKeyB64Only(signer)
|
||||
if pubB64 != "" && strings.Contains(stripSpaces(joined), pubB64) {
|
||||
r.DKIMMatches = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("M8SH DNS: DKIM lookup failed for %s: %v", r.DKIMName, err)
|
||||
}
|
||||
|
||||
// DMARC: TXT _dmarc.<domain> contains v=DMARC1
|
||||
r.DMARCSuggested = "v=DMARC1; p=quarantine; rua=mailto:postmaster@" + domain
|
||||
if txts, err := lookupTXT(ctx, resolver, "_dmarc."+domain); err == nil {
|
||||
for _, t := range txts {
|
||||
if strings.HasPrefix(t, "v=DMARC1") {
|
||||
r.DMARCOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("M8SH DNS: DMARC lookup failed for _dmarc.%s: %v", domain, err)
|
||||
}
|
||||
|
||||
// MX: <domain> MX points at domain (or a host under it)
|
||||
if mxs, err := resolver.LookupMX(ctx, domain); err == nil {
|
||||
for _, mx := range mxs {
|
||||
if strings.HasSuffix(strings.TrimSuffix(mx.Host, "."), domain) {
|
||||
r.MXOk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Warn("M8SH DNS: MX lookup failed for %s: %v", domain, err)
|
||||
}
|
||||
|
||||
// PTR: <ip> resolves to domain. Use the first A record of the domain.
|
||||
if ips, err := resolver.LookupIPAddr(ctx, domain); err == nil && len(ips) > 0 {
|
||||
ip := ips[0].IP.String()
|
||||
if names, err := resolver.LookupAddr(ctx, ip); err == nil {
|
||||
for _, n := range names {
|
||||
if strings.HasSuffix(strings.TrimSuffix(n, "."), domain) {
|
||||
r.PTROk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// LogGaps writes a warning for every missing record with the value to publish.
|
||||
func (r *DNSReport) LogGaps() {
|
||||
if r.SPFOk && r.DKIMOk && r.DKIMMatches && r.DMARCOk && r.MXOk && r.PTROk {
|
||||
log.Info("M8SH DNS: all mail DNS records OK")
|
||||
return
|
||||
}
|
||||
log.Warn("M8SH DNS: some mail DNS records are missing or incorrect:")
|
||||
if !r.SPFOk {
|
||||
log.Warn(" - SPF: publish TXT @ = %q", r.SPFSuggested)
|
||||
}
|
||||
if !r.DKIMOk || !r.DKIMMatches {
|
||||
log.Warn(" - DKIM: publish TXT %s = %q", r.DKIMName, r.DKIMExpectedValue)
|
||||
}
|
||||
if !r.DMARCOk {
|
||||
log.Warn(" - DMARC: publish TXT _dmarc.%s = %q", setting.Domain, r.DMARCSuggested)
|
||||
}
|
||||
if !r.MXOk {
|
||||
log.Warn(" - MX: publish MX %s = %q", setting.Domain, "10 "+setting.Domain+".")
|
||||
}
|
||||
if !r.PTROk {
|
||||
log.Warn(" - PTR: ensure your server IP reverse-resolves to %s", setting.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupTXT(ctx context.Context, r *net.Resolver, name string) ([]string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return r.LookupTXT(cctx, name)
|
||||
}
|
||||
|
||||
func stripSpaces(s string) string {
|
||||
return strings.Join(strings.Fields(s), "")
|
||||
}
|
||||
|
||||
// publicKeyB64Only extracts the p=<base64> portion (without the v= prefix) of
|
||||
// the expected DNS record, used to compare against a published TXT value.
|
||||
func publicKeyB64Only(s *DKIMSigner) string {
|
||||
rec := s.PublicKeyDNSRecord()
|
||||
_, after, ok := strings.Cut(rec, "p=")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stripSpaces(after)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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(" "))
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
mail_model "gitea.dev/models/mail"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/timeutil"
|
||||
)
|
||||
|
||||
// StartHealthcheck runs the loopback healthcheck immediately and then on the
|
||||
// configured interval. It is non-fatal: failures are recorded in m8sh_health.
|
||||
func StartHealthcheck(ctx context.Context, signer *DKIMSigner, sender *Sender) {
|
||||
runOnce(ctx, signer, sender)
|
||||
interval := setting.M8SHMailServer.HealthcheckInterv
|
||||
if interval <= 0 {
|
||||
log.Info("M8SH healthcheck: interval is 0, running once at startup only")
|
||||
return
|
||||
}
|
||||
go graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
runOnce(ctx, signer, sender)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// runOnce performs a single healthcheck: DNS + a full network loopback test
|
||||
// (send to healthcheck@<domain> over the public MX path, wait for receipt).
|
||||
func runOnce(ctx context.Context, signer *DKIMSigner, sender *Sender) {
|
||||
r := CheckDNS(ctx, signer)
|
||||
smtpOk := true
|
||||
errMsg := ""
|
||||
|
||||
// Full network loopback: send through the public MX, expect local receipt.
|
||||
if sender != nil {
|
||||
to := "healthcheck@" + setting.Domain
|
||||
probe := SendOptions{
|
||||
From: "healthcheck@" + setting.Domain,
|
||||
To: to,
|
||||
Subject: "M8SH healthcheck " + time.Now().UTC().Format(time.RFC3339),
|
||||
Body: "m8sh loopback healthcheck",
|
||||
Headers: map[string]string{healthcheckHeader: "true"},
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := sender.Send(sctx, probe); err != nil {
|
||||
smtpOk = false
|
||||
errMsg = fmt.Sprintf("loopback send: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h := &mail_model.Health{
|
||||
CheckedAt: timeutil.TimeStampNow(),
|
||||
SMTPOk: smtpOk,
|
||||
DNSSpf: r.SPFOk,
|
||||
DNSDkim: r.DKIMOk && r.DKIMMatches,
|
||||
DNSDmarc: r.DMARCOk,
|
||||
DNSPtr: r.PTROk,
|
||||
ErrorMsg: errMsg,
|
||||
}
|
||||
if err := mail_model.InsertHealth(ctx, h); err != nil {
|
||||
log.Error("M8SH healthcheck: insert: %v", err)
|
||||
}
|
||||
if !smtpOk || !r.SPFOk || !r.DKIMOk || !r.DKIMMatches || !r.DMARCOk || !r.PTROk {
|
||||
log.Warn("M8SH healthcheck: issues detected (smtp_ok=%v spf=%v dkim=%v dmarc=%v ptr=%v)",
|
||||
smtpOk, r.SPFOk, r.DKIMOk && r.DKIMMatches, r.DMARCOk, r.PTROk)
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
stdlibmail "net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/models/mail"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/eventsource"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/storage"
|
||||
"gitea.dev/modules/timeutil"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
// healthcheckHeader is the marker the healthcheck probe sets to detect itself.
|
||||
const healthcheckHeader = "X-M8SH-Healthcheck"
|
||||
|
||||
// DeliverInbound parses a raw RFC 5322 message and stores it for the recipient.
|
||||
// Healthcheck probes are logged and dropped (not stored).
|
||||
func DeliverInbound(ctx context.Context, from, to, raw string) error {
|
||||
// Parse the message with net/mail.
|
||||
msg, err := stdlibmail.ReadMessage(strings.NewReader(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh: parse message: %w", err)
|
||||
}
|
||||
headers := msg.Header
|
||||
|
||||
// Healthcheck probe: log and drop.
|
||||
if headers.Get(healthcheckHeader) != "" {
|
||||
log.Info("M8SH: received healthcheck probe from %s (subject=%q)", from, headers.Get("Subject"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve the local recipient user. The local-part of the address is the
|
||||
// username; the address may also be a registered email.
|
||||
ownerUID, err := resolveRecipientUID(ctx, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh: resolve recipient %s: %w", to, err)
|
||||
}
|
||||
if ownerUID == 0 {
|
||||
// Unknown local recipient: drop silently (could bounce in the future).
|
||||
log.Warn("M8SH: dropping mail for unknown local recipient %s", to)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decode subject.
|
||||
subject := decodeMimeHeader(headers.Get("Subject"))
|
||||
messageID := strings.TrimSpace(headers.Get("Message-ID"))
|
||||
inReplyTo := strings.TrimSpace(headers.Get("In-Reply-To"))
|
||||
date := time.Now()
|
||||
if d, err := stdlibmail.ParseDate(headers.Get("Date")); err == nil {
|
||||
date = d
|
||||
}
|
||||
|
||||
// Determine body + content type and attachments.
|
||||
body, contentType, atts, err := extractBodyAndAttachments(msg, headers)
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh: extract body: %w", err)
|
||||
}
|
||||
|
||||
// Build the canonical participant set and find/create the conversation.
|
||||
fromAddr := parseAddressHeader(from)
|
||||
participants := []string{fromAddr, to}
|
||||
partKey := participantsKey(participants)
|
||||
|
||||
conv, err := mail.GetOrCreateConversationForUser(ctx, ownerUID, partKey, subject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh: conversation: %w", err)
|
||||
}
|
||||
|
||||
m := &mail.Message{
|
||||
ConversationID: conv.ID,
|
||||
UID: 0, // external sender for inbound SMTP
|
||||
FromAddr: fromAddr,
|
||||
MessageID: messageID,
|
||||
InReplyTo: inReplyTo,
|
||||
Subject: subject,
|
||||
ContentType: contentType,
|
||||
Body: body,
|
||||
IsRead: false,
|
||||
Transport: mail.TransportSMTP,
|
||||
ReceivedUnix: timeutil.TimeStamp(date.Unix()),
|
||||
}
|
||||
if err := mail.InsertMessage(ctx, m); err != nil {
|
||||
return fmt.Errorf("m8sh: insert message: %w", err)
|
||||
}
|
||||
|
||||
// Notify the user in real time via the existing SSE eventsource, so the
|
||||
// messenger UI and the navbar badge update without a manual refresh.
|
||||
eventsource.GetManager().SendMessage(ownerUID, &eventsource.Event{
|
||||
Name: "notification-count",
|
||||
Data: `{"Count":1,"What":"m8sh-mail"}`,
|
||||
})
|
||||
|
||||
// Persist attachment metadata + blobs.
|
||||
for _, a := range atts {
|
||||
a.MessageID = m.ID
|
||||
if err := mail.InsertAttachment(ctx, a); err != nil {
|
||||
log.Error("M8SH: insert attachment: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRecipientUID returns the user id for a local email address, or 0.
|
||||
// Tries: local-part as username, then registered email address lookup.
|
||||
func resolveRecipientUID(ctx context.Context, addr string) (int64, error) {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
local, _, found := strings.Cut(addr, "@")
|
||||
if !found {
|
||||
return 0, nil
|
||||
}
|
||||
// Reserved/role accounts are not real users.
|
||||
switch local {
|
||||
case "noreply", "postmaster", "healthcheck", "abuse", "mailer":
|
||||
return 0, nil
|
||||
}
|
||||
// local-part = username
|
||||
if u, err := user_model.GetUserByName(ctx, local); err == nil && u != nil {
|
||||
return u.ID, nil
|
||||
}
|
||||
// otherwise a registered (possibly non-primary) email
|
||||
if ea, err := user_model.GetEmailAddressByEmail(ctx, addr); err == nil && ea != nil {
|
||||
return ea.UID, nil
|
||||
} else if !user_model.IsErrEmailAddressNotExist(err) && !user_model.IsErrUserNotExist(err) {
|
||||
return 0, err
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// extractBodyAndAttachments parses a (possibly multipart) message.
|
||||
func extractBodyAndAttachments(msg *stdlibmail.Message, headers stdlibmail.Header) (string, string, []*mail.Attachment, error) {
|
||||
contentType := headers.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
return string(body), "text/plain", nil, err
|
||||
}
|
||||
|
||||
mediatype, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
body, _ := io.ReadAll(msg.Body)
|
||||
return string(body), "text/plain", nil, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mediatype, "multipart/") {
|
||||
return parseMultipart(msg.Body, params["boundary"])
|
||||
}
|
||||
|
||||
// Single-part non-multipart message.
|
||||
body, err := readContent(msg.Body, headers.Get("Content-Transfer-Encoding"))
|
||||
if err != nil {
|
||||
return "", mediatype, nil, err
|
||||
}
|
||||
return body, mediatype, nil, nil
|
||||
}
|
||||
|
||||
// parseMultipart walks a multipart/* tree picking text/plain or text/html as
|
||||
// the body and stores every other part as an attachment.
|
||||
func parseMultipart(r io.Reader, boundary string) (string, string, []*mail.Attachment, error) {
|
||||
mpr := multipart.NewReader(r, boundary)
|
||||
var plainBody, htmlBody string
|
||||
var atts []*mail.Attachment
|
||||
|
||||
for {
|
||||
p, err := mpr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
ct := p.Header.Get("Content-Type")
|
||||
mediatype, params, _ := mime.ParseMediaType(ct)
|
||||
enc := p.Header.Get("Content-Transfer-Encoding")
|
||||
|
||||
switch {
|
||||
case mediatype == "text/plain":
|
||||
b, _ := readContent(p, enc)
|
||||
if plainBody == "" {
|
||||
plainBody = decodeCharset(b, params["charset"])
|
||||
}
|
||||
case mediatype == "text/html":
|
||||
b, _ := readContent(p, enc)
|
||||
if htmlBody == "" {
|
||||
htmlBody = decodeCharset(b, params["charset"])
|
||||
}
|
||||
case strings.HasPrefix(mediatype, "multipart/"):
|
||||
// Recurse into nested multipart (e.g. alternative inside mixed).
|
||||
if pb, hb, subAtts, err := parseMultipart(p, params["boundary"]); err == nil {
|
||||
if plainBody == "" {
|
||||
plainBody = pb
|
||||
}
|
||||
if htmlBody == "" {
|
||||
htmlBody = hb
|
||||
}
|
||||
atts = append(atts, subAtts...)
|
||||
}
|
||||
default:
|
||||
// Attachment (inline or not).
|
||||
data, _ := readContentBytes(p, enc)
|
||||
att, err := storeAttachment(p, mediatype, params, data)
|
||||
if err != nil {
|
||||
log.Warn("M8SH: store attachment: %v", err)
|
||||
continue
|
||||
}
|
||||
atts = append(atts, att)
|
||||
}
|
||||
}
|
||||
|
||||
if htmlBody != "" {
|
||||
return htmlBody, "text/html", atts, nil
|
||||
}
|
||||
return plainBody, "text/plain", atts, nil
|
||||
}
|
||||
|
||||
// readContent decodes a part body according to its Content-Transfer-Encoding.
|
||||
func readContent(r io.Reader, enc string) (string, error) {
|
||||
b, err := readContentBytes(r, enc)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func readContentBytes(r io.Reader, enc string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(enc)) {
|
||||
case "base64":
|
||||
dec := base64.NewDecoder(base64.StdEncoding, r)
|
||||
return io.ReadAll(dec)
|
||||
case "quoted-printable":
|
||||
dec := quotedprintable.NewReader(r)
|
||||
return io.ReadAll(dec)
|
||||
default:
|
||||
return io.ReadAll(r)
|
||||
}
|
||||
}
|
||||
|
||||
// storeAttachment writes the blob to the attachments storage and returns metadata.
|
||||
func storeAttachment(p *multipart.Part, mediatype string, params map[string]string, data []byte) (*mail.Attachment, error) {
|
||||
filename := params["name"]
|
||||
if filename == "" {
|
||||
filename = p.FileName()
|
||||
}
|
||||
if filename == "" {
|
||||
filename = "attachment"
|
||||
}
|
||||
cid := strings.Trim(p.Header.Get("Content-ID"), "<>")
|
||||
isInline := strings.EqualFold(p.Header.Get("Content-Disposition"), "inline") || cid != ""
|
||||
|
||||
storagePath := "m8sh/" + util.CryptoRandomString(32)
|
||||
if _, err := storage.Attachments.Save(storagePath, strings.NewReader(string(data)), int64(len(data))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mail.Attachment{
|
||||
StoragePath: storagePath,
|
||||
ContentType: mediatype,
|
||||
Filename: filename,
|
||||
Size: int64(len(data)),
|
||||
IsInline: isInline,
|
||||
ContentID: cid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decodeMimeHeader decodes RFC 2047 encoded-word headers.
|
||||
func decodeMimeHeader(s string) string {
|
||||
dec := new(mime.WordDecoder)
|
||||
out, err := dec.DecodeHeader(s)
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decodeCharset is a no-op for utf-8 (most common); fall back unchanged.
|
||||
func decodeCharset(s, charset string) string {
|
||||
_ = charset
|
||||
return s
|
||||
}
|
||||
|
||||
// parseAddressHeader extracts the bare email from a "Name <a@b>" header.
|
||||
func parseAddressHeader(v string) string {
|
||||
if a, err := stdlibmail.ParseAddress(v); err == nil {
|
||||
return a.Address
|
||||
}
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// participantsKey returns a canonical JSON array of sorted lowercase addresses.
|
||||
func participantsKey(addrs []string) string {
|
||||
lower := make([]string, len(addrs))
|
||||
for i, a := range addrs {
|
||||
lower[i] = strings.ToLower(strings.TrimSpace(a))
|
||||
}
|
||||
// dedupe + sort for stable matching
|
||||
seen := map[string]bool{}
|
||||
uniq := lower[:0]
|
||||
for _, a := range lower {
|
||||
if !seen[a] {
|
||||
seen[a] = true
|
||||
uniq = append(uniq, a)
|
||||
}
|
||||
}
|
||||
// sort
|
||||
for i := 1; i < len(uniq); i++ {
|
||||
for j := i; j > 0 && uniq[j-1] > uniq[j]; j-- {
|
||||
uniq[j-1], uniq[j] = uniq[j], uniq[j-1]
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(uniq)
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestReadContent_Plain verifies the default (no encoding) path reads bytes verbatim.
|
||||
func TestReadContent_Plain(t *testing.T) {
|
||||
out, err := readContent(strings.NewReader("hello world"), "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello world", out)
|
||||
}
|
||||
|
||||
// TestReadContent_Base64 verifies base64 decoding.
|
||||
func TestReadContent_Base64(t *testing.T) {
|
||||
// "hello" base64 = "aGVsbG8="
|
||||
out, err := readContent(strings.NewReader("aGVsbG8="), "base64")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello", out)
|
||||
}
|
||||
|
||||
// TestReadContent_QuotedPrintable verifies quoted-printable decoding.
|
||||
func TestReadContent_QuotedPrintable(t *testing.T) {
|
||||
// "=68=69" decodes to "hi"
|
||||
out, err := readContent(strings.NewReader("=68=69"), "quoted-printable")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hi", out)
|
||||
}
|
||||
|
||||
// TestParticipantsKey_Canonical verifies dedup, lowercase, sort, JSON shape.
|
||||
func TestParticipantsKey_Canonical(t *testing.T) {
|
||||
got := participantsKey([]string{"Bob@Example.com", "alice@example.com", "BOB@example.com"})
|
||||
// Sorted + deduped + lowercased.
|
||||
assert.Equal(t, `["alice@example.com","bob@example.com"]`, got)
|
||||
}
|
||||
|
||||
// TestParticipantsKey_OrderIndependent verifies the same set in any order
|
||||
// produces the same key (essential for conversation matching).
|
||||
func TestParticipantsKey_OrderIndependent(t *testing.T) {
|
||||
a := participantsKey([]string{"a@x.com", "b@x.com"})
|
||||
b := participantsKey([]string{"b@x.com", "a@x.com"})
|
||||
assert.Equal(t, a, b)
|
||||
}
|
||||
|
||||
// TestParseAddressHeader verifies extraction of the bare email from a
|
||||
// "Display Name <addr>" header and the bare-addr fallback.
|
||||
func TestParseAddressHeader(t *testing.T) {
|
||||
assert.Equal(t, "a@b.com", parseAddressHeader("Alice <a@b.com>"))
|
||||
assert.Equal(t, "a@b.com", parseAddressHeader("a@b.com"))
|
||||
assert.Equal(t, "garbage", parseAddressHeader("garbage"))
|
||||
}
|
||||
|
||||
// TestDecodeMimeHeader verifies RFC 2047 encoded-word decoding falls back
|
||||
// gracefully on plain ASCII (returns it unchanged).
|
||||
func TestDecodeMimeHeader_PlainASCII(t *testing.T) {
|
||||
assert.Equal(t, "Hello", decodeMimeHeader("Hello"))
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// SendOptions describes an outgoing message.
|
||||
type SendOptions struct {
|
||||
From string // envelope + From header (typically noreply@<domain>)
|
||||
To string // single recipient
|
||||
Subject string
|
||||
Body string
|
||||
HTML bool // if true, body is HTML
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// Sender is the shared outgoing sender used by both M8SH user mail and the
|
||||
// replaced Gitea system mailer.
|
||||
type Sender struct {
|
||||
dkim *DKIMSigner
|
||||
}
|
||||
|
||||
// NewSender returns a Sender with the configured DKIM signer (may be nil).
|
||||
func NewSender(dkim *DKIMSigner) *Sender {
|
||||
return &Sender{dkim: dkim}
|
||||
}
|
||||
|
||||
// Send delivers a message, preferring the M8SH HTTP endpoint when the recipient
|
||||
// domain advertises one, and falling back to SMTP (MX lookup) otherwise.
|
||||
func (s *Sender) Send(ctx context.Context, opts SendOptions) error {
|
||||
if opts.From == "" {
|
||||
opts.From = "noreply@" + setting.Domain
|
||||
}
|
||||
toAddr := opts.To
|
||||
_, domain, _ := strings.Cut(toAddr, "@")
|
||||
|
||||
// Try the M8SH HTTP fast-path first.
|
||||
if domain != "" && domain != setting.Domain {
|
||||
if used, err := s.tryM8SHHTTP(ctx, opts); err != nil {
|
||||
log.Warn("M8SH send: HTTP endpoint for %s failed: %v (falling back to SMTP)", domain, err)
|
||||
} else if used {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP fallback via MX lookup.
|
||||
return s.sendSMTP(ctx, opts)
|
||||
}
|
||||
|
||||
// sendSMTP resolves MX records and delivers via plaintext/STARTTLS SMTP.
|
||||
func (s *Sender) sendSMTP(ctx context.Context, opts SendOptions) error {
|
||||
_, domain, found := strings.Cut(opts.To, "@")
|
||||
if !found {
|
||||
return fmt.Errorf("m8sh send: invalid recipient %q", opts.To)
|
||||
}
|
||||
|
||||
mxs, err := net.DefaultResolver.LookupMX(ctx, domain)
|
||||
if err != nil || len(mxs) == 0 {
|
||||
// Per RFC 5321, fall back to the domain itself as the MX host.
|
||||
mxs = []*net.MX{{Host: domain + "."}}
|
||||
}
|
||||
|
||||
raw := s.buildRawMessage(opts)
|
||||
|
||||
var lastErr error
|
||||
for _, mx := range mxs {
|
||||
host := strings.TrimSuffix(mx.Host, ".")
|
||||
addr := net.JoinHostPort(host, "25")
|
||||
if err := dialAndDeliver(addr, host, opts.From, opts.To, raw); err != nil {
|
||||
lastErr = err
|
||||
log.Warn("M8SH send: deliver to %s failed: %v", host, err)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("m8sh send: no MX could be reached for %s", domain)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// dialAndDeliver opens a connection, says HELO, issues MAIL/RCPT/DATA.
|
||||
func dialAndDeliver(addr, host, from, to string, raw []byte) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client: %w", err)
|
||||
}
|
||||
defer func() { _ = c.Quit() }()
|
||||
|
||||
helo := setting.Domain
|
||||
if err := c.Hello(helo); err != nil {
|
||||
return fmt.Errorf("helo: %w", err)
|
||||
}
|
||||
// Opportunistic STARTTLS.
|
||||
if ok, _ := c.Extension("STARTTLS"); ok {
|
||||
if err := c.StartTLS(tlsClientConfig(host)); err != nil {
|
||||
log.Warn("M8SH send: STARTTLS to %s failed, continuing plaintext: %v", host, err)
|
||||
}
|
||||
}
|
||||
if err := c.Mail(from); err != nil {
|
||||
return fmt.Errorf("MAIL: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT: %w", err)
|
||||
}
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRawMessage assembles the full RFC 5322 message, applying DKIM signing.
|
||||
func (s *Sender) buildRawMessage(opts SendOptions) []byte {
|
||||
date := time.Now().Format(time.RFC1123Z)
|
||||
messageID := fmt.Sprintf("<m8sh-%d@%s>", time.Now().UnixNano(), setting.Domain)
|
||||
|
||||
contentType := "text/plain; charset=utf-8"
|
||||
if opts.HTML {
|
||||
contentType = "text/html; charset=utf-8"
|
||||
}
|
||||
|
||||
var hdr strings.Builder
|
||||
hdr.WriteString("From: " + opts.From + "\r\n")
|
||||
hdr.WriteString("To: " + opts.To + "\r\n")
|
||||
hdr.WriteString("Subject: " + opts.Subject + "\r\n")
|
||||
hdr.WriteString("Date: " + date + "\r\n")
|
||||
hdr.WriteString("Message-ID: " + messageID + "\r\n")
|
||||
hdr.WriteString("MIME-Version: 1.0\r\n")
|
||||
hdr.WriteString("Content-Type: " + contentType + "\r\n")
|
||||
hdr.WriteString("Content-Transfer-Encoding: 8bit\r\n")
|
||||
for k, v := range opts.Headers {
|
||||
hdr.WriteString(k + ": " + v + "\r\n")
|
||||
}
|
||||
|
||||
// DKIM sign (relaxed/relaxed) over the headers above + body.
|
||||
if s.dkim != nil {
|
||||
sig, err := s.dkim.Sign(opts.From, opts.To, opts.Subject, date, messageID, opts.Body)
|
||||
if err != nil {
|
||||
log.Warn("M8SH send: dkim sign failed: %v", err)
|
||||
} else {
|
||||
hdr.WriteString("DKIM-Signature: " + sig + "\r\n")
|
||||
}
|
||||
}
|
||||
hdr.WriteString("\r\n")
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(hdr.String())
|
||||
buf.WriteString(strings.ReplaceAll(opts.Body, "\n", "\r\n"))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// tryM8SHHTTP checks https://<domain>/.well-known/m8sh and POSTs the message
|
||||
// to /api/v1/mail/deliver if advertised. Returns (used, err).
|
||||
func (s *Sender) tryM8SHHTTP(ctx context.Context, opts SendOptions) (bool, error) {
|
||||
_, domain, _ := strings.Cut(opts.To, "@")
|
||||
wellKnown := "https://" + domain + "/.well-known/m8sh"
|
||||
|
||||
checkReq, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnown, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(checkReq)
|
||||
if err != nil {
|
||||
return false, nil // not an M8SH host, just fall back
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Advertised: deliver via HTTP.
|
||||
payload := buildDeliverPayload(opts)
|
||||
deliverURL := "https://" + domain + "/api/v1/mail/deliver"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, deliverURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
dresp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer dresp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, dresp.Body)
|
||||
if dresp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("m8sh http deliver: %s", dresp.Status)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// buildDeliverPayload is a minimal JSON payload for /api/v1/mail/deliver.
|
||||
func buildDeliverPayload(opts SendOptions) []byte {
|
||||
// Keep it simple and human-readable; the receiver just persists the fields.
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"from":"` + jsonEscape(opts.From) + `","to":"` + jsonEscape(opts.To) + `"`)
|
||||
b.WriteString(`,"subject":"` + jsonEscape(opts.Subject) + `"`)
|
||||
if opts.HTML {
|
||||
b.WriteString(`,"content_type":"text/html"`)
|
||||
} else {
|
||||
b.WriteString(`,"content_type":"text/plain"`)
|
||||
}
|
||||
b.WriteString(`,"body":"` + jsonEscape(opts.Body) + `"}`)
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
func jsonEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b.WriteString(`\"`)
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
default:
|
||||
if r < 0x20 {
|
||||
fmt.Fprintf(&b, `\u%04x`, r)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
smtpPort = "25"
|
||||
smtpBannerHost = "m8sh"
|
||||
)
|
||||
|
||||
// smtpSession tracks a single SMTP transaction.
|
||||
type smtpSession struct {
|
||||
conn net.Conn
|
||||
r *bufio.Reader
|
||||
w *bufio.Writer
|
||||
tlsConf *tls.Config
|
||||
|
||||
from string
|
||||
rcpts []string
|
||||
helo string
|
||||
starttls bool
|
||||
}
|
||||
|
||||
// StartSMTP launches the built-in SMTP listener on port 25.
|
||||
// It runs in a graceful-managed goroutine and is meant to be called after the
|
||||
// Gitea TLS listener is up (so certs are ready).
|
||||
func StartSMTP(ctx context.Context) error {
|
||||
tlsConf, err := GetTLSConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh smtp: tls config: %w", err)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(setting.HTTPAddr, smtpPort)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh smtp: listen %s: %w", addr, err)
|
||||
}
|
||||
|
||||
_, _, finished := process.GetManager().AddTypedContext(ctx, "M8SH: SMTP server (port 25)", process.SystemProcessType, true)
|
||||
go func() {
|
||||
defer finished()
|
||||
<-graceful.GetManager().IsShutdown()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
log.Info("M8SH SMTP: listening on %s (STARTTLS: %v)", addr, tlsConf != nil)
|
||||
|
||||
go func() {
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-graceful.GetManager().IsShutdown():
|
||||
wg.Wait()
|
||||
return
|
||||
default:
|
||||
log.Error("M8SH SMTP: accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
wg.Go(func() {
|
||||
handleSMTPConn(ctx, conn, tlsConf)
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSMTPConn(ctx context.Context, conn net.Conn, tlsConf *tls.Config) {
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Minute))
|
||||
|
||||
s := &smtpSession{
|
||||
conn: conn,
|
||||
r: bufio.NewReader(conn),
|
||||
w: bufio.NewWriter(conn),
|
||||
tlsConf: tlsConf,
|
||||
}
|
||||
fmt.Fprintf(s.w, "220 %s M8SH ESMTP\r\n", smtpBannerHost)
|
||||
if err := s.w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
line, err := s.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
verb := strings.ToUpper(line)
|
||||
cmd, _ := splitCmd(verb)
|
||||
|
||||
switch cmd {
|
||||
case "HELO", "EHLO":
|
||||
s.helo = strings.TrimSpace(line[len(cmd):])
|
||||
s.greet(cmd == "EHLO")
|
||||
case "STARTTLS":
|
||||
s.doStartTLS()
|
||||
case "MAIL":
|
||||
s.handleMail(line)
|
||||
case "RCPT":
|
||||
s.handleRcpt(line)
|
||||
case "DATA":
|
||||
s.handleData(ctx)
|
||||
case "RSET":
|
||||
s.reset()
|
||||
fmt.Fprint(s.w, "250 OK\r\n")
|
||||
case "NOOP":
|
||||
fmt.Fprint(s.w, "250 OK\r\n")
|
||||
case "QUIT":
|
||||
fmt.Fprint(s.w, "221 Bye\r\n")
|
||||
s.w.Flush()
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(s.w, "502 Command not implemented: %s\r\n", cmd)
|
||||
}
|
||||
if err := s.w.Flush(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// greet prints the EHLO capability list or the HELO acknowledgement.
|
||||
func (s *smtpSession) greet(ehlo bool) {
|
||||
fmt.Fprintf(s.w, "250-%s M8SH at your service\r\n", smtpBannerHost)
|
||||
fmt.Fprintf(s.w, "250-8BITMIME\r\n")
|
||||
fmt.Fprintf(s.w, "250-PIPELINING\r\n")
|
||||
fmt.Fprintf(s.w, "250-SIZE %d\r\n", 25*1024*1024)
|
||||
if s.tlsConf != nil && !s.starttls {
|
||||
fmt.Fprintf(s.w, "250-STARTTLS\r\n")
|
||||
}
|
||||
fmt.Fprintf(s.w, "250 HELP\r\n")
|
||||
_ = ehlo
|
||||
}
|
||||
|
||||
func (s *smtpSession) doStartTLS() {
|
||||
if s.tlsConf == nil {
|
||||
fmt.Fprint(s.w, "502 STARTTLS not available\r\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprint(s.w, "220 Ready to start TLS\r\n")
|
||||
s.w.Flush()
|
||||
|
||||
tlsConn := tls.Server(s.conn, s.tlsConf)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
log.Warn("M8SH SMTP: STARTTLS handshake failed: %v", err)
|
||||
return
|
||||
}
|
||||
s.conn = tlsConn
|
||||
s.r = bufio.NewReader(tlsConn)
|
||||
s.w = bufio.NewWriter(tlsConn)
|
||||
s.starttls = true
|
||||
s.reset()
|
||||
}
|
||||
|
||||
func (s *smtpSession) handleMail(line string) {
|
||||
addr := extractAddr(line)
|
||||
if addr == "" {
|
||||
fmt.Fprint(s.w, "501 Syntax: MAIL FROM:<address>\r\n")
|
||||
return
|
||||
}
|
||||
s.reset()
|
||||
s.from = addr
|
||||
fmt.Fprint(s.w, "250 OK\r\n")
|
||||
}
|
||||
|
||||
func (s *smtpSession) handleRcpt(line string) {
|
||||
if s.from == "" {
|
||||
fmt.Fprint(s.w, "503 Need MAIL first\r\n")
|
||||
return
|
||||
}
|
||||
addr := extractAddr(line)
|
||||
if addr == "" {
|
||||
fmt.Fprint(s.w, "501 Syntax: RCPT TO:<address>\r\n")
|
||||
return
|
||||
}
|
||||
// Only accept mail for our own domain (we are not an open relay).
|
||||
if !strings.HasSuffix(strings.ToLower(addr), "@"+strings.ToLower(setting.Domain)) {
|
||||
fmt.Fprintf(s.w, "550 Relay not allowed for %s\r\n", addr)
|
||||
return
|
||||
}
|
||||
s.rcpts = append(s.rcpts, addr)
|
||||
fmt.Fprint(s.w, "250 OK\r\n")
|
||||
}
|
||||
|
||||
func (s *smtpSession) handleData(ctx context.Context) {
|
||||
if len(s.rcpts) == 0 {
|
||||
fmt.Fprint(s.w, "503 Need RCPT first\r\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprint(s.w, "354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
s.w.Flush()
|
||||
|
||||
// Read the message body until the lone dot terminator.
|
||||
var b strings.Builder
|
||||
for {
|
||||
line, err := s.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
trimmed := strings.TrimRight(line, "\r\n")
|
||||
if trimmed == "." {
|
||||
break
|
||||
}
|
||||
// RFC 5321 dot-unstuffing
|
||||
if strings.HasPrefix(trimmed, "..") {
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
raw := b.String()
|
||||
for _, rcpt := range s.rcpts {
|
||||
if err := DeliverInbound(ctx, s.from, rcpt, raw); err != nil {
|
||||
log.Error("M8SH SMTP: deliver %s -> %s: %v", s.from, rcpt, err)
|
||||
}
|
||||
}
|
||||
s.reset()
|
||||
fmt.Fprint(s.w, "250 OK: queued\r\n")
|
||||
}
|
||||
|
||||
func (s *smtpSession) reset() {
|
||||
s.from = ""
|
||||
s.rcpts = nil
|
||||
}
|
||||
|
||||
// splitCmd returns the command verb and the rest of the line.
|
||||
func splitCmd(line string) (cmd, rest string) {
|
||||
line = strings.TrimSpace(line)
|
||||
if before, after, ok := strings.Cut(line, " "); ok {
|
||||
return before, after
|
||||
}
|
||||
return line, ""
|
||||
}
|
||||
|
||||
// extractAddr pulls the <addr> out of a "MAIL FROM:<addr>" or "RCPT TO:<addr>" line.
|
||||
func extractAddr(line string) string {
|
||||
i := strings.IndexByte(line, '<')
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
j := strings.IndexByte(line[i+1:], '>')
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return line[i+1 : i+1+j]
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestSplitCmd verifies the SMTP command verb + rest splitting.
|
||||
func TestSplitCmd(t *testing.T) {
|
||||
cmd, rest := splitCmd("MAIL FROM:<a@b.com>")
|
||||
assert.Equal(t, "MAIL", cmd)
|
||||
assert.Equal(t, "FROM:<a@b.com>", rest)
|
||||
|
||||
cmd, rest = splitCmd("NOOP")
|
||||
assert.Equal(t, "NOOP", cmd)
|
||||
assert.Empty(t, rest)
|
||||
|
||||
cmd, rest = splitCmd("EHLO example.com")
|
||||
assert.Equal(t, "EHLO", cmd)
|
||||
assert.Equal(t, "example.com", rest)
|
||||
}
|
||||
|
||||
// TestExtractAddr verifies <addr> extraction from MAIL FROM / RCPT TO lines,
|
||||
// including the missing-bracket and malformed cases.
|
||||
func TestExtractAddr(t *testing.T) {
|
||||
assert.Equal(t, "a@b.com", extractAddr("MAIL FROM:<a@b.com>"))
|
||||
assert.Equal(t, "x@y.com", extractAddr("RCPT TO:<x@y.com>"))
|
||||
assert.Empty(t, extractAddr("MAIL FROM:a@b.com")) // no angle brackets
|
||||
assert.Empty(t, extractAddr("")) // empty
|
||||
assert.Empty(t, extractAddr("MAIL FROM:<unclosed")) // no closing bracket
|
||||
}
|
||||
|
||||
// TestSessionReset verifies the transaction state is cleared.
|
||||
func TestSessionReset(t *testing.T) {
|
||||
s := &smtpSession{from: "a@b.com", rcpts: []string{"x@y.com"}}
|
||||
s.reset()
|
||||
assert.Empty(t, s.from)
|
||||
assert.Empty(t, s.rcpts)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// SharedSender is the process-wide outgoing sender, set by Start.
|
||||
// It is also used by the Gitea system-mailer shim (mailer.go).
|
||||
var SharedSender *Sender
|
||||
|
||||
// Start boots the M8SH mail subsystem: DKIM key, DNS check, SMTP listener and
|
||||
// the healthcheck goroutine. It is a no-op unless [mail_server] ENABLED=true.
|
||||
// It must be called after Gitea's TLS listener is ready.
|
||||
func Start(ctx context.Context) error {
|
||||
if setting.M8SHMailServer == nil || !setting.M8SHMailServer.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
signer, err := LoadOrGenerateDKIMKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("m8sh: dkim: %w", err)
|
||||
}
|
||||
|
||||
// DNS check at startup (non-fatal, logged warnings with copy-paste values).
|
||||
CheckDNS(ctx, signer).LogGaps()
|
||||
|
||||
SharedSender = NewSender(signer)
|
||||
|
||||
if err := StartSMTP(ctx); err != nil {
|
||||
// Don't abort boot over the SMTP listener; mail may still go out.
|
||||
log.Error("M8SH: SMTP listener failed to start: %v", err)
|
||||
} else {
|
||||
go StartHealthcheck(ctx, signer, SharedSender)
|
||||
}
|
||||
|
||||
log.Info("M8SH mail server started (domain=%s)", setting.Domain)
|
||||
_ = graceful.GetManager // ensure import is exercised
|
||||
return nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2026 The M8SH Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package mail
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
)
|
||||
|
||||
// ErrNoTLSCertificate indicates no TLS cert is configured (SMTP runs plaintext).
|
||||
var ErrNoTLSCertificate = errors.New("no TLS certificate configured")
|
||||
|
||||
// GetTLSConfig builds a *tls.Config for the SMTP STARTTLS listener, reusing
|
||||
// the very same certificate Gitea's HTTPS server uses.
|
||||
//
|
||||
// Precedence:
|
||||
// 1. setting.EnableAcme → use certmagic (same Default.Storage as web_acme.go)
|
||||
// 2. setting.CertFile/KeyFile → load the keypair directly
|
||||
// 3. otherwise → returns nil (SMTP runs plaintext with a warning)
|
||||
func GetTLSConfig() (*tls.Config, error) {
|
||||
domain := setting.Domain
|
||||
|
||||
if setting.EnableAcme {
|
||||
// Mirror the storage configuration used by cmd/web_acme.go. We do not
|
||||
// request new certs here (the HTTPS listener already did); we just
|
||||
// serve the cached one for STARTTLS.
|
||||
oldDefaultStorage := certmagic.Default.Storage
|
||||
oldDefaultACME := certmagic.DefaultACME
|
||||
certmagic.Default.Storage = &certmagic.FileStorage{Path: setting.AcmeLiveDirectory}
|
||||
certmagic.DefaultACME = certmagic.ACMEIssuer{
|
||||
CA: setting.AcmeURL,
|
||||
Email: setting.AcmeEmail,
|
||||
Agreed: setting.AcmeTOS,
|
||||
DisableHTTPChallenge: false,
|
||||
DisableTLSALPNChallenge: false,
|
||||
ListenHost: setting.HTTPAddr,
|
||||
}
|
||||
defer func() {
|
||||
certmagic.Default.Storage = oldDefaultStorage
|
||||
certmagic.DefaultACME = oldDefaultACME
|
||||
}()
|
||||
|
||||
magic := certmagic.NewDefault()
|
||||
tlsConfig := magic.TLSConfig()
|
||||
tlsConfig.ServerName = domain
|
||||
tlsConfig.MinVersion = tls.VersionTLS12
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
if setting.CertFile != "" && setting.KeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(setting.CertFile, setting.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("m8sh: load cert/key pair: %w", err)
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
ServerName: domain,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}, nil
|
||||
}
|
||||
|
||||
log.Warn("M8SH TLS: no certificate available (no ACME, no CERT_FILE) — SMTP will run without STARTTLS")
|
||||
return nil, ErrNoTLSCertificate
|
||||
}
|
||||
@@ -34,23 +34,13 @@ func NewContext(ctx context.Context) {
|
||||
notify_service.RegisterNotifier(NewNotifier())
|
||||
}
|
||||
|
||||
// M8SH: if the built-in mail server is enabled, route all system mail
|
||||
// (activation, password reset, notifications) through it instead of the
|
||||
// external [mailer] SMTP config. The sender is always noreply@<domain>.
|
||||
if setting.M8SHMailServer != nil && setting.M8SHMailServer.Enabled {
|
||||
sender = &sender_service.M8SHSender{}
|
||||
if setting.MailService == nil {
|
||||
setting.MailService = &setting.Mailer{From: "noreply@" + setting.Domain}
|
||||
}
|
||||
} else {
|
||||
switch setting.MailService.Protocol {
|
||||
case "sendmail":
|
||||
sender = &sender_service.SendmailSender{}
|
||||
case "dummy":
|
||||
sender = &sender_service.DummySender{}
|
||||
default:
|
||||
sender = &sender_service.SMTPSender{}
|
||||
}
|
||||
switch setting.MailService.Protocol {
|
||||
case "sendmail":
|
||||
sender = &sender_service.SendmailSender{}
|
||||
case "dummy":
|
||||
sender = &sender_service.DummySender{}
|
||||
default:
|
||||
sender = &sender_service.SMTPSender{}
|
||||
}
|
||||
|
||||
_ = templates.MailRenderer()
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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) }
|
||||
@@ -51,34 +51,6 @@
|
||||
<div class="ui warning message">{{ctx.Locale.Tr "admin.config.cache_test_slow" .CacheSlow}}</div>
|
||||
{{end}}
|
||||
|
||||
{{/* M8SH: Mail DNS status */}}
|
||||
{{if .MailDNSReport}}
|
||||
<div class="ui attached segment">
|
||||
<h3 class="ui header">M8SH Mail DNS Status</h3>
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr><td>SPF (TXT @)</td><td>{{if .MailDNSReport.SPFOk}}✅{{else}}❌{{end}}</td>
|
||||
<td><code>{{.MailDNSReport.SPFSuggested}}</code></td></tr>
|
||||
<tr><td>DKIM (TXT {{.MailDNSReport.DKIMName}})</td>
|
||||
<td>{{if .MailDNSReport.DKIMMatches}}✅{{else if .MailDNSReport.DKIMOk}}⚠️ key mismatch{{else}}❌{{end}}</td>
|
||||
<td><code>{{.MailDNSReport.DKIMExpectedValue}}</code></td></tr>
|
||||
<tr><td>DMARC (TXT _dmarc.{{.Domain}})</td><td>{{if .MailDNSReport.DMARCOk}}✅{{else}}❌{{end}}</td>
|
||||
<td><code>{{.MailDNSReport.DMARCSuggested}}</code></td></tr>
|
||||
<tr><td>MX</td><td>{{if .MailDNSReport.MXOk}}✅{{else}}❌{{end}}</td>
|
||||
<td><code>10 {{.Domain}}.</code></td></tr>
|
||||
<tr><td>PTR</td><td>{{if .MailDNSReport.PTROk}}✅{{else}}❌{{end}}</td>
|
||||
<td><code>{{.Domain}}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{{if .MailHealth}}
|
||||
<div class="ui {{if .MailHealth.SMTPOk}}ok{{else}}error{{end}} message">
|
||||
Last healthcheck: SMTP {{if .MailHealth.SMTPOk}}OK{{else}}FAILED{{end}}
|
||||
{{if .MailHealth.ErrorMsg}}— {{.MailHealth.ErrorMsg}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* only shown when there is no visible "self-check-problem" */}}
|
||||
<div class="ui attached segment tw-hidden self-check-no-problem">
|
||||
{{ctx.Locale.Tr "admin.self_check.no_problem_found"}}
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
{{if and .IsSigned .MustChangePassword}}
|
||||
{{/* No links */}}
|
||||
{{else if .IsSigned}}
|
||||
{{$mailData := .PageGlobalData}}{{$mailUnread := 0}}{{if $mailData}}{{$mailUnread = call $mailData.GetMailUnreadCount}}{{end}}
|
||||
<a class="item{{if .PageIsMail}} active{{end}}" href="{{AppSubUrl}}/mail">
|
||||
<span>{{ctx.Locale.Tr "mail.navbar"}}</span>{{if $mailUnread}}
|
||||
<span class="ui tiny red label">{{$mailUnread}}</span>{{end}}
|
||||
</a>
|
||||
{{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
|
||||
<a class="item{{if .PageIsIssues}} active{{end}}" href="{{AppSubUrl}}/issues">{{ctx.Locale.Tr "issues"}}</a>
|
||||
{{end}}
|
||||
@@ -32,6 +27,10 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/repos">{{ctx.Locale.Tr "explore_title"}}</a>
|
||||
<!-- M8SH Mail: "Messages" link to the right of explore, always visible when signed in -->
|
||||
{{if and .IsSigned (not .MustChangePassword)}}
|
||||
<a class="item{{if .PageIsMail}} active{{end}}" href="{{AppSubUrl}}/mail">Chats</a>
|
||||
{{end}}
|
||||
{{else if .IsLandingPageOrganizations}}
|
||||
<a class="item{{if .PageIsExplore}} active{{end}}" href="{{AppSubUrl}}/explore/organizations">{{ctx.Locale.Tr "explore_title"}}</a>
|
||||
{{else}}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{{template "base/head" .}}
|
||||
<div role="main" aria-label="{{.Title}}" class="page-content mail-page">
|
||||
<div id="mail-app"></div>
|
||||
</div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,26 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div id="messenger-app"
|
||||
data-current-user-email="{{$.CurrentUserEmail}}"
|
||||
data-conversations="{{.ConversationsJSON}}"
|
||||
data-active-conversation="{{.ActiveConvJSON}}"
|
||||
data-messages="{{.MessagesJSON}}"
|
||||
data-initial-conversation-id="{{.Conversation.ID}}"
|
||||
data-locale-search="{{ctx.Locale.Tr "mail.search_placeholder"}}"
|
||||
data-locale-compose="{{ctx.Locale.Tr "mail.compose_placeholder"}}"
|
||||
data-locale-markdown="{{ctx.Locale.Tr "mail.markdown_supported"}}"
|
||||
data-locale-select="{{ctx.Locale.Tr "mail.select_conversation"}}"
|
||||
data-locale-empty="{{ctx.Locale.Tr "mail.empty"}}"
|
||||
data-locale-new-conv="{{ctx.Locale.Tr "mail.new_conversation"}}"
|
||||
data-locale-inbox="{{ctx.Locale.Tr "mail.inbox"}}"
|
||||
data-locale-sent="{{ctx.Locale.Tr "mail.sent"}}"
|
||||
data-locale-starred="{{ctx.Locale.Tr "mail.starred"}}"
|
||||
data-locale-archive="{{ctx.Locale.Tr "mail.archive"}}"
|
||||
data-locale-groups="{{ctx.Locale.Tr "mail.groups"}}"
|
||||
data-locale-reply-to="{{ctx.Locale.Tr "mail.reply_to"}}"
|
||||
data-locale-participants="Participants (email or username)"
|
||||
data-locale-subject="Subject"
|
||||
data-locale-message="Message"
|
||||
data-locale-cancel="Cancel"
|
||||
data-locale-send="{{ctx.Locale.Tr "mail.send"}}"
|
||||
></div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,24 +0,0 @@
|
||||
{{template "base/head" .}}
|
||||
<div id="messenger-app"
|
||||
data-current-user-email="{{.CurrentUserEmail}}"
|
||||
data-conversations="{{.ConversationsJSON}}"
|
||||
data-initial-conversation-id="0"
|
||||
data-locale-search="{{ctx.Locale.Tr "mail.search_placeholder"}}"
|
||||
data-locale-compose="{{ctx.Locale.Tr "mail.compose_placeholder"}}"
|
||||
data-locale-markdown="{{ctx.Locale.Tr "mail.markdown_supported"}}"
|
||||
data-locale-select="{{ctx.Locale.Tr "mail.select_conversation"}}"
|
||||
data-locale-empty="{{ctx.Locale.Tr "mail.empty"}}"
|
||||
data-locale-new-conv="{{ctx.Locale.Tr "mail.new_conversation"}}"
|
||||
data-locale-inbox="{{ctx.Locale.Tr "mail.inbox"}}"
|
||||
data-locale-sent="{{ctx.Locale.Tr "mail.sent"}}"
|
||||
data-locale-starred="{{ctx.Locale.Tr "mail.starred"}}"
|
||||
data-locale-archive="{{ctx.Locale.Tr "mail.archive"}}"
|
||||
data-locale-groups="{{ctx.Locale.Tr "mail.groups"}}"
|
||||
data-locale-reply-to="{{ctx.Locale.Tr "mail.reply_to"}}"
|
||||
data-locale-participants="Participants (email or username)"
|
||||
data-locale-subject="Subject"
|
||||
data-locale-message="Message"
|
||||
data-locale-cancel="Cancel"
|
||||
data-locale-send="{{ctx.Locale.Tr "mail.send"}}"
|
||||
></div>
|
||||
{{template "base/footer" .}}
|
||||
@@ -1,30 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -84,8 +84,8 @@
|
||||
@import "./explore.css";
|
||||
@import "./review.css";
|
||||
@import "./actions.css";
|
||||
@import "./mail.css";
|
||||
|
||||
@import "./helpers.css";
|
||||
@import "./features/messenger.css";
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/* Copyright 2026 The M8SH Authors. */
|
||||
/* SPDX-License-Identifier: GPL-3.0 */
|
||||
|
||||
/* The mail page fills the viewport below the navbar and above the footer.
|
||||
The body is a flex column: [navbar wrapper .full.height] then [footer].
|
||||
.full.height has flex-grow:1, so it already fills everything except the footer.
|
||||
We make it a flex container so the mail page can flex:1 within it, sitting below the navbar. */
|
||||
|
||||
/* When the mail page is active, make .full.height a column flex container so
|
||||
the navbar stays fixed at top and the mail page fills the remaining height. */
|
||||
.full.height:has(.mail-page) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-bottom: 0; /* mail manages its own bottom spacing */
|
||||
}
|
||||
|
||||
.page-content.mail-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-content.mail-page > #mail-app {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* On mobile, hide the footer so the mail app uses the full viewport height. */
|
||||
@media (max-width: 639.98px) {
|
||||
.full.height:has(.mail-page) ~ .page-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* In-thread search highlight. Global (not scoped) because it must reach
|
||||
<mark> nodes produced via v-html inside bubbles and html cards. */
|
||||
#mail-app mark.search-hit {
|
||||
background: var(--color-highlight-bg);
|
||||
color: var(--color-text);
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
#mail-app mark.search-hit.current {
|
||||
outline: 1px solid var(--color-highlight-fg);
|
||||
}
|
||||
|
||||
/* Thread context menu. It is teleported to <body> (outside the Vue scoped
|
||||
component subtree), so its styles live here, unscoped. */
|
||||
body > .ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 1002;
|
||||
min-width: 140px;
|
||||
background: var(--color-menu);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
body > .ctx-menu .ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
body > .ctx-menu .ctx-item:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
body > .ctx-menu .ctx-item.danger:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* Delete conversation confirmation dialog (teleported, so unscoped). */
|
||||
body > .confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1003;
|
||||
background: var(--color-overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: var(--color-menu);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-red-badge-bg);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-title {
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-light-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-cancel {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
background: var(--color-body);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-cancel:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-delete {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: var(--color-danger);
|
||||
color: var(--color-white);
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
body > .confirm-overlay .confirm-delete:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Block toggle row: a right-aligned chevron that flips up when expanded. */
|
||||
body > .ctx-menu .ctx-toggle .ctx-chev {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-light-2);
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
body > .ctx-menu .ctx-toggle .ctx-chev.up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Expandable Block panel: its option buttons go edge-to-edge on hover so the
|
||||
whole row is the target, not just the centered label. */
|
||||
body > .ctx-menu .ctx-expand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-top: 2px;
|
||||
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
body > .ctx-menu .ctx-expand .ctx-opt {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
padding-left: 10px; /* flush with the other menu items' text, no icon column */
|
||||
}
|
||||
body > .ctx-menu .ctx-expand .ctx-opt:hover {
|
||||
width: 100%;
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
/* Rename group dialog input */
|
||||
body > .confirm-overlay .rename-input {
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
body > .confirm-overlay .rename-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
body > .confirm-overlay .rename-modal {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* Mobile overflow menu: transparent full-screen click catcher with the menu
|
||||
positioned in the top-right corner, just like the context menu (no darkening). */
|
||||
body > .overflow-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1002;
|
||||
}
|
||||
|
||||
body > .overflow-backdrop .overflow-menu {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
min-width: 160px;
|
||||
background: var(--color-menu);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
body > .overflow-backdrop .ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
body > .overflow-backdrop .ctx-item:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
body > .overflow-backdrop .ctx-item.danger:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
|
||||
/* Audio player buttons: instant hover, no delay (overrides all inherited transitions) */
|
||||
#mail-app .audio-player a.ap-btn,
|
||||
#mail-app .audio-player button.ap-btn,
|
||||
#mail-app .audio-player a.ap-download,
|
||||
#mail-app .audio-player .ap-download,
|
||||
#mail-app .audio-player .ap-close,
|
||||
#mail-app .audio-player .ap-btn,
|
||||
#mail-app .audio-player .ap-btn:hover {
|
||||
transition: none !important;
|
||||
transition-property: none !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, onMounted, computed, nextTick} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
type Conversation = {
|
||||
id: number;
|
||||
subject: string;
|
||||
participants: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type Message = {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
from_addr: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
received_unix: number;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MessengerApp',
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
currentUserEmail: {type: String, default: ''},
|
||||
initialConversationId: {type: Number, default: 0},
|
||||
initialConversations: {type: String, default: '[]'},
|
||||
initialActiveConversation: {type: String, default: ''},
|
||||
initialMessages: {type: String, default: '[]'},
|
||||
locale: {type: Object, default: () => ({})},
|
||||
},
|
||||
setup(props) {
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const activeConversation = ref<Conversation | null>(null);
|
||||
const messages = ref<Message[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const composeText = ref('');
|
||||
const loading = ref(false);
|
||||
const newConvModalOpen = ref(false);
|
||||
const newConvParticipants = ref('');
|
||||
const newConvSubject = ref('');
|
||||
const newConvMessage = ref('');
|
||||
|
||||
// Deterministic avatar hue from string hash
|
||||
const avatarHue = (s: string): number => {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (h * 31 + s.charCodeAt(i)) % 360;
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
const avatarStyle = (s: string) => ({
|
||||
background: `hsl(${avatarHue(s)}, 65%, 50%)`,
|
||||
});
|
||||
|
||||
const initials = (s: string): string => {
|
||||
const emailPart = s.includes('@') ? s.split('@')[0] : s;
|
||||
const parts = emailPart.split(/[\s,]/).filter(Boolean);
|
||||
const result = parts.slice(0, 2).map(p => p[0]?.toUpperCase() || '').join('');
|
||||
return result || '?';
|
||||
};
|
||||
|
||||
const formatDate = (unix: number | string): string => {
|
||||
const d = typeof unix === 'string' ? new Date(unix) : new Date(unix * 1000);
|
||||
return d.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
|
||||
};
|
||||
|
||||
// Fetch conversation list
|
||||
const fetchConversations = async () => {
|
||||
try {
|
||||
const resp = await GET(`${appSubUrl}/api/v1/mail/conversations`);
|
||||
if (resp.ok) {
|
||||
conversations.value = await resp.json();
|
||||
}
|
||||
} catch {
|
||||
// API may not be fully implemented yet; fall back to page data
|
||||
}
|
||||
};
|
||||
|
||||
// Load a conversation
|
||||
const selectConversation = async (conv: Conversation) => {
|
||||
activeConversation.value = conv;
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await GET(`${appSubUrl}/mail/${conv.id}/poll?since=0`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
messages.value = (data.messages || []).map((m: any) => ({
|
||||
...m,
|
||||
conversation_id: conv.id,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = document.querySelector('.messenger-messages');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
};
|
||||
|
||||
// Send a message
|
||||
const sendMessage = async () => {
|
||||
if (!composeText.value.trim() || !activeConversation.value) return;
|
||||
const conv = activeConversation.value;
|
||||
const text = composeText.value;
|
||||
composeText.value = '';
|
||||
|
||||
try {
|
||||
await POST(`${appSubUrl}/mail/${conv.id}/send`, {
|
||||
body: {body: text},
|
||||
});
|
||||
} catch {
|
||||
// Optimistic append even if API fails
|
||||
}
|
||||
|
||||
// Optimistic: append locally
|
||||
messages.value.push({
|
||||
id: Date.now(),
|
||||
conversation_id: conv.id,
|
||||
from_addr: props.currentUserEmail,
|
||||
subject: '',
|
||||
body: text,
|
||||
received_unix: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
};
|
||||
|
||||
// Filter pills
|
||||
const activeFilter = ref('inbox');
|
||||
const filters = [
|
||||
{id: 'inbox', icon: 'octicon-inbox', label: props.locale.inbox || 'Inbox'},
|
||||
{id: 'sent', icon: 'octicon-paper-airplane', label: props.locale.sent || 'Sent'},
|
||||
{id: 'starred', icon: 'octicon-star', label: props.locale.starred || 'Starred'},
|
||||
{id: 'archive', icon: 'octicon-archive', label: props.locale.archive || 'Archive'},
|
||||
];
|
||||
|
||||
const setFilter = (id: string) => {
|
||||
activeFilter.value = id;
|
||||
};
|
||||
|
||||
// New conversation modal
|
||||
const openNewConv = () => {
|
||||
newConvModalOpen.value = true;
|
||||
};
|
||||
const closeNewConv = () => {
|
||||
newConvModalOpen.value = false;
|
||||
newConvParticipants.value = '';
|
||||
newConvSubject.value = '';
|
||||
newConvMessage.value = '';
|
||||
};
|
||||
const createConversation = async () => {
|
||||
if (!newConvParticipants.value.trim()) return;
|
||||
try {
|
||||
const resp = await POST(`${appSubUrl}/api/v1/mail/conversations`, {
|
||||
body: {
|
||||
participants: newConvParticipants.value,
|
||||
subject: newConvSubject.value || props.locale.newConversation || 'New conversation',
|
||||
body: newConvMessage.value,
|
||||
},
|
||||
});
|
||||
if (resp.ok) {
|
||||
await fetchConversations();
|
||||
closeNewConv();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-resize textarea
|
||||
const onComposeInput = (e: Event) => {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = `${Math.min(ta.scrollHeight, 120)}px`;
|
||||
};
|
||||
|
||||
// Compose via Ctrl+Enter
|
||||
const onComposeKeydown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for new messages every 5 seconds when a conversation is open
|
||||
const startPolling = () => {
|
||||
setInterval(async () => {
|
||||
if (!activeConversation.value) return;
|
||||
const lastId = messages.value.length > 0 ? messages.value[messages.value.length - 1].id : 0;
|
||||
try {
|
||||
const resp = await GET(`${appSubUrl}/mail/${activeConversation.value.id}/poll?since=${lastId}`);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
for (const m of data.messages) {
|
||||
messages.value.push({...m, conversation_id: activeConversation.value!.id});
|
||||
}
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// Use server-rendered initial data instead of fetching
|
||||
try {
|
||||
conversations.value = JSON.parse(props.initialConversations || '[]');
|
||||
} catch {
|
||||
conversations.value = [];
|
||||
}
|
||||
|
||||
// If there's an active conversation passed from server, load its messages directly
|
||||
if (props.initialConversationId > 0) {
|
||||
const conv = conversations.value.find(c => c.id === props.initialConversationId);
|
||||
if (conv) {
|
||||
activeConversation.value = conv;
|
||||
try {
|
||||
messages.value = JSON.parse(props.initialMessages || '[]');
|
||||
} catch {
|
||||
messages.value = [];
|
||||
}
|
||||
await nextTick();
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to refresh from API after a moment (in case data changed)
|
||||
setTimeout(() => fetchConversations(), 2000);
|
||||
|
||||
startPolling();
|
||||
});
|
||||
|
||||
return {
|
||||
conversations, activeConversation, messages, searchQuery, composeText,
|
||||
loading, activeFilter, filters, newConvModalOpen, newConvParticipants,
|
||||
newConvSubject, newConvMessage,
|
||||
avatarStyle, initials, formatDate,
|
||||
selectConversation, sendMessage, setFilter,
|
||||
openNewConv, closeNewConv, createConversation,
|
||||
onComposeInput, onComposeKeydown,
|
||||
currentUserEmail: props.currentUserEmail,
|
||||
locale: props.locale,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="messenger-root">
|
||||
<!-- Left panel: thread list -->
|
||||
<aside class="messenger-sidebar">
|
||||
<!-- Search row -->
|
||||
<div class="messenger-search-row">
|
||||
<div class="messenger-search-input">
|
||||
<SvgIcon name="octicon-search" :size="14"/>
|
||||
<input v-model="searchQuery" type="text" :placeholder="locale.searchPlaceholder || 'Search...'"/>
|
||||
</div>
|
||||
<button class="messenger-btn-add" @click="openNewConv" :title="locale.newConversation || 'New'">
|
||||
<SvgIcon name="octicon-plus" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Groups label -->
|
||||
<div class="messenger-groups-row">
|
||||
<span class="messenger-groups-label">
|
||||
<SvgIcon name="octicon-organization" :size="12"/>
|
||||
{{ locale.groups || 'Groups' }}
|
||||
</span>
|
||||
<span class="messenger-groups-add"><SvgIcon name="octicon-plus" :size="12"/></span>
|
||||
</div>
|
||||
<!-- Filter pills -->
|
||||
<div class="messenger-pills">
|
||||
<span
|
||||
v-for="f in filters"
|
||||
:key="f.id"
|
||||
class="messenger-pill"
|
||||
:class="{active: activeFilter === f.id}"
|
||||
@click="setFilter(f.id)"
|
||||
>
|
||||
<SvgIcon :name="f.icon" :size="12"/>
|
||||
{{ f.label }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Thread list -->
|
||||
<div class="messenger-thread-list">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="messenger-thread-item"
|
||||
:class="{active: activeConversation?.id === conv.id}"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="messenger-avatar" :style="avatarStyle(conv.participants)">{{ initials(conv.participants) }}</div>
|
||||
<div class="messenger-thread-body">
|
||||
<div class="messenger-thread-top">
|
||||
<span class="messenger-thread-title">{{ conv.subject }}</span>
|
||||
<span class="messenger-thread-time">{{ formatDate(conv.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="messenger-thread-sub">{{ conv.participants }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="conversations.length === 0" class="messenger-empty-list">
|
||||
{{ locale.empty || 'No conversations yet' }}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right panel: chat view -->
|
||||
<main class="messenger-chat">
|
||||
<!-- Empty state -->
|
||||
<div v-if="!activeConversation" class="messenger-chat-empty">
|
||||
<div>
|
||||
<SvgIcon name="octicon-mail" :size="48"/>
|
||||
<p>{{ locale.selectConversation || 'Select a conversation' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active conversation -->
|
||||
<template v-else>
|
||||
<!-- Chat header -->
|
||||
<div class="messenger-chat-header">
|
||||
<div class="messenger-chat-header-avatar">
|
||||
<div class="messenger-avatar" :style="avatarStyle(activeConversation.participants)">{{ initials(activeConversation.participants) }}</div>
|
||||
</div>
|
||||
<div class="messenger-chat-header-info">
|
||||
<div class="messenger-chat-header-title">{{ activeConversation.subject }}</div>
|
||||
<div class="messenger-chat-header-sub">{{ activeConversation.participants }}</div>
|
||||
</div>
|
||||
<div class="messenger-chat-header-actions">
|
||||
<button><SvgIcon name="octicon-star" :size="16"/></button>
|
||||
<button><SvgIcon name="octicon-archive" :size="16"/></button>
|
||||
<button><SvgIcon name="octicon-kebab-horizontal" :size="16"/></button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Messages -->
|
||||
<div class="messenger-messages">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="messenger-msg"
|
||||
:class="{sent: msg.from_addr === currentUserEmail}"
|
||||
>
|
||||
<div class="messenger-msg-avatar messenger-avatar" :style="avatarStyle(msg.from_addr)">{{ initials(msg.from_addr) }}</div>
|
||||
<div class="messenger-msg-content">
|
||||
<div v-if="msg.from_addr !== currentUserEmail" class="messenger-msg-name">{{ msg.from_addr }}</div>
|
||||
<div class="messenger-msg-bubble">{{ msg.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Compose area -->
|
||||
<div class="messenger-compose">
|
||||
<div class="messenger-compose-recipients">
|
||||
<span>{{ locale.replyTo || 'Reply to' }}:</span>
|
||||
<span class="messenger-compose-chip">{{ activeConversation.participants }}</span>
|
||||
</div>
|
||||
<div class="messenger-compose-input-row">
|
||||
<textarea
|
||||
v-model="composeText"
|
||||
class="messenger-compose-textarea"
|
||||
:placeholder="locale.composePlaceholder || 'Type a message...'"
|
||||
rows="1"
|
||||
@input="onComposeInput"
|
||||
@keydown="onComposeKeydown"
|
||||
></textarea>
|
||||
<button class="messenger-compose-send" @click="sendMessage">
|
||||
<SvgIcon name="octicon-paper-airplane" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="messenger-compose-hint">{{ locale.markdownSupported || 'Markdown is supported' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<!-- New conversation modal -->
|
||||
<div v-if="newConvModalOpen" class="messenger-new-conv-overlay" @click="closeNewConv">
|
||||
<div class="messenger-new-conv-modal" @click.stop>
|
||||
<h3>{{ locale.newConversation || 'New conversation' }}</h3>
|
||||
<div class="messenger-new-conv-body">
|
||||
<div>
|
||||
<label>{{ locale.participants || 'Participants (email or username)' }}</label>
|
||||
<input v-model="newConvParticipants" type="text" placeholder="alice@example.com, bob@example.com"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ locale.subject || 'Subject' }}</label>
|
||||
<input v-model="newConvSubject" type="text" :placeholder="locale.subjectPlaceholder || 'Conversation subject...'"/>
|
||||
</div>
|
||||
<div>
|
||||
<label>{{ locale.message || 'Message' }}</label>
|
||||
<textarea v-model="newConvMessage" rows="3" :placeholder="locale.composePlaceholder || 'Type a message...'"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="messenger-new-conv-actions">
|
||||
<button class="ui button" @click="closeNewConv">{{ locale.cancel || 'Cancel' }}</button>
|
||||
<button class="ui primary button" @click="createConversation">{{ locale.send || 'Send' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Full-viewport fill via flex */
|
||||
.messenger-root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Left panel */
|
||||
.messenger-sidebar {
|
||||
width: 340px;
|
||||
min-width: 340px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-menu);
|
||||
border-right: 0.5px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.messenger-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.messenger-search-input {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--color-box-body);
|
||||
border: 0.5px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.messenger-search-input input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
.messenger-btn-add {
|
||||
width: 32px; height: 32px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 0.5px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--color-box-body);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.messenger-btn-add:hover { background: var(--color-hover); }
|
||||
|
||||
.messenger-groups-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 12px 6px;
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
.messenger-groups-label { display: flex; align-items: center; gap: 4px; }
|
||||
.messenger-groups-add { cursor: pointer; opacity: 0.5; }
|
||||
|
||||
.messenger-pills {
|
||||
display: flex; gap: 6px; padding: 0 12px 8px; overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.messenger-pills::-webkit-scrollbar { display: none; }
|
||||
.messenger-pill {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px; border-radius: var(--border-radius-full);
|
||||
font-size: 12px; white-space: nowrap;
|
||||
border: 0.5px solid var(--color-secondary);
|
||||
background: var(--color-box-body); color: var(--color-text-light);
|
||||
cursor: pointer;
|
||||
}
|
||||
.messenger-pill.active {
|
||||
background: var(--color-primary-alpha-20);
|
||||
border-color: var(--color-primary-alpha-40);
|
||||
color: var(--color-primary); font-weight: 600;
|
||||
}
|
||||
|
||||
.messenger-thread-list { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.messenger-thread-item {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; cursor: pointer; border-bottom: 0.5px solid var(--color-secondary);
|
||||
}
|
||||
.messenger-thread-item:hover { background: var(--color-hover); }
|
||||
.messenger-thread-item.active { background: var(--color-active); }
|
||||
.messenger-thread-body { flex: 1; min-width: 0; }
|
||||
.messenger-thread-top { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.messenger-thread-title { font-size: 13px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.messenger-thread-time { font-size: 11px; color: var(--color-text-light); margin-left: 8px; flex-shrink: 0; }
|
||||
.messenger-thread-sub { font-size: 12px; color: var(--color-text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; }
|
||||
.messenger-empty-list { padding: 24px; text-align: center; opacity: 0.5; font-size: 13px; }
|
||||
|
||||
/* Avatars */
|
||||
.messenger-avatar {
|
||||
width: 36px; height: 36px; border-radius: var(--border-radius-full);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Right panel */
|
||||
.messenger-chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--color-body); }
|
||||
.messenger-chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-text-light); text-align: center; }
|
||||
.messenger-chat-empty p { margin-top: 8px; }
|
||||
|
||||
.messenger-chat-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; border-bottom: 0.5px solid var(--color-secondary);
|
||||
}
|
||||
.messenger-chat-header-avatar .messenger-avatar { width: 32px; height: 32px; }
|
||||
.messenger-chat-header-info { flex: 1; min-width: 0; }
|
||||
.messenger-chat-header-title { font-size: 15px; font-weight: 600; }
|
||||
.messenger-chat-header-sub { font-size: 12px; color: var(--color-text-light); }
|
||||
.messenger-chat-header-actions { display: flex; gap: 4px; }
|
||||
.messenger-chat-header-actions button {
|
||||
background: none; border: none; color: var(--color-text-light);
|
||||
cursor: pointer; padding: 6px; border-radius: var(--border-radius);
|
||||
}
|
||||
.messenger-chat-header-actions button:hover { background: var(--color-hover); }
|
||||
|
||||
/* Messages */
|
||||
.messenger-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; min-height: 0; }
|
||||
.messenger-msg { display: flex; gap: 8px; max-width: 75%; }
|
||||
.messenger-msg.sent { align-self: flex-end; flex-direction: row-reverse; }
|
||||
.messenger-msg-avatar { width: 28px; height: 28px; font-size: 11px; }
|
||||
.messenger-msg-content { min-width: 0; }
|
||||
.messenger-msg-name { font-size: 11px; color: var(--color-text-light); margin-bottom: 2px; }
|
||||
.messenger-msg.sent .messenger-msg-name { display: none; }
|
||||
.messenger-msg-bubble {
|
||||
padding: 8px 12px; border-radius: var(--border-radius-medium);
|
||||
font-size: 13px; line-height: 1.45; word-wrap: break-word;
|
||||
background: var(--color-box-body); border: 0.5px solid var(--color-secondary);
|
||||
}
|
||||
.messenger-msg.sent .messenger-msg-bubble {
|
||||
background: var(--color-primary-alpha-20); border-color: var(--color-primary-alpha-30);
|
||||
}
|
||||
|
||||
/* Compose */
|
||||
.messenger-compose { border-top: 0.5px solid var(--color-secondary); padding: 8px 16px; background: var(--color-menu); }
|
||||
.messenger-compose-recipients { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: var(--color-text-light); margin-bottom: 6px; }
|
||||
.messenger-compose-chip { background: var(--color-secondary); border-radius: var(--border-radius-full); padding: 2px 8px; font-size: 11px; }
|
||||
.messenger-compose-input-row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
.messenger-compose-textarea {
|
||||
flex: 1; resize: none; min-height: 36px; max-height: 120px;
|
||||
padding: 8px 12px; border: 0.5px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius-medium); font-size: 13px; line-height: 1.4;
|
||||
background: var(--color-box-body); color: var(--color-text); outline: none;
|
||||
}
|
||||
.messenger-compose-textarea:focus { border-color: var(--color-primary); }
|
||||
.messenger-compose-send {
|
||||
flex-shrink: 0; padding: 8px 16px; background: var(--color-primary);
|
||||
color: var(--color-primary-contrast); border: none;
|
||||
border-radius: var(--border-radius-medium); font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.messenger-compose-send:hover { background: var(--color-primary-hover); }
|
||||
.messenger-compose-hint { font-size: 11px; opacity: 0.5; margin-top: 4px; }
|
||||
|
||||
/* New conversation modal */
|
||||
.messenger-new-conv-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; display: flex; align-items: center; justify-content: center; }
|
||||
.messenger-new-conv-modal { background: var(--color-box-body); border: 0.5px solid var(--color-secondary); border-radius: var(--border-radius-medium); width: 520px; max-width: 90vw; }
|
||||
.messenger-new-conv-modal h3 { padding: 16px; margin: 0; border-bottom: 0.5px solid var(--color-secondary); }
|
||||
.messenger-new-conv-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.messenger-new-conv-body label { font-size: 12px; color: var(--color-text-light); margin-bottom: 4px; display: block; }
|
||||
.messenger-new-conv-body input, .messenger-new-conv-body textarea {
|
||||
width: 100%; padding: 8px 10px; border: 0.5px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius); background: var(--color-body); color: var(--color-text); font-size: 13px; outline: none;
|
||||
}
|
||||
.messenger-new-conv-body input:focus, .messenger-new-conv-body textarea:focus { border-color: var(--color-primary); }
|
||||
.messenger-new-conv-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 0 16px 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, computed, onMounted, onBeforeUnmount} from 'vue';
|
||||
import {SvgIcon} from '../../svg.ts';
|
||||
import {cloneMock, domainOf, type Attachment, type BlockEntry, type BlockScope, type Message, type ForwardedContent, type Thread} from './types.ts';
|
||||
import {otherParticipant} from './threadTitle.ts';
|
||||
import ThreadList from './components/ThreadList.vue';
|
||||
import ChatPane from './components/ChatPane.vue';
|
||||
import ComposePanel from './components/ComposePanel.vue';
|
||||
|
||||
// Root component: holds the in-memory thread store, the responsive two-column
|
||||
// layout (full-screen list / chat on mobile), and wires all child events.
|
||||
export default defineComponent({
|
||||
name: 'MailApp',
|
||||
components: {SvgIcon, ThreadList, ChatPane, ComposePanel},
|
||||
setup() {
|
||||
// All session state lives in memory only; no localStorage/cookies.
|
||||
const threads = ref<Thread[]>(cloneMock());
|
||||
const activeId = ref<string>('');
|
||||
const view = ref<'list' | 'chat'>('list'); // mobile navigation toggle
|
||||
const searchQuery = ref('');
|
||||
const composeOpen = ref(false);
|
||||
|
||||
// Block list (in-memory, mock-only). Stored as a reactive array of entries.
|
||||
const blocked = ref<BlockEntry[]>([]);
|
||||
|
||||
const isMobile = ref(false);
|
||||
function updateMobile() {
|
||||
isMobile.value = window.innerWidth < 640;
|
||||
}
|
||||
updateMobile();
|
||||
|
||||
const activeThread = computed(() => threads.value.find((t) => t.id === activeId.value) ?? null);
|
||||
|
||||
// blockEntryForEmail reports whether an email is blocked, either by exact
|
||||
// address ('user') or by domain ('domain'). Returns the entry or undefined.
|
||||
function blockEntryForEmail(email: string): BlockEntry | undefined {
|
||||
const lower = email.toLowerCase();
|
||||
const dom = domainOf(email);
|
||||
return blocked.value.find((b) => {
|
||||
if (b.scope === 'user') return b.email.toLowerCase() === lower;
|
||||
return b.scope === 'domain' && b.email.toLowerCase() === dom;
|
||||
});
|
||||
}
|
||||
|
||||
function isThreadBlocked(t: Thread): boolean {
|
||||
if (t.isGroup) return false;
|
||||
return !!blockEntryForEmail(otherParticipant(t).email);
|
||||
}
|
||||
|
||||
// blockScopeOf returns the active block scope for a thread's participant,
|
||||
// or null when not blocked. Used to show "Blocked" vs "Blocked (domain)".
|
||||
function blockScopeOf(t: Thread): BlockScope | null {
|
||||
if (t.isGroup) return null;
|
||||
const entry = blockEntryForEmail(otherParticipant(t).email);
|
||||
return entry ? entry.scope : null;
|
||||
}
|
||||
|
||||
function selectThread(id: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t) return;
|
||||
activeId.value = id;
|
||||
t.unread = false;
|
||||
if (isMobile.value) view.value = 'chat';
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
view.value = 'list';
|
||||
}
|
||||
|
||||
function togglePin(id: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (t) t.pinned = !t.pinned;
|
||||
}
|
||||
|
||||
function markUnread(id: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (t) t.unread = true;
|
||||
}
|
||||
|
||||
function deleteThread(id: string) {
|
||||
const idx = threads.value.findIndex((x) => x.id === id);
|
||||
if (idx < 0) return;
|
||||
threads.value.splice(idx, 1);
|
||||
if (activeId.value === id) {
|
||||
activeId.value = '';
|
||||
if (isMobile.value) view.value = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
// blockThread adds a block entry; optionally deletes the conversation and any
|
||||
// other conversation sharing the blocked user/domain.
|
||||
function blockThread(id: string, scope: BlockScope, alsoDelete: boolean) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t || t.isGroup) return;
|
||||
const other = otherParticipant(t);
|
||||
const key = scope === 'domain' ? domainOf(other.email) : other.email;
|
||||
if (!key) return;
|
||||
if (!blocked.value.some((b) => b.scope === scope && b.email === key)) {
|
||||
blocked.value.push({email: key, scope});
|
||||
}
|
||||
if (alsoDelete) {
|
||||
// remove all DM threads whose other participant is now blocked
|
||||
const surviving: Thread[] = [];
|
||||
for (const th of threads.value) {
|
||||
if (th.isGroup) { surviving.push(th); continue; }
|
||||
if (blockEntryForEmail(otherParticipant(th).email)) continue;
|
||||
surviving.push(th);
|
||||
}
|
||||
threads.value = surviving;
|
||||
if (activeId.value && !threads.value.some((x) => x.id === activeId.value)) {
|
||||
activeId.value = '';
|
||||
if (isMobile.value) view.value = 'list';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unblockThread removes both user- and domain-level entries matching the
|
||||
// thread's other participant, so the "Blocked" indicator clears.
|
||||
function unblockThread(id: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t || t.isGroup) return;
|
||||
const other = otherParticipant(t);
|
||||
const dom = domainOf(other.email);
|
||||
blocked.value = blocked.value.filter((b) => {
|
||||
if (b.scope === 'user') return b.email.toLowerCase() !== other.email.toLowerCase();
|
||||
return b.email.toLowerCase() !== dom;
|
||||
});
|
||||
}
|
||||
|
||||
// onForward appends forwarded messages to a target thread. When multiple
|
||||
// messages are selected they are merged into a SINGLE forwarded message:
|
||||
// each source becomes a nested ForwardedContent layer (in selection order),
|
||||
// so the target receives one envelope whose `forwarded` chain contains all
|
||||
// of them. An optional note becomes the envelope's body text. The forwarder
|
||||
// is the current user (self).
|
||||
function onForward(targetId: string, msgs: Message[], note?: string) {
|
||||
const target = threads.value.find((x) => x.id === targetId);
|
||||
if (!target) return;
|
||||
const self = target.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
|
||||
|
||||
// Build forwarded content for each selected message. When multiple
|
||||
// messages are selected they become SIBLINGS in a `quotes` array (flat,
|
||||
// same-level group) rather than a deep nested chain. A single forward
|
||||
// still uses `forwarded` for backward compatibility.
|
||||
const quotes: ForwardedContent[] = msgs.map((m) => ({
|
||||
from: m.sender,
|
||||
subject: m.subject,
|
||||
body: m.body,
|
||||
timestamp: m.timestamp,
|
||||
forwarded: m.forwarded,
|
||||
}));
|
||||
|
||||
const envelope: {forwarded?: ForwardedContent; quotes?: ForwardedContent[]} = {};
|
||||
if (quotes.length === 1) envelope.forwarded = quotes[0];
|
||||
else if (quotes.length > 1) envelope.quotes = quotes;
|
||||
|
||||
target.messages.push({
|
||||
id: 'fwd-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
|
||||
sender: self,
|
||||
kind: 'text',
|
||||
subject: target.subject,
|
||||
body: note || (msgs.length > 1 ? `Forwarded ${msgs.length} messages` : 'Forwarded message'),
|
||||
timestamp: Date.now(),
|
||||
...envelope,
|
||||
});
|
||||
target.unread = false;
|
||||
}
|
||||
|
||||
// onDeleteSelected removes the given message ids from the active thread.
|
||||
function onDeleteSelected(ids: string[]) {
|
||||
const t = activeThread.value;
|
||||
if (!t) return;
|
||||
const set = new Set(ids);
|
||||
t.messages = t.messages.filter((m) => !set.has(m.id));
|
||||
}
|
||||
|
||||
// renameGroup updates a group thread's subject to the new name and pushes a
|
||||
// name-change meta message so the conversation reflects the rename inline.
|
||||
function renameGroup(id: string, newName: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t || !t.isGroup) return;
|
||||
const self = t.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
|
||||
t.messages.push({
|
||||
id: 'rename-' + Date.now(),
|
||||
sender: self,
|
||||
kind: 'name-change',
|
||||
subject: newName,
|
||||
body: newName,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
t.subject = newName;
|
||||
}
|
||||
|
||||
// addMemberToGroup adds a participant to a group thread by email.
|
||||
function addMemberToGroup(id: string, email: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t || !t.isGroup) return;
|
||||
if (t.participants.some((p) => p.email === email)) return;
|
||||
const name = email.slice(0, email.indexOf('@'));
|
||||
t.participants.push({email, name});
|
||||
}
|
||||
|
||||
// removeMemberFromGroup removes a participant from a group thread.
|
||||
function removeMemberFromGroup(id: string, email: string) {
|
||||
const t = threads.value.find((x) => x.id === id);
|
||||
if (!t || !t.isGroup) return;
|
||||
t.participants = t.participants.filter((p) => p.email !== email);
|
||||
}
|
||||
|
||||
function onSend(body: string, attachments?: Attachment[]) {
|
||||
const t = activeThread.value;
|
||||
if (!t) return;
|
||||
const self = t.participants.find((p) => p.self) ?? {email: 'd@m8sh.su', name: 'Danila', self: true};
|
||||
t.messages.push({
|
||||
id: 'msg-' + Date.now(),
|
||||
sender: self,
|
||||
kind: 'text',
|
||||
subject: t.subject,
|
||||
body,
|
||||
timestamp: Date.now(),
|
||||
attachments: attachments && attachments.length ? attachments : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function onCreateThread(newThread: Thread) {
|
||||
threads.value.unshift(newThread);
|
||||
selectThread(newThread.id);
|
||||
composeOpen.value = false;
|
||||
}
|
||||
|
||||
// Close compose on Escape / outside click (outside handled here, inside stops propagation).
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') composeOpen.value = false;
|
||||
}
|
||||
function onWindowClick() {
|
||||
composeOpen.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', updateMobile);
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateMobile);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
return {
|
||||
threads, activeId, activeThread, view, searchQuery, composeOpen, isMobile,
|
||||
selectThread, goBack, togglePin, markUnread, deleteThread, onSend, onCreateThread,
|
||||
onWindowClick, blocked, isThreadBlocked, blockScopeOf, blockThread, unblockThread,
|
||||
onForward, onDeleteSelected,
|
||||
renameGroup,
|
||||
addMemberToGroup, removeMemberFromGroup,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mail-app" :class="{'view-chat': view === 'chat'}" @click="onWindowClick">
|
||||
<aside class="left-panel">
|
||||
<div class="search-bar" @click.stop>
|
||||
<input v-model="searchQuery" class="search-input" placeholder="Filter conversations…">
|
||||
<i
|
||||
class="compose-toggle"
|
||||
:class="{open: composeOpen}"
|
||||
:title="composeOpen ? 'Close composer' : 'New conversation'"
|
||||
@click="composeOpen = !composeOpen"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-up" :size="22"/>
|
||||
</i>
|
||||
</div>
|
||||
|
||||
<ComposePanel v-if="composeOpen" @create="onCreateThread" @close="composeOpen = false"/>
|
||||
|
||||
<ThreadList
|
||||
:threads="threads"
|
||||
:active-id="activeId"
|
||||
:query="searchQuery"
|
||||
:blocked="blocked"
|
||||
:is-thread-blocked="isThreadBlocked"
|
||||
:block-scope-of="blockScopeOf"
|
||||
@select="selectThread"
|
||||
@toggle-pin="togglePin"
|
||||
@mark-unread="markUnread"
|
||||
@delete="deleteThread"
|
||||
@block="blockThread"
|
||||
@unblock="unblockThread"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="right-panel">
|
||||
<ChatPane
|
||||
v-if="activeThread"
|
||||
:key="activeThread.id"
|
||||
:thread="activeThread"
|
||||
:is-mobile="isMobile"
|
||||
:all-threads="threads"
|
||||
@back="goBack"
|
||||
@toggle-pin="togglePin(activeThread!.id)"
|
||||
@delete="deleteThread(activeThread!.id)"
|
||||
@send="onSend"
|
||||
@forward="onForward"
|
||||
@delete-selected="onDeleteSelected"
|
||||
@rename-group="renameGroup(activeThread!.id, $event)"
|
||||
@add-member="addMemberToGroup(activeThread!.id, $event)"
|
||||
@remove-member="removeMemberFromGroup(activeThread!.id, $event)"
|
||||
/>
|
||||
<div v-else class="empty-chat">
|
||||
<SvgIcon name="octicon-mail" :size="32"/>
|
||||
<p>Select a conversation to start reading</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-app {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--color-body);
|
||||
}
|
||||
|
||||
/* Desktop: two columns side by side */
|
||||
.left-panel {
|
||||
width: 272px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 0.5px solid var(--color-secondary-alpha-30);
|
||||
background: var(--color-body);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 7px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.compose-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-light-2);
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
transform: rotate(180deg); /* chevron-up pointing down = "closed, tap to fold open" */
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.compose-toggle.open {
|
||||
transform: rotate(0deg); /* pointing up = "open, tap to fold back up" */
|
||||
}
|
||||
|
||||
.compose-toggle:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.empty-chat {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Mobile: stack and slide */
|
||||
@media (max-width: 639.98px) {
|
||||
.mail-app {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left-panel,
|
||||
.right-panel {
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.mail-app.view-chat .left-panel {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.mail-app.view-chat .right-panel {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,435 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, ref, computed, watch, onMounted, onBeforeUnmount} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {Attachment} from '../types.ts';
|
||||
|
||||
// Floating audio player bar. Layout: prev/play/next | [name / seek] | download/close
|
||||
export default defineComponent({
|
||||
name: 'AudioPlayer',
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
tracks: {type: Array as PropType<Attachment[]>, required: true},
|
||||
startIndex: {type: Number, default: 0},
|
||||
},
|
||||
emits: ['close', 'playing-track'],
|
||||
setup(props, {emit}) {
|
||||
const audio = ref<HTMLAudioElement | null>(null);
|
||||
const index = ref(props.startIndex);
|
||||
const playing = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const duration = ref(0);
|
||||
|
||||
const current = computed(() => props.tracks[index.value] ?? null);
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!duration.value) return 0;
|
||||
return Math.min(100, (currentTime.value / duration.value) * 100);
|
||||
});
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!s || isNaN(s)) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const el = audio.value;
|
||||
if (!el) return;
|
||||
if (playing.value) { el.pause(); playing.value = false; }
|
||||
else { el.play().then(() => { playing.value = true; }).catch(() => {}); }
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (props.tracks.length === 0) return;
|
||||
index.value = (index.value - 1 + props.tracks.length) % props.tracks.length;
|
||||
currentTime.value = 0;
|
||||
}
|
||||
function next() {
|
||||
if (props.tracks.length === 0) return;
|
||||
index.value = (index.value + 1) % props.tracks.length;
|
||||
currentTime.value = 0;
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
const el = audio.value;
|
||||
if (!el) return;
|
||||
currentTime.value = el.currentTime;
|
||||
}
|
||||
function onLoaded() {
|
||||
const el = audio.value;
|
||||
if (!el) return;
|
||||
duration.value = el.duration;
|
||||
}
|
||||
function onEnded() {
|
||||
playing.value = false;
|
||||
if (index.value < props.tracks.length - 1) {
|
||||
next();
|
||||
setTimeout(() => {
|
||||
const el2 = audio.value;
|
||||
if (el2) { el2.play().then(() => { playing.value = true; }).catch(() => {}); }
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
function onSeek(e: Event) {
|
||||
const el = audio.value;
|
||||
if (!el) return;
|
||||
const val = Number((e.target as HTMLInputElement).value);
|
||||
el.currentTime = val;
|
||||
currentTime.value = val;
|
||||
}
|
||||
|
||||
// downloadTrack: create a temporary <a> to trigger download without the
|
||||
// hover lag that <a data-href> causes with large base64 data URLs.
|
||||
function downloadTrack() {
|
||||
const t = current.value;
|
||||
if (!t) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = t.url;
|
||||
a.download = t.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
// onSeekClick: click anywhere on the seek bar to jump to that position.
|
||||
function onSeekClick(e: MouseEvent) {
|
||||
const el = audio.value;
|
||||
if (!el || !duration.value) return;
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const val = pct * duration.value;
|
||||
el.currentTime = val;
|
||||
currentTime.value = val;
|
||||
}
|
||||
|
||||
// Emit the current track id so the gallery can highlight it
|
||||
function emitTrack() {
|
||||
const t = current.value;
|
||||
if (t) emit('playing-track', t.id);
|
||||
}
|
||||
|
||||
watch(index, () => {
|
||||
currentTime.value = 0;
|
||||
playing.value = false;
|
||||
emitTrack();
|
||||
setTimeout(() => {
|
||||
const el = audio.value;
|
||||
if (el) {
|
||||
el.load();
|
||||
el.play().then(() => { playing.value = true; }).catch(() => {});
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Auto-play on mount — the watch above only fires on changes, not initial value
|
||||
onMounted(() => {
|
||||
emitTrack();
|
||||
setTimeout(() => {
|
||||
const el = audio.value;
|
||||
if (el) {
|
||||
el.load();
|
||||
el.play().then(() => { playing.value = true; }).catch(() => {});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Watch the startIndex prop so clicking a different audio tile switches the
|
||||
// player to that track even when the player is already open.
|
||||
watch(() => props.startIndex, (newIdx) => {
|
||||
if (newIdx !== index.value) {
|
||||
index.value = newIdx;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (audio.value) {
|
||||
audio.value.pause();
|
||||
audio.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
audio, index, current, playing, currentTime, duration, progress,
|
||||
formatTime, togglePlay, prev, next,
|
||||
onTimeUpdate, onLoaded, onEnded, onSeek, onSeekClick, emitTrack, downloadTrack,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audio-player">
|
||||
<audio
|
||||
ref="audio"
|
||||
:src="current?.url"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@loadedmetadata="onLoaded"
|
||||
@ended="onEnded"
|
||||
@canplay="onLoaded"
|
||||
/>
|
||||
|
||||
<div class="ap-controls">
|
||||
<!-- Left: prev/play/next -->
|
||||
<div class="ap-buttons">
|
||||
<button v-if="tracks.length > 1" type="button" class="ap-btn" title="Previous" style="transition:none!important" @click="prev">
|
||||
<SvgIcon name="octicon-triangle-down" :size="20" class="ap-prev-icon"/>
|
||||
</button>
|
||||
<button type="button" class="ap-play-btn" :title="playing ? 'Pause' : 'Play'" @click="togglePlay">
|
||||
<span v-if="playing" class="ap-icon-stop"/>
|
||||
<span v-else class="ap-icon-play"/>
|
||||
</button>
|
||||
<button v-if="tracks.length > 1" type="button" class="ap-btn" title="Next" style="transition:none!important" @click="next">
|
||||
<SvgIcon name="octicon-triangle-down" :size="20" class="ap-next-icon"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Center: name on top, seek bar below -->
|
||||
<div class="ap-center">
|
||||
<span class="ap-name">{{ current?.name ?? '' }}</span>
|
||||
<div class="ap-seek-row">
|
||||
<span class="ap-time">{{ formatTime(currentTime) }}</span>
|
||||
<div class="ap-seek-wrap" @click="onSeekClick">
|
||||
<div class="ap-seek-track">
|
||||
<div class="ap-seek-fill" :style="{width: progress + '%'}"/>
|
||||
<div class="ap-seek-handle" :style="{left: progress + '%'}"/>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ap-time">{{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download (left on mobile) -->
|
||||
<div class="ap-download-wrap">
|
||||
<button v-if="current" type="button" class="ap-btn" title="Download" style="transition:none!important" @click="downloadTrack">
|
||||
<SvgIcon name="octicon-download" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Close (right always) -->
|
||||
<div class="ap-close-wrap">
|
||||
<button type="button" class="ap-btn" title="Close" style="transition:none!important" @click="$emit('close')">
|
||||
<SvgIcon name="octicon-x" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audio-player,
|
||||
.audio-player *,
|
||||
.audio-player *::before,
|
||||
.audio-player *::after {
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
display: flex;
|
||||
padding: 8px 14px;
|
||||
background: var(--color-secondary-bg);
|
||||
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ap-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Left buttons group */
|
||||
.ap-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Center: name + seek stacked */
|
||||
.ap-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ap-name {
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ap-seek-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ap-download-wrap {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ap-close-wrap {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ap-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
transition-duration: 0s !important;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ap-btn:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
.ap-prev-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ap-next-icon {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Play button: bare triangle, no circle */
|
||||
.ap-play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
transition-duration: 0s !important;
|
||||
color: var(--color-white);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition-duration: 0s !important;
|
||||
}
|
||||
|
||||
.ap-play-btn:hover {
|
||||
background: var(--color-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ap-icon-play {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 8px 0 8px 12px;
|
||||
border-color: transparent transparent transparent var(--color-white);
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.ap-icon-stop {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ap-icon-stop::before,
|
||||
.ap-icon-stop::after {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 16px;
|
||||
background: var(--color-white);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ap-time {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Seek bar with blue progress fill */
|
||||
.ap-seek-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ap-seek-track {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-full);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ap-seek-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
|
||||
.ap-seek-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-primary);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Mobile: download on far left, close on far right, controls in between */
|
||||
@media (max-width: 639.98px) {
|
||||
.ap-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.ap-download-wrap {
|
||||
order: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ap-buttons {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
margin: 0 auto; /* center the prev/play/next group */
|
||||
}
|
||||
.ap-close-wrap {
|
||||
order: 3;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ap-center {
|
||||
flex-basis: 100%;
|
||||
order: 10;
|
||||
margin-top: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, computed, ref} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import {useAvatarResolver, DEFAULT_AVATAR_URL} from '../useAvatarResolver.ts';
|
||||
|
||||
// Renders an avatar for an email or a group. Always renders immediately with
|
||||
// the fallback letter/icon circle and reactively swaps to an <img> once the
|
||||
// avatar URL resolves. When resolution fails, the instance default gopher avatar is shown.
|
||||
export default defineComponent({
|
||||
name: 'AvatarCircle',
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
email: {type: String, default: ''}, // for DM participants
|
||||
name: {type: String, default: ''}, // display name, falls back to localpart
|
||||
isGroup: {type: Boolean, default: false},
|
||||
size: {type: Number, default: 36}, // px
|
||||
},
|
||||
setup(props) {
|
||||
const {get, resolve} = useAvatarResolver();
|
||||
|
||||
// Kick off resolution on first render for an email (idempotent, non-blocking).
|
||||
if (props.email && !props.isGroup) resolve(props.email);
|
||||
|
||||
const hue = computed(() => {
|
||||
const source = (props.email || props.name || '?').toLowerCase();
|
||||
let sum = 0;
|
||||
for (let i = 0; i < source.length; i++) sum += source.charCodeAt(i);
|
||||
return sum % 360;
|
||||
});
|
||||
|
||||
const letter = computed(() => {
|
||||
const label = props.name || localpart(props.email) || '?';
|
||||
return label.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
const imgError = ref(false);
|
||||
|
||||
// The URL to render: real avatar if resolved, instance default gopher on fallback.
|
||||
// '' from the resolver means "resolved-to-fallback" -> use the default gopher image.
|
||||
const resolvedUrl = computed(() => {
|
||||
if (props.isGroup || imgError.value) return undefined;
|
||||
const url = get(props.email);
|
||||
if (url === undefined) return undefined; // still pending -> letter circle
|
||||
return url || DEFAULT_AVATAR_URL; // '' -> default gopher
|
||||
});
|
||||
|
||||
return {hue, letter, resolvedUrl, imgError};
|
||||
},
|
||||
});
|
||||
|
||||
function localpart(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
return at < 0 ? email : email.slice(0, at);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="avatar-circle"
|
||||
:class="{group: isGroup}"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
fontSize: Math.round(size * 0.42) + 'px',
|
||||
background: isGroup ? `hsl(${hue}, 55%, 45%)` : resolvedUrl ? undefined : `hsl(${hue}, 55%, 45%)`,
|
||||
}"
|
||||
:aria-label="isGroup ? name : (name || email)"
|
||||
role="img"
|
||||
>
|
||||
<SvgIcon v-if="isGroup" name="octicon-organization" :size="Math.round(size * 0.5)"/>
|
||||
<img
|
||||
v-else-if="resolvedUrl"
|
||||
:src="resolvedUrl"
|
||||
:alt="name || email"
|
||||
class="avatar-img"
|
||||
@error="imgError = true"
|
||||
>
|
||||
<span v-else class="avatar-letter">{{ letter }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-circle {
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--border-radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.avatar-circle.group {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-letter {
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, ref, computed, onMounted, onBeforeUnmount} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {Thread} from '../types.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
type Tab = 'direct' | 'group';
|
||||
|
||||
// Compose panel: toggled below the search bar. Closes on Escape or outside click.
|
||||
// Emits "create" with a fully-formed Thread to be inserted into the list, and "close".
|
||||
export default defineComponent({
|
||||
name: 'ComposePanel',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
emits: ['create', 'close'],
|
||||
setup(_props, {emit}) {
|
||||
const tab = ref<Tab>('direct');
|
||||
|
||||
// Direct tab state
|
||||
const directEmail = ref('');
|
||||
const directBody = ref('');
|
||||
const directSubject = ref('');
|
||||
const editingSubject = ref(false);
|
||||
|
||||
const autoSubject = computed(() => {
|
||||
const first = directBody.value.trim().split(/[.!?\n]/)[0] ?? '';
|
||||
return first.slice(0, 60).trim();
|
||||
});
|
||||
const effectiveSubject = computed(() => directSubject.value || autoSubject.value);
|
||||
|
||||
// Group tab state
|
||||
const groupName = ref('');
|
||||
const groupEmailInput = ref('');
|
||||
const groupChips = ref<string[]>([]);
|
||||
const groupBody = ref('');
|
||||
|
||||
function setTab(t: Tab) {
|
||||
tab.value = t;
|
||||
}
|
||||
|
||||
function autoGrow(e: Event) {
|
||||
const el = e.target as HTMLTextAreaElement;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 140) + 'px';
|
||||
}
|
||||
|
||||
function commitSubjectIfEditing() {
|
||||
editingSubject.value = false;
|
||||
}
|
||||
|
||||
function addChip() {
|
||||
// Trim and strip any trailing delimiter (comma, semicolon, space) the user may have typed
|
||||
const v = groupEmailInput.value.trim().replace(/[,;\s]+$/, '');
|
||||
if (v && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !groupChips.value.includes(v)) {
|
||||
groupChips.value.push(v);
|
||||
}
|
||||
groupEmailInput.value = '';
|
||||
}
|
||||
|
||||
function onGroupInputKeydown(e: KeyboardEvent) {
|
||||
// Delimiters that commit the current input as a chip
|
||||
if (e.key === 'Enter' || e.key === ',' || e.key === ';' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
addChip();
|
||||
} else if (e.key === 'Backspace' && groupEmailInput.value === '' && groupChips.value.length) {
|
||||
groupChips.value.pop();
|
||||
}
|
||||
}
|
||||
|
||||
function removeChip(idx: number) {
|
||||
groupChips.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
function createDirect() {
|
||||
const email = directEmail.value.trim();
|
||||
if (!email || !directBody.value.trim()) return;
|
||||
const name = email.slice(0, email.indexOf('@'));
|
||||
const thread: Thread = {
|
||||
id: 'dm-' + email.toLowerCase() + '-' + Date.now(),
|
||||
isGroup: false,
|
||||
subject: effectiveSubject.value || 'New conversation',
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants: [
|
||||
{email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
{email, name},
|
||||
],
|
||||
messages: [{
|
||||
id: 'c-' + Date.now(),
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: effectiveSubject.value,
|
||||
body: directBody.value.trim(),
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
};
|
||||
emit('create', thread);
|
||||
reset();
|
||||
}
|
||||
|
||||
function createGroup() {
|
||||
if (!groupName.value.trim() || groupChips.value.length === 0 || !groupBody.value.trim()) return;
|
||||
const participants = [
|
||||
{email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
...groupChips.value.map((email) => ({email, name: email.slice(0, email.indexOf('@'))})),
|
||||
];
|
||||
const thread: Thread = {
|
||||
id: 'group-' + Date.now(),
|
||||
isGroup: true,
|
||||
subject: groupName.value.trim(),
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants,
|
||||
messages: [{
|
||||
id: 'c-' + Date.now(),
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: groupName.value.trim(),
|
||||
body: groupBody.value.trim(),
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
};
|
||||
emit('create', thread);
|
||||
reset();
|
||||
}
|
||||
|
||||
function reset() {
|
||||
directEmail.value = '';
|
||||
directBody.value = '';
|
||||
directSubject.value = '';
|
||||
editingSubject.value = false;
|
||||
groupName.value = '';
|
||||
groupEmailInput.value = '';
|
||||
groupChips.value = [];
|
||||
groupBody.value = '';
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// Close on Escape; outside click handled by parent via backdrop.
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close');
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown));
|
||||
|
||||
return {
|
||||
tab, setTab,
|
||||
directEmail, directBody, directSubject, editingSubject,
|
||||
autoSubject, effectiveSubject, commitSubjectIfEditing,
|
||||
groupName, groupEmailInput, groupChips, groupBody,
|
||||
autoGrow, addChip, onGroupInputKeydown, removeChip,
|
||||
createDirect, createGroup, reset,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compose-panel" @click.stop>
|
||||
<div class="compose-tabs">
|
||||
<button type="button" class="compose-tab" :class="{active: tab === 'direct'}" @click="setTab('direct')">
|
||||
Direct
|
||||
</button>
|
||||
<button type="button" class="compose-tab" :class="{active: tab === 'group'}" @click="setTab('group')">
|
||||
Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'direct'" class="compose-body">
|
||||
<input v-model="directEmail" class="compose-input" type="email" placeholder="To: email address">
|
||||
|
||||
<div class="compose-subject">
|
||||
<template v-if="!editingSubject">
|
||||
<span
|
||||
v-if="effectiveSubject"
|
||||
class="subject-preview"
|
||||
title="Click to edit"
|
||||
@click="editingSubject = true; directSubject = effectiveSubject"
|
||||
>Subject: {{ effectiveSubject }}</span>
|
||||
</template>
|
||||
<input
|
||||
v-else
|
||||
v-model="directSubject"
|
||||
class="compose-input subject-input"
|
||||
placeholder="Subject"
|
||||
@blur="commitSubjectIfEditing"
|
||||
@keydown.enter="commitSubjectIfEditing"
|
||||
>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="directBody"
|
||||
class="compose-textarea"
|
||||
placeholder="Write a message…"
|
||||
rows="2"
|
||||
@input="autoGrow"
|
||||
/>
|
||||
|
||||
<div class="compose-actions">
|
||||
<button type="button" class="compose-send" :disabled="!directEmail || !directBody" @click="createDirect">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="compose-body">
|
||||
<input v-model="groupName" class="compose-input" placeholder="Group name">
|
||||
|
||||
<div class="chip-field">
|
||||
<span v-for="(chip, idx) in groupChips" :key="chip" class="chip">
|
||||
<AvatarCircle :email="chip" :size="16"/>
|
||||
<span class="chip-email">{{ chip }}</span>
|
||||
<button type="button" class="chip-remove" :title="'Remove ' + chip" @click="removeChip(idx)">
|
||||
<SvgIcon name="octicon-x" :size="12"/>
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
v-model="groupEmailInput"
|
||||
class="chip-input"
|
||||
type="email"
|
||||
placeholder="Add member email (Enter, comma, semicolon, space)"
|
||||
@keydown="onGroupInputKeydown"
|
||||
@blur="addChip"
|
||||
>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="groupBody"
|
||||
class="compose-textarea"
|
||||
placeholder="Write a message…"
|
||||
rows="2"
|
||||
@input="autoGrow"
|
||||
/>
|
||||
|
||||
<div class="compose-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="compose-send"
|
||||
:disabled="!groupName || !groupChips.length || !groupBody"
|
||||
@click="createGroup"
|
||||
>
|
||||
Create group
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compose-panel {
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
background: var(--color-body);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compose-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.compose-tab {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
font-size: 13px;
|
||||
padding: 6px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compose-tab.active {
|
||||
background: var(--color-secondary-bg);
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.compose-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compose-input,
|
||||
.compose-textarea {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.compose-input:focus,
|
||||
.compose-textarea:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chip-input {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
font-size: 13px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.compose-textarea {
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
min-height: 36px;
|
||||
max-height: 140px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.compose-subject {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.subject-preview {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--color-text-light-2);
|
||||
cursor: text;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.subject-input {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chip-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.chip-field:focus-within {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-full);
|
||||
padding: 1px 4px 1px 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-email {
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
display: inline-flex;
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
|
||||
.chip-remove:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.compose-send {
|
||||
border: none;
|
||||
background: hsl(214, 70%, 93%);
|
||||
color: hsl(214, 70%, 30%);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: 6px 14px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compose-send:disabled {
|
||||
opacity: var(--opacity-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, ref, nextTick} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {Thread} from '../types.ts';
|
||||
import {threadDisplayName, threadAvatarEmail} from '../threadTitle.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
// Modal dialog for picking a target thread to forward selected messages into.
|
||||
// Two-step: first pick a target, then (optionally) add a note that accompanies
|
||||
// the forwarded content. Emits `pick(target, note)` with the chosen target and
|
||||
// note, or `close` to cancel.
|
||||
export default defineComponent({
|
||||
name: 'ForwardDialog',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
props: {
|
||||
threads: {type: Array as PropType<Thread[]>, required: true},
|
||||
currentId: {type: String, default: ''},
|
||||
selectedCount: {type: Number, default: 0},
|
||||
},
|
||||
emits: ['pick', 'close'],
|
||||
setup(_, {emit}) {
|
||||
const query = ref('');
|
||||
const step = ref<'target' | 'note'>('target');
|
||||
const chosen = ref<Thread | null>(null);
|
||||
const note = ref('');
|
||||
const noteInput = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
function chooseTarget(t: Thread) {
|
||||
chosen.value = t;
|
||||
step.value = 'note';
|
||||
nextTick(() => noteInput.value?.focus());
|
||||
}
|
||||
function backToTargets() {
|
||||
step.value = 'target';
|
||||
chosen.value = null;
|
||||
}
|
||||
function confirm() {
|
||||
if (!chosen.value) return;
|
||||
emit('pick', chosen.value, note.value.trim());
|
||||
}
|
||||
return {query, step, chosen, note, noteInput, chooseTarget, backToTargets, confirm};
|
||||
},
|
||||
computed: {
|
||||
targets(): Thread[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
return this.threads.filter((t) => {
|
||||
if (t.id === this.currentId) return false;
|
||||
if (!q) return true;
|
||||
return threadDisplayName(t).toLowerCase().includes(q);
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
nameOf: threadDisplayName,
|
||||
avatarOf: threadAvatarEmail,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fwd-overlay" @click.self="$emit('close')">
|
||||
<div class="fwd-modal">
|
||||
<!-- Step 1: pick target -->
|
||||
<template v-if="step === 'target'">
|
||||
<header class="fwd-modal-head">
|
||||
<div class="fwd-modal-title">
|
||||
<SvgIcon name="octicon-link" :size="16"/>
|
||||
Forward {{ selectedCount }} to
|
||||
</div>
|
||||
<button type="button" class="head-icon" title="Cancel" @click="$emit('close')">
|
||||
<SvgIcon name="octicon-x" :size="18"/>
|
||||
</button>
|
||||
</header>
|
||||
<div class="fwd-modal-search">
|
||||
<SvgIcon name="octicon-search" :size="16" class="fwd-search-icon"/>
|
||||
<input v-model="query" type="text" class="fwd-search-input" placeholder="Filter conversations…">
|
||||
</div>
|
||||
<div class="fwd-modal-list">
|
||||
<button
|
||||
v-for="t in targets" :key="t.id"
|
||||
type="button"
|
||||
class="fwd-target"
|
||||
@click="chooseTarget(t)"
|
||||
>
|
||||
<AvatarCircle :email="avatarOf(t)" :name="nameOf(t)" :is-group="t.isGroup" :size="32"/>
|
||||
<div class="fwd-target-name">{{ nameOf(t) }}</div>
|
||||
<SvgIcon name="octicon-chevron-right" :size="14" class="fwd-target-chev"/>
|
||||
</button>
|
||||
<div v-if="!targets.length" class="fwd-empty">No conversations to forward to</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: add a note -->
|
||||
<template v-else>
|
||||
<header class="fwd-modal-head">
|
||||
<button type="button" class="head-icon" title="Back" @click="backToTargets">
|
||||
<SvgIcon name="octicon-arrow-left" :size="18"/>
|
||||
</button>
|
||||
<div class="fwd-modal-title">
|
||||
<SvgIcon name="octicon-link" :size="16"/>
|
||||
Add a note
|
||||
</div>
|
||||
<button type="button" class="head-icon" title="Cancel" @click="$emit('close')">
|
||||
<SvgIcon name="octicon-x" :size="18"/>
|
||||
</button>
|
||||
</header>
|
||||
<div class="fwd-note-body">
|
||||
<div class="fwd-note-target">
|
||||
<AvatarCircle
|
||||
v-if="chosen"
|
||||
:email="avatarOf(chosen)"
|
||||
:name="nameOf(chosen)"
|
||||
:is-group="chosen.isGroup"
|
||||
:size="24"
|
||||
/>
|
||||
<span class="fwd-note-target-name">{{ chosen ? nameOf(chosen) : '' }}</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref="noteInput"
|
||||
v-model="note"
|
||||
class="fwd-note-input"
|
||||
placeholder="Add a message to accompany the forwarded content…"
|
||||
rows="4"
|
||||
/>
|
||||
</div>
|
||||
<div class="fwd-note-foot">
|
||||
<button type="button" class="fwd-send" @click="confirm">
|
||||
<SvgIcon name="octicon-paper-airplane" :size="16"/>
|
||||
Forward
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fwd-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1003;
|
||||
background: var(--color-overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.fwd-modal {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-menu);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fwd-modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.fwd-modal-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.head-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.head-icon:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fwd-modal-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.fwd-search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.fwd-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fwd-search-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fwd-modal-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.fwd-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius);
|
||||
text-align: left;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.fwd-target:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.fwd-target-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fwd-target-chev {
|
||||
color: var(--color-text-light-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fwd-empty {
|
||||
padding: 24px 14px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
/* Note step */
|
||||
.fwd-note-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.fwd-note-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.fwd-note-target-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fwd-note-input {
|
||||
resize: none;
|
||||
font-family: var(--fonts-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: 8px 10px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fwd-note-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fwd-note-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px 12px;
|
||||
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.fwd-send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
cursor: pointer;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.fwd-send:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import {type ForwardedContent, forwardedDepth, MAX_FORWARD_DEPTH} from '../types.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
// Recursive forwarded-content renderer. Order is reversed vs. a flat thread:
|
||||
// the NESTED forwarded content renders on top, then this layer's body below it.
|
||||
// Each layer indents by a left vertical hairline (so a forward-of-a-forward shows
|
||||
// two nested hairlines, etc.). Up to MAX_FORWARD_DEPTH layers render fully; the
|
||||
// first layer beyond the limit renders as a static, non-clickable mention noting
|
||||
// how many nested layers are folded. It does NOT unfold or link anywhere.
|
||||
export default defineComponent({
|
||||
name: 'ForwardedBubble',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
props: {
|
||||
content: {type: Object as PropType<ForwardedContent>, required: true},
|
||||
depth: {type: Number, default: 1}, // 1-based: the outermost forward is depth 1
|
||||
},
|
||||
emits: ['jump'],
|
||||
setup(props) {
|
||||
// A layer beyond the visible depth limit renders as a single reference card.
|
||||
const overLimit = props.depth > MAX_FORWARD_DEPTH;
|
||||
const extraLayers = overLimit ? forwardedDepth(props.content) : 0;
|
||||
return {overLimit, extraLayers, MAX_FORWARD_DEPTH};
|
||||
},
|
||||
computed: {
|
||||
fromName(): string {
|
||||
return this.content.from.name || this.content.from.email;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fwd" :class="['fwd-depth-' + depth]">
|
||||
<!-- Static mention (non-clickable) for layers beyond the visible depth limit.
|
||||
It just notes how many nested layers are folded; no author, no jump. -->
|
||||
<div v-if="overLimit" class="fwd-ref">
|
||||
<span class="fwd-ref-text">
|
||||
{{ extraLayers }} more forwarded layer{{ extraLayers === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Full render of an in-limit layer: nested forwarded content first, then body. -->
|
||||
<template v-else>
|
||||
<!-- Recurse into the nested forwarded content FIRST (it renders on top). -->
|
||||
<ForwardedBubble
|
||||
v-if="content.forwarded"
|
||||
:content="content.forwarded"
|
||||
:depth="depth + 1"
|
||||
@jump="(c: ForwardedContent) => $emit('jump', c)"
|
||||
/>
|
||||
|
||||
<!-- This layer's header + body (below the nested chain). -->
|
||||
<div class="fwd-layer">
|
||||
<div class="fwd-header">
|
||||
<AvatarCircle :email="content.from.email" :name="fromName" :size="20"/>
|
||||
<div class="fwd-meta">
|
||||
<span class="fwd-from">Forwarded from {{ fromName }}</span>
|
||||
<span class="fwd-subject" v-if="content.subject">{{ content.subject }}</span>
|
||||
</div>
|
||||
<span class="fwd-time">{{ formatTime(content.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="fwd-body">{{ content.body }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fwd {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* Each nesting level adds a left vertical hairline via border, so a
|
||||
forward-of-a-forward shows two nested lines, etc. */
|
||||
border-left: 2px solid var(--color-secondary-alpha-30);
|
||||
padding-left: 10px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Deeper nesting renders the hairline slightly fainter to keep hierarchy readable. */
|
||||
.fwd-depth-2 {
|
||||
border-left-color: var(--color-secondary-alpha-30);
|
||||
opacity: 0.96;
|
||||
}
|
||||
|
||||
.fwd-layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fwd-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.fwd-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.fwd-from {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.fwd-subject {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fwd-time {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fwd-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
padding: 8px 10px;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Static mention for layers beyond MAX_FORWARD_DEPTH. Non-clickable. */
|
||||
.fwd-ref {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.fwd-ref-text {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, computed, ref} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {Message} from '../types.ts';
|
||||
import {sanitizeHtml} from '../sanitize.ts';
|
||||
import {highlightHtml} from '../highlight.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
// Full-width HTML email card. Header bar with sender + timestamp + expand,
|
||||
// sanitized body rendered with v-html, <pre> styled with mono font. When a
|
||||
// search `query` is set, matches inside text nodes are highlighted.
|
||||
export default defineComponent({
|
||||
name: 'HtmlMailCard',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
props: {
|
||||
message: {type: Object as PropType<Message>, required: true},
|
||||
query: {type: String, default: ''},
|
||||
},
|
||||
setup(props) {
|
||||
const expanded = ref(false);
|
||||
const safeBody = computed(() => {
|
||||
const clean = sanitizeHtml(props.message.body);
|
||||
return highlightHtml(clean, props.query);
|
||||
});
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return {expanded, safeBody, formatTime};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="html-card">
|
||||
<div class="html-header">
|
||||
<AvatarCircle
|
||||
:email="message.sender.email"
|
||||
:name="message.sender.name || message.sender.email"
|
||||
:size="22"
|
||||
/>
|
||||
<span class="html-sender">{{ message.sender.name || message.sender.email }}</span>
|
||||
<span class="html-time">{{ formatTime(message.timestamp) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="html-expand"
|
||||
:title="expanded ? 'Collapse' : 'Expand'"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<SvgIcon name="octicon-screen-full" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- v-html content is sanitized via sanitizeHtml() allowlist in safeBody -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-body" v-html="safeBody"/>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="expanded" class="html-overlay" @click.self="expanded = false">
|
||||
<div class="html-overlay-card">
|
||||
<div class="html-header">
|
||||
<AvatarCircle
|
||||
:email="message.sender.email"
|
||||
:name="message.sender.name || message.sender.email"
|
||||
:size="22"
|
||||
/>
|
||||
<span class="html-sender">{{ message.sender.name || message.sender.email }}</span>
|
||||
<span class="html-time">{{ formatTime(message.timestamp) }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="html-expand"
|
||||
title="Collapse"
|
||||
@click="expanded = false"
|
||||
>
|
||||
<SvgIcon name="octicon-x" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="html-body overlay-body" v-html="safeBody"/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.html-card {
|
||||
width: 100%;
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
overflow: hidden;
|
||||
background: var(--color-body);
|
||||
}
|
||||
|
||||
.html-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--color-secondary-bg);
|
||||
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.html-sender {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.html-time {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.html-expand {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.html-expand:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.html-body {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.html-body :deep(pre) {
|
||||
font-family: var(--fonts-monospace);
|
||||
background: var(--color-secondary-bg);
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.html-body :deep(h1),
|
||||
.html-body :deep(h2),
|
||||
.html-body :deep(h3) {
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.html-body :deep(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.html-body :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* full-screen overlay on mobile (and desktop expand) */
|
||||
.html-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1001;
|
||||
background: var(--color-overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.html-overlay-card {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
background: var(--color-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,356 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, ref} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {Thread, Participant} from '../types.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
// Right sidebar showing group info: avatar, editable name, member list with
|
||||
// add/remove. Emits `close`, `rename(newName)`, `add-member(email)`,
|
||||
// `remove-member(email)`. On desktop it takes ~28% width sliding from the right;
|
||||
// on mobile it fills the screen.
|
||||
export default defineComponent({
|
||||
name: 'MembersPanel',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
props: {
|
||||
thread: {type: Object as PropType<Thread>, required: true},
|
||||
},
|
||||
emits: ['close', 'rename', 'add-member', 'remove-member'],
|
||||
setup(props) {
|
||||
const editingName = ref(false);
|
||||
const nameValue = ref('');
|
||||
const addingMember = ref(false);
|
||||
const newEmail = ref('');
|
||||
|
||||
function startRename() {
|
||||
nameValue.value = props.thread.subject;
|
||||
editingName.value = true;
|
||||
}
|
||||
function saveRename() {
|
||||
const name = nameValue.value.trim();
|
||||
if (name) {
|
||||
this.$emit('rename', name);
|
||||
}
|
||||
editingName.value = false;
|
||||
}
|
||||
function cancelRename() { editingName.value = false; }
|
||||
|
||||
function toggleAdd() {
|
||||
addingMember.value = !addingMember.value;
|
||||
newEmail.value = '';
|
||||
}
|
||||
function confirmAdd() {
|
||||
const email = newEmail.value.trim();
|
||||
if (email) {
|
||||
this.$emit('add-member', email);
|
||||
}
|
||||
addingMember.value = false;
|
||||
newEmail.value = '';
|
||||
}
|
||||
|
||||
return {editingName, nameValue, addingMember, newEmail, startRename, saveRename, cancelRename, toggleAdd, confirmAdd};
|
||||
},
|
||||
methods: {
|
||||
labelOf(p: Participant): string {
|
||||
return p.name || p.email.slice(0, p.email.indexOf('@'));
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="members-panel">
|
||||
<header class="members-head">
|
||||
<button type="button" class="head-icon" title="Close" @click="$emit('close')">
|
||||
<SvgIcon name="octicon-x" :size="18"/>
|
||||
</button>
|
||||
<div class="members-title">Group info</div>
|
||||
</header>
|
||||
|
||||
<div class="members-body">
|
||||
<!-- Group avatar + editable name -->
|
||||
<div class="group-id">
|
||||
<AvatarCircle :email="''" :name="thread.subject" :is-group="true" :size="56"/>
|
||||
<div class="group-name-wrap">
|
||||
<div v-if="!editingName" class="group-name-row">
|
||||
<span class="group-name">{{ thread.subject }}</span>
|
||||
<button type="button" class="head-icon sm" title="Rename" @click="startRename">
|
||||
<SvgIcon name="octicon-tag" :size="14"/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="group-rename">
|
||||
<input v-model="nameValue" class="rename-inline" placeholder="Group name…" @keydown.enter="saveRename" @keydown.escape="cancelRename">
|
||||
<button type="button" class="head-icon sm" title="Save" @click="saveRename">
|
||||
<SvgIcon name="octicon-check" :size="14"/>
|
||||
</button>
|
||||
<button type="button" class="head-icon sm" title="Cancel" @click="cancelRename">
|
||||
<SvgIcon name="octicon-x" :size="14"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members section -->
|
||||
<div class="members-section">
|
||||
<div class="members-label">
|
||||
<span>Members ({{ thread.participants.length }})</span>
|
||||
<button type="button" class="head-icon sm" title="Add member" @click="toggleAdd">
|
||||
<SvgIcon name="octicon-plus" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add member input -->
|
||||
<div v-if="addingMember" class="add-member">
|
||||
<input v-model="newEmail" class="add-input" placeholder="email@example.com" @keydown.enter="confirmAdd" @keydown.escape="toggleAdd">
|
||||
<button type="button" class="head-icon sm" title="Add" @click="confirmAdd">
|
||||
<SvgIcon name="octicon-check" :size="16"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Member list -->
|
||||
<div class="members-list">
|
||||
<div v-for="p in thread.participants" :key="p.email" class="member-item">
|
||||
<AvatarCircle :email="p.email" :name="labelOf(p)" :size="32"/>
|
||||
<div class="member-info">
|
||||
<div class="member-name">
|
||||
{{ labelOf(p) }}
|
||||
<span v-if="p.self" class="member-self">you</span>
|
||||
</div>
|
||||
<div class="member-email">{{ p.email }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!p.self"
|
||||
type="button"
|
||||
class="head-icon sm danger"
|
||||
title="Remove member"
|
||||
@click="$emit('remove-member', p.email)"
|
||||
>
|
||||
<SvgIcon name="octicon-x" :size="14"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.members-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 320px;
|
||||
z-index: 10;
|
||||
background: var(--color-body);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 0.5px solid var(--color-secondary-alpha-30);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* On mobile, take the full screen as an overlay */
|
||||
@media (max-width: 639.98px) {
|
||||
.members-panel {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
.members-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 0.5px solid var(--color-secondary-alpha-30);
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.members-title {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.head-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-light-2);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.head-icon.sm {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.head-icon:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.head-icon.danger:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.members-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
/* Group identity section */
|
||||
.group-id {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.group-name-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.group-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-rename {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rename-inline {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 5px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rename-inline:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Members section */
|
||||
.members-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.members-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-light-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.add-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.add-input {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-family: var(--fonts-regular);
|
||||
color: var(--color-text);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--border-radius);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.member-item:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.member-self {
|
||||
font-size: 10px;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-primary-alpha-10);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0 4px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.member-email {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,760 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, computed, ref} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {ForwardedContent, Message} from '../types.ts';
|
||||
import {highlightPlainText} from '../highlight.ts';
|
||||
import {formatFileSize} from '../types.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
import ForwardedBubble from './ForwardedBubble.vue';
|
||||
|
||||
// Plain text message bubble. Others: left aligned with avatar + sender name.
|
||||
// Self: right aligned, no name, blue-ish bubble. When the message has forwarded
|
||||
// content, the forwarded panel renders ON TOP and the message body below it.
|
||||
// A deeper-than-limit forwarded layer emits `jump` so the parent can open the
|
||||
// origin thread at that message. When `selectable`, a checkbox appears.
|
||||
export default defineComponent({
|
||||
name: 'MessageBubble',
|
||||
components: {SvgIcon, AvatarCircle, ForwardedBubble},
|
||||
props: {
|
||||
message: {type: Object as PropType<Message>, required: true},
|
||||
query: {type: String, default: ''},
|
||||
selectable: {type: Boolean, default: false},
|
||||
selected: {type: Boolean, default: false},
|
||||
playingTrackId: {type: String, default: ''},
|
||||
},
|
||||
emits: ['toggle-select', 'jump', 'play-audio'],
|
||||
setup(props) {
|
||||
const bubbleHtml = computed(() => highlightPlainText(props.message.body, props.query));
|
||||
|
||||
// Lightbox gallery: stores all image URLs for the lightbox
|
||||
const galleryImages = computed(() => {
|
||||
if (!props.message.attachments) return [];
|
||||
return props.message.attachments.filter((a) => a.kind === 'image').map((a) => a.url);
|
||||
});
|
||||
|
||||
// Media items for the gallery grid: ALL attachments
|
||||
const mediaItems = computed(() => {
|
||||
return props.message.attachments ?? [];
|
||||
});
|
||||
|
||||
// Show the grid gallery when: 2+ images, or any non-image attachment exists.
|
||||
// A single image-only message shows a large preview instead.
|
||||
const shouldShowGallery = computed(() => {
|
||||
const atts = props.message.attachments ?? [];
|
||||
if (atts.length === 0) return false;
|
||||
// Only single image or single video get natural-size preview;
|
||||
// single audio and single file still render as square grid tiles.
|
||||
if (atts.length === 1 && (atts[0].kind === 'image' || atts[0].kind === 'video')) return false;
|
||||
return true;
|
||||
});
|
||||
const lightboxIndex = ref(-1);
|
||||
const lightboxOpen = computed(() => lightboxIndex.value >= 0 && lightboxIndex.value < galleryImages.value.length);
|
||||
const lightboxSrc = computed(() => lightboxOpen.value ? galleryImages.value[lightboxIndex.value] : null);
|
||||
function openLightbox(url: string) {
|
||||
const idx = galleryImages.value.indexOf(url);
|
||||
lightboxIndex.value = idx >= 0 ? idx : 0;
|
||||
}
|
||||
function closeLightbox() { lightboxIndex.value = -1; }
|
||||
function lightboxPrev() {
|
||||
const n = galleryImages.value.length;
|
||||
if (n > 0) lightboxIndex.value = (lightboxIndex.value - 1 + n) % n;
|
||||
}
|
||||
function lightboxNext() {
|
||||
const n = galleryImages.value.length;
|
||||
if (n > 0) lightboxIndex.value = (lightboxIndex.value + 1) % n;
|
||||
}
|
||||
return {bubbleHtml, galleryImages, mediaItems, shouldShowGallery, lightboxIndex, lightboxOpen, lightboxSrc, openLightbox, closeLightbox, lightboxPrev, lightboxNext};
|
||||
},
|
||||
methods: {
|
||||
formatFileSize,
|
||||
downloadFile(att: {url: string, name: string}) {
|
||||
const a = document.createElement('a');
|
||||
a.href = att.url;
|
||||
a.download = att.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
},
|
||||
formatTime(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="msg-row" :class="{self: message.sender.self, selectable}">
|
||||
<!-- Selection checkbox always renders at the leftmost edge of the row,
|
||||
independent of the self/others alignment. It's a circular check. -->
|
||||
<label v-if="selectable" class="msg-check">
|
||||
<input type="checkbox" :checked="selected" @change="$emit('toggle-select')">
|
||||
<span class="msg-check-box" :class="{checked: selected}">
|
||||
<SvgIcon v-if="selected" name="octicon-check" :size="12" class="msg-check-icon"/>
|
||||
</span>
|
||||
</label>
|
||||
<AvatarCircle
|
||||
v-if="!message.sender.self"
|
||||
:email="message.sender.email"
|
||||
:name="message.sender.name || message.sender.email"
|
||||
:size="22"
|
||||
class="msg-avatar"
|
||||
/>
|
||||
<div class="msg-content">
|
||||
<div v-if="!message.sender.self" class="msg-sender">
|
||||
{{ message.sender.name || message.sender.email }}
|
||||
</div>
|
||||
<div class="msg-bubble-wrap" :class="{'atts-only': !message.body}">
|
||||
<!-- Forwarded panel renders ON TOP of the body (reversed order). -->
|
||||
<div v-if="message.forwarded || (message.quotes && message.quotes.length)" class="msg-forwarded">
|
||||
<ForwardedBubble
|
||||
v-if="message.forwarded"
|
||||
:content="message.forwarded"
|
||||
:depth="1"
|
||||
@jump="(c: ForwardedContent) => $emit('jump', c)"
|
||||
/>
|
||||
<ForwardedBubble
|
||||
v-for="(q, qi) in message.quotes ?? []" :key="qi"
|
||||
:content="q"
|
||||
:depth="1"
|
||||
:class="{'fwd-sibling': qi > 0}"
|
||||
@jump="(c: ForwardedContent) => $emit('jump', c)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Single image: large natural preview -->
|
||||
<a
|
||||
v-if="mediaItems.length === 1 && mediaItems[0].kind === 'image'"
|
||||
href="#"
|
||||
class="att-single-image"
|
||||
@click.prevent="openLightbox(mediaItems[0].url)"
|
||||
>
|
||||
<img :src="mediaItems[0].url" :alt="mediaItems[0].name" loading="lazy">
|
||||
</a>
|
||||
|
||||
<!-- Single video: natural-size inline player -->
|
||||
<div
|
||||
v-if="mediaItems.length === 1 && mediaItems[0].kind === 'video'"
|
||||
class="att-single-video"
|
||||
>
|
||||
<video :src="mediaItems[0].url" controls preload="metadata"/>
|
||||
<span class="att-gallery-label">{{ mediaItems[0].name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- All attachments as uniform square gallery tiles -->
|
||||
<div
|
||||
v-if="shouldShowGallery"
|
||||
class="att-gallery"
|
||||
:class="'att-gallery-' + Math.min(mediaItems.length, 3)"
|
||||
>
|
||||
<template v-for="att in message.attachments ?? []" :key="att.id">
|
||||
<a v-if="att.kind === 'image'" href="#" class="att-gallery-item" @click.prevent="openLightbox(att.url)">
|
||||
<img :src="att.url" :alt="att.name" loading="lazy">
|
||||
</a>
|
||||
<button v-else-if="att.kind === 'audio'" type="button" class="att-gallery-item att-gallery-audio"
|
||||
:class="{'att-gallery-playing': playingTrackId === att.id}" @click.stop="$emit('play-audio', att)">
|
||||
<span v-if="playingTrackId === att.id" class="att-audio-pause-icon"/>
|
||||
<span v-else class="att-audio-play-icon"/>
|
||||
<span class="att-gallery-label att-gallery-audio-label">{{ att.name }}</span>
|
||||
</button>
|
||||
<div v-else-if="att.kind === 'video'" class="att-gallery-item att-gallery-video">
|
||||
<video :src="att.url" controls preload="metadata"/>
|
||||
<span class="att-gallery-label">{{ att.name }}</span>
|
||||
</div>
|
||||
<button v-else type="button" class="att-gallery-item att-gallery-file" @click.stop="downloadFile(att)">
|
||||
<SvgIcon name="octicon-file" :size="40"/>
|
||||
<span class="att-gallery-file-info">
|
||||
<span class="att-gallery-file-name">{{ att.name }}</span>
|
||||
<span class="att-gallery-file-size">{{ formatFileSize(att.size) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- bubbleHtml is escaped+highlighted output, safe for v-html -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-if="message.body" class="msg-bubble" :class="{searching: !!query}" v-html="bubbleHtml"/>
|
||||
</div>
|
||||
<div class="msg-time">{{ formatTime(message.timestamp) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image lightbox gallery with navigation -->
|
||||
<Teleport to="body">
|
||||
<div v-if="lightboxOpen" class="att-lightbox" @click="closeLightbox" @keydown.escape="closeLightbox">
|
||||
<button type="button" class="att-lightbox-close" title="Close (Esc)" @click.stop="closeLightbox">
|
||||
<SvgIcon name="octicon-x" :size="24"/>
|
||||
</button>
|
||||
<img v-if="lightboxSrc" :src="lightboxSrc" class="att-lightbox-img" alt="" @click.stop>
|
||||
<!-- Navigation arrows (only when multiple images) -->
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
type="button"
|
||||
class="att-lightbox-nav att-lightbox-prev"
|
||||
title="Previous (←)"
|
||||
@click.stop="lightboxPrev"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-left" :size="28"/>
|
||||
</button>
|
||||
<button
|
||||
v-if="galleryImages.length > 1"
|
||||
type="button"
|
||||
class="att-lightbox-nav att-lightbox-next"
|
||||
title="Next (→)"
|
||||
@click.stop="lightboxNext"
|
||||
>
|
||||
<SvgIcon name="octicon-chevron-right" :size="28"/>
|
||||
</button>
|
||||
<!-- Counter -->
|
||||
<span v-if="galleryImages.length > 1" class="att-lightbox-count" @click.stop>
|
||||
{{ lightboxIndex + 1 }} / {{ galleryImages.length }}
|
||||
</span>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Self-messages align content to the right via margin-left:auto rather than
|
||||
row-reverse. This keeps the checkbox (first child) anchored at the far-left
|
||||
edge for BOTH incoming and outgoing, on the same vertical line. */
|
||||
.msg-row.self .msg-content {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.msg-row.selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: min(75%, 520px);
|
||||
}
|
||||
|
||||
/* Attachments can be wider than the text bubble */
|
||||
.msg-content .msg-attachments {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.msg-row.self .msg-content {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
margin: 0 0 2px 2px;
|
||||
}
|
||||
|
||||
.msg-bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: visible;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
padding: 7px 11px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-top: none;
|
||||
border-bottom-left-radius: 3px;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.msg-row.self .msg-bubble {
|
||||
background: hsl(214, 70%, 93%);
|
||||
color: hsl(214, 70%, 30%);
|
||||
border: 0.5px solid hsl(214, 70%, 88%);
|
||||
border-top: none;
|
||||
border-bottom-left-radius: var(--border-radius-medium);
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
/* When a message carries forwarded content, the forwarded panel sits on top and
|
||||
the bubble drops its TOP radius so it visually attaches below the panel. */
|
||||
.msg-forwarded {
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--border-radius-medium);
|
||||
border-top-right-radius: var(--border-radius-medium);
|
||||
background: var(--color-body);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.msg-row.self .msg-forwarded {
|
||||
border-color: hsl(214, 70%, 88%);
|
||||
}
|
||||
|
||||
/* When multiple forwarded messages are grouped as siblings, a hairline separates
|
||||
them so each quote reads as its own block at the same nesting level. */
|
||||
.msg-forwarded :deep(.fwd-sibling) {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
margin: 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Selection checkbox: visually a custom box since the native input is hidden.
|
||||
It centers vertically against the bubble content via align-self on the row. */
|
||||
.msg-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
margin-left: 4px; /* small breathing room from the left edge of the messages area */
|
||||
}
|
||||
|
||||
.msg-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.msg-check-box {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-secondary-bg);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.msg-check-box.checked {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.msg-check-icon {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
/* Attachments */
|
||||
.msg-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.att-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* When message is attachment-only (no body text), remove border constraints */
|
||||
.msg-bubble-wrap.atts-only .msg-attachments {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.att-image img {
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
cursor: pointer;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.att-video video {
|
||||
max-width: 280px;
|
||||
max-height: 200px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
/* Audio tile in gallery grid — same square shape as image tiles */
|
||||
.att-gallery-audio {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30) !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1 !important;
|
||||
}
|
||||
|
||||
.att-gallery-audio:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Highlight the currently playing audio tile */
|
||||
.att-gallery-playing {
|
||||
background: var(--color-primary-alpha-10) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Audio icon: grey, centered */
|
||||
/* CSS play triangle (matches player) */
|
||||
.att-audio-play-icon {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 16px 0 16px 26px;
|
||||
border-color: transparent transparent transparent var(--color-text-light-2);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* CSS pause bars (two fat vertical lines) */
|
||||
.att-audio-pause-icon {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.att-audio-pause-icon::before,
|
||||
.att-audio-pause-icon::after {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 24px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Filename: bottom-right with padding, slightly bigger */
|
||||
.att-gallery-audio-label {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: calc(100% - 8px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.att-gallery-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.att-gallery-sub {
|
||||
font-size: 9px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
/* Video tile: fills the square with the video player */
|
||||
.att-gallery-video {
|
||||
background: var(--color-body);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.att-gallery-video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* File tile: centered icon + filename */
|
||||
.att-gallery-file {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30) !important;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
aspect-ratio: 1 !important;
|
||||
}
|
||||
|
||||
.att-gallery-file:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.att-gallery-file svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.att-gallery-file-info {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.att-gallery-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--color-text-light-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.att-gallery-file-size {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
|
||||
/* Single image: large non-square preview */
|
||||
.att-single-image {
|
||||
aspect-ratio: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.att-single-image img {
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
object-fit: contain;
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
/* Single video: natural-size player */
|
||||
.att-single-video video {
|
||||
max-width: 280px;
|
||||
max-height: 240px;
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
}
|
||||
|
||||
.att-single-video .att-gallery-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.att-audio-name {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.att-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.att-file {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--color-secondary-bg);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
border-radius: var(--border-radius-medium);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.att-file:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.att-file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.att-file-name {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
.att-file-size {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
/* Image gallery grid — multiple images in a compact grid */
|
||||
.att-gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.att-gallery-item {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-medium);
|
||||
border: 0.5px solid var(--color-secondary-alpha-30);
|
||||
cursor: pointer;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.att-gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Gallery with 1 image: no aspect ratio constraint, show full size */
|
||||
.att-gallery-1 .att-gallery-item {
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.att-gallery-1 .att-gallery-item img {
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
object-fit: contain;
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
/* Image lightbox — full-screen overlay with the image centered and navigation */
|
||||
.att-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1004;
|
||||
background: var(--color-overlay-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.att-lightbox-img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
border-radius: var(--border-radius-medium);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.att-lightbox-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--color-overlay-backdrop);
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-full);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.att-lightbox-close:hover {
|
||||
background: var(--color-overlay-backdrop);
|
||||
}
|
||||
|
||||
.att-lightbox-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--color-overlay-backdrop);
|
||||
color: #fff;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--border-radius-full);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.att-lightbox-nav:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.att-lightbox-prev {
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.att-lightbox-next {
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.att-lightbox-count {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
background: var(--color-overlay-backdrop);
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--border-radius-full);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,429 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType, computed, ref, onMounted, onBeforeUnmount} from 'vue';
|
||||
import {SvgIcon} from '../../../svg.ts';
|
||||
import type {BlockEntry, BlockScope, Thread} from '../types.ts';
|
||||
import {threadDisplayName, threadAvatarEmail} from '../threadTitle.ts';
|
||||
import AvatarCircle from './AvatarCircle.vue';
|
||||
|
||||
// Left panel thread list with pinned + messages sections, search filtering,
|
||||
// and a right-click / long-press context menu with an expandable Block group.
|
||||
export default defineComponent({
|
||||
name: 'ThreadList',
|
||||
components: {SvgIcon, AvatarCircle},
|
||||
props: {
|
||||
threads: {type: Array as PropType<Thread[]>, required: true},
|
||||
activeId: {type: String, default: ''},
|
||||
query: {type: String, default: ''},
|
||||
blocked: {type: Array as PropType<BlockEntry[]>, default: () => []},
|
||||
isThreadBlocked: {type: Function as PropType<(t: Thread) => boolean>, required: true},
|
||||
blockScopeOf: {type: Function as PropType<(t: Thread) => BlockScope | null>, required: true},
|
||||
},
|
||||
emits: ['select', 'toggle-pin', 'mark-unread', 'delete', 'block', 'unblock'],
|
||||
setup(props, {emit}) {
|
||||
// Name/email resolution is centralised in threadTitle.ts for the backend merge.
|
||||
const threadName = threadDisplayName;
|
||||
const threadEmail = threadAvatarEmail;
|
||||
|
||||
function lastMessage(t: Thread) {
|
||||
for (let i = t.messages.length - 1; i >= 0; i--) {
|
||||
const m = t.messages[i];
|
||||
if (m.kind === 'text' || m.kind === 'html') return m;
|
||||
}
|
||||
return t.messages[t.messages.length - 1];
|
||||
}
|
||||
|
||||
function lastPreview(t: Thread): string {
|
||||
const m = lastMessage(t);
|
||||
if (!m) return '';
|
||||
if (m.kind === 'html') return 'HTML message';
|
||||
return m.body;
|
||||
}
|
||||
|
||||
function lastTimestamp(t: Thread): number {
|
||||
const m = lastMessage(t);
|
||||
return m ? m.timestamp : 0;
|
||||
}
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60_000, hour = 60 * min, day = 24 * hour;
|
||||
if (diff < min) return 'now';
|
||||
if (diff < hour) return Math.floor(diff / min) + 'm';
|
||||
if (diff < day) return Math.floor(diff / hour) + 'h';
|
||||
if (diff < 7 * day) return Math.floor(diff / day) + 'd';
|
||||
return new Date(ts).toLocaleDateString([], {month: 'short', day: 'numeric'});
|
||||
}
|
||||
|
||||
const filtered = computed<Thread[]>(() => {
|
||||
const q = props.query.trim().toLowerCase();
|
||||
if (!q) return props.threads;
|
||||
return props.threads.filter((t) => {
|
||||
return threadName(t).toLowerCase().includes(q) || lastPreview(t).toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
const pinned = computed(() => filtered.value.filter((t) => t.pinned));
|
||||
const others = computed(() => filtered.value.filter((t) => !t.pinned));
|
||||
|
||||
// Context menu. Delete sits at the top; Block is an expandable toggle whose
|
||||
// options render below it when open.
|
||||
const menu = ref<{threadId: string, x: number, y: number} | null>(null);
|
||||
const submenuOpen = ref(false);
|
||||
let longPressTimer: number | undefined;
|
||||
// Guard timestamp: some browsers (e.g. Firefox) fire a `click` on right-click;
|
||||
// ignore clicks within this window so the menu doesn't close instantly.
|
||||
let menuOpenedAt = 0;
|
||||
|
||||
function openMenu(t: Thread, x: number, y: number) {
|
||||
// clamp so the menu never renders off the right/bottom edge, where it
|
||||
// would appear "missing" to the user.
|
||||
const w = window.innerWidth;
|
||||
const h = window.innerHeight;
|
||||
const maxX = Math.max(0, w - 180);
|
||||
const maxY = Math.max(0, h - 320);
|
||||
menu.value = {threadId: t.id, x: Math.min(x, maxX), y: Math.min(y, maxY)};
|
||||
submenuOpen.value = false;
|
||||
menuOpenedAt = Date.now();
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, t: Thread) {
|
||||
e.preventDefault();
|
||||
openMenu(t, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function onTouchStart(e: TouchEvent, t: Thread) {
|
||||
const touch = e.touches[0];
|
||||
longPressTimer = window.setTimeout(() => {
|
||||
openMenu(t, touch.clientX, touch.clientY);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function clearLongPress() {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
menu.value = null;
|
||||
submenuOpen.value = false;
|
||||
}
|
||||
|
||||
function menuThread(): Thread | undefined {
|
||||
return props.threads.find((t) => t.id === menu.value?.threadId);
|
||||
}
|
||||
|
||||
function pinFromMenu() {
|
||||
if (menu.value) emit('toggle-pin', menu.value.threadId);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function unreadFromMenu() {
|
||||
if (menu.value) emit('mark-unread', menu.value.threadId);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function deleteFromMenu() {
|
||||
if (menu.value) emit('delete', menu.value.threadId);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function toggleSubmenu() {
|
||||
submenuOpen.value = !submenuOpen.value;
|
||||
}
|
||||
|
||||
function blockFromMenu(scope: BlockScope, alsoDelete: boolean) {
|
||||
if (menu.value) emit('block', menu.value.threadId, scope, alsoDelete);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function unblockFromMenu() {
|
||||
if (menu.value) emit('unblock', menu.value.threadId);
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function onWindowClickClose(e: MouseEvent) {
|
||||
// ignore the synthesized click that some browsers fire right after contextmenu
|
||||
if (Date.now() - menuOpenedAt < 350) return;
|
||||
// ignore clicks inside the menu itself
|
||||
if ((e.target as HTMLElement)?.closest('.ctx-menu')) return;
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (submenuOpen.value) submenuOpen.value = false;
|
||||
else closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', onWindowClickClose);
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', onWindowClickClose);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
clearLongPress();
|
||||
});
|
||||
|
||||
return {
|
||||
filtered, pinned, others,
|
||||
threadName, threadEmail, lastPreview, lastTimestamp, formatRelative,
|
||||
menu, submenuOpen, onContextMenu, onTouchStart, clearLongPress, closeMenu, menuThread,
|
||||
pinFromMenu, unreadFromMenu, deleteFromMenu, toggleSubmenu, blockFromMenu, unblockFromMenu,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="thread-list">
|
||||
<!-- pinned section -->
|
||||
<template v-if="pinned.length">
|
||||
<div class="list-section-label">
|
||||
<SvgIcon name="octicon-pin" :size="12"/>
|
||||
Pinned
|
||||
</div>
|
||||
<div
|
||||
v-for="t in pinned" :key="t.id"
|
||||
class="thread-item"
|
||||
:class="{active: t.id === activeId, unread: t.unread, blocked: isThreadBlocked(t)}"
|
||||
@click="$emit('select', t.id)"
|
||||
@contextmenu="onContextMenu($event, t)"
|
||||
@touchstart.passive="onTouchStart($event, t)"
|
||||
@touchend="clearLongPress"
|
||||
@touchmove="clearLongPress"
|
||||
>
|
||||
<AvatarCircle :email="threadEmail(t)" :name="threadName(t)" :is-group="t.isGroup" :size="36"/>
|
||||
<div class="ti-center">
|
||||
<div class="ti-name">
|
||||
{{ threadName(t) }}
|
||||
<span v-if="isThreadBlocked(t)" class="ti-blocked-tag">
|
||||
{{ blockScopeOf(t) === 'domain' ? 'Blocked (domain)' : 'Blocked' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ti-preview">{{ lastPreview(t) }}</div>
|
||||
</div>
|
||||
<div class="ti-right">
|
||||
<div class="ti-time">{{ formatRelative(lastTimestamp(t)) }}</div>
|
||||
<SvgIcon v-if="t.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
|
||||
<span v-else-if="t.unread" class="ti-dot"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="list-section-label">Messages</div>
|
||||
<div
|
||||
v-for="t in others" :key="t.id"
|
||||
class="thread-item"
|
||||
:class="{active: t.id === activeId, unread: t.unread, blocked: isThreadBlocked(t)}"
|
||||
@click="$emit('select', t.id)"
|
||||
@contextmenu="onContextMenu($event, t)"
|
||||
@touchstart.passive="onTouchStart($event, t)"
|
||||
@touchend="clearLongPress"
|
||||
@touchmove="clearLongPress"
|
||||
>
|
||||
<AvatarCircle :email="threadEmail(t)" :name="threadName(t)" :is-group="t.isGroup" :size="36"/>
|
||||
<div class="ti-center">
|
||||
<div class="ti-name">
|
||||
{{ threadName(t) }}
|
||||
<span v-if="isThreadBlocked(t)" class="ti-blocked-tag">
|
||||
{{ blockScopeOf(t) === 'domain' ? 'Blocked (domain)' : 'Blocked' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ti-preview">{{ lastPreview(t) }}</div>
|
||||
</div>
|
||||
<div class="ti-right">
|
||||
<div class="ti-time">{{ formatRelative(lastTimestamp(t)) }}</div>
|
||||
<SvgIcon v-if="t.pinned" name="octicon-pin" :size="14" class="ti-pin"/>
|
||||
<span v-else-if="t.unread" class="ti-dot"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!filtered.length" class="empty">No conversations found</div>
|
||||
|
||||
<!-- context menu (teleported; styles live unscoped in mail.css).
|
||||
Order: Pin, Mark unread, Delete, then an expandable Block toggle. -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="menu"
|
||||
class="ctx-menu"
|
||||
:style="{left: menu.x + 'px', top: menu.y + 'px'}"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" class="ctx-item" @click="pinFromMenu">
|
||||
<SvgIcon name="octicon-pin" :size="14"/>
|
||||
{{ menuThread()?.pinned ? 'Unpin' : 'Pin' }}
|
||||
</button>
|
||||
<button type="button" class="ctx-item" @click="unreadFromMenu">
|
||||
<SvgIcon name="octicon-dot-fill" :size="14"/>
|
||||
Mark unread
|
||||
</button>
|
||||
<button type="button" class="ctx-item danger" @click="deleteFromMenu">
|
||||
<SvgIcon name="octicon-trash" :size="14"/>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<!-- Block: expandable toggle (DMs only). Renders its options below. -->
|
||||
<button
|
||||
v-if="menuThread() && !menuThread()!.isGroup && !isThreadBlocked(menuThread()!)"
|
||||
type="button"
|
||||
class="ctx-item danger ctx-toggle"
|
||||
:class="{expanded: submenuOpen}"
|
||||
@click="toggleSubmenu"
|
||||
>
|
||||
<SvgIcon name="octicon-blocked" :size="14"/>
|
||||
Block
|
||||
<SvgIcon
|
||||
name="octicon-chevron-down"
|
||||
:size="12"
|
||||
class="ctx-chev"
|
||||
:class="{up: submenuOpen}"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="menuThread() && !menuThread()!.isGroup && isThreadBlocked(menuThread()!)"
|
||||
type="button" class="ctx-item danger" @click="unblockFromMenu"
|
||||
>
|
||||
<SvgIcon name="octicon-blocked" :size="14"/>
|
||||
Unblock sender
|
||||
</button>
|
||||
|
||||
<!-- Expandable Block options (no icons; text flush left) -->
|
||||
<div v-if="submenuOpen && menuThread() && !menuThread()!.isGroup" class="ctx-expand">
|
||||
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', false)">
|
||||
Block sender
|
||||
</button>
|
||||
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('user', true)">
|
||||
Block & delete
|
||||
</button>
|
||||
<button type="button" class="ctx-item danger ctx-opt" @click="blockFromMenu('domain', true)">
|
||||
Block domain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.thread-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-light-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 12px 14px 4px;
|
||||
}
|
||||
|
||||
.thread-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
min-height: 56px; /* >= 44px touch target */
|
||||
}
|
||||
|
||||
.thread-item:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.thread-item.active {
|
||||
background: var(--color-secondary-bg);
|
||||
}
|
||||
|
||||
.thread-item.blocked {
|
||||
opacity: var(--opacity-disabled);
|
||||
}
|
||||
|
||||
.ti-center {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ti-name {
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.thread-item.unread .ti-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.ti-blocked-tag {
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-danger);
|
||||
background: var(--color-red-badge-bg);
|
||||
border: 0.5px solid var(--color-red-badge);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0 4px;
|
||||
line-height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ti-preview {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-light-2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ti-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ti-time {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.ti-pin {
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.ti-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--border-radius-full);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 24px 14px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
// Highlighting helpers for the in-thread search. All user-controlled text is
|
||||
// HTML-escaped before being wrapped, so the produced markup is safe to v-html.
|
||||
|
||||
// Built from char codes so the entity strings are never decoded by tooling.
|
||||
const AMP = String.fromCharCode(38);
|
||||
const ENT_AMP = `${AMP}amp;`;
|
||||
const ENT_LT = `${AMP}lt;`;
|
||||
const ENT_GT = `${AMP}gt;`;
|
||||
const ENT_QUOT = `${AMP}quot;`;
|
||||
const ENT_SQUOT = `${AMP}#39;`;
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => {
|
||||
switch (c) {
|
||||
case '&': return ENT_AMP;
|
||||
case '<': return ENT_LT;
|
||||
case '>': return ENT_GT;
|
||||
case '"': return ENT_QUOT;
|
||||
default: return ENT_SQUOT; // '
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Wraps case-insensitive occurrences of `query` in <mark> inside the plain
|
||||
// text `text`. Returns HTML safe for v-html. Empty query returns escaped text.
|
||||
export function highlightPlainText(text: string, query: string): string {
|
||||
const escaped = escapeHtml(text);
|
||||
const q = query.trim();
|
||||
if (!q) return escaped;
|
||||
const pattern = new RegExp(`(${escapeRegExp(escapeHtml(q))})`, 'gi');
|
||||
// Trusted wrapper literal around already-escaped content.
|
||||
// eslint-disable-next-line gitea/unescaped-html-literal
|
||||
return escaped.replace(pattern, '<mark class="search-hit">$1</mark>');
|
||||
}
|
||||
|
||||
// Returns true if `text` contains `query` (case-insensitive). Used to build
|
||||
// the match list without constructing HTML.
|
||||
export function textMatches(text: string, query: string): boolean {
|
||||
const q = query.trim();
|
||||
if (!q) return false;
|
||||
return text.toLowerCase().includes(q.toLowerCase());
|
||||
}
|
||||
|
||||
// Visible text of an HTML fragment, used for match counting and searching.
|
||||
export function htmlTextContent(html: string): string {
|
||||
// Wrapping root is a trusted fixed literal.
|
||||
// eslint-disable-next-line gitea/unescaped-html-literal
|
||||
const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, 'text/html');
|
||||
return doc.querySelector<HTMLElement>('#root')!.textContent ?? '';
|
||||
}
|
||||
|
||||
// Wraps occurrences of `query` inside the text nodes of an HTML string,
|
||||
// leaving tags/attributes untouched. Returns HTML safe for v-html. The input
|
||||
// is assumed already-sanized; this only mutates TEXT_NODE children.
|
||||
export function highlightHtml(html: string, query: string): string {
|
||||
const q = query.trim();
|
||||
if (!q) return html;
|
||||
// Wrapping root is a trusted fixed literal.
|
||||
// eslint-disable-next-line gitea/unescaped-html-literal
|
||||
const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, 'text/html');
|
||||
const root = doc.querySelector<HTMLElement>('#root')!;
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const targets: Text[] = [];
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
targets.push(node as Text);
|
||||
node = walker.nextNode();
|
||||
}
|
||||
const pattern = new RegExp(escapeRegExp(q), 'gi');
|
||||
for (const textNode of targets) {
|
||||
const value = textNode.nodeValue ?? '';
|
||||
if (!pattern.test(value)) continue;
|
||||
pattern.lastIndex = 0;
|
||||
const frag = splitAndMark(doc, value, pattern);
|
||||
textNode.replaceWith(frag);
|
||||
}
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
function splitAndMark(doc: Document, value: string, pattern: RegExp): DocumentFragment {
|
||||
const frag = doc.createDocumentFragment();
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
pattern.lastIndex = 0;
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
if (match.index > last) frag.append(doc.createTextNode(value.slice(last, match.index)));
|
||||
const mark = doc.createElement('mark');
|
||||
mark.className = 'search-hit';
|
||||
mark.textContent = match[0];
|
||||
frag.append(mark);
|
||||
last = match.index + match[0].length;
|
||||
if (match[0].length === 0) pattern.lastIndex++; // avoid zero-width loop
|
||||
}
|
||||
if (last < value.length) frag.append(doc.createTextNode(value.slice(last)));
|
||||
return frag;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
import {createApp} from 'vue';
|
||||
import MailApp from './MailApp.vue';
|
||||
|
||||
// Mounts the M8SH Mail Vue app into the #mail-app element rendered by mail.tmpl.
|
||||
export function initMailApp(): void {
|
||||
const el = document.querySelector('#mail-app');
|
||||
if (el) {
|
||||
createApp(MailApp).mount(el);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
// Pure-TypeScript MD5 implementation (RFC 1321) returning a lowercase hex digest.
|
||||
// No external library, used only for building Gravatar URLs.
|
||||
|
||||
function safeAdd(x: number, y: number): number {
|
||||
const lsw = (x & 0xffff) + (y & 0xffff);
|
||||
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
|
||||
return (msw << 16) | (lsw & 0xffff);
|
||||
}
|
||||
|
||||
function rol(num: number, cnt: number): number {
|
||||
return (num << cnt) | (num >>> (32 - cnt));
|
||||
}
|
||||
|
||||
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
|
||||
return safeAdd(rol(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
|
||||
}
|
||||
|
||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn((b & c) | (~b & d), a, b, x, s, t);
|
||||
}
|
||||
|
||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn((b & d) | (c & ~d), a, b, x, s, t);
|
||||
}
|
||||
|
||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn(b ^ c ^ d, a, b, x, s, t);
|
||||
}
|
||||
|
||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
|
||||
return cmn(c ^ (b | ~d), a, b, x, s, t);
|
||||
}
|
||||
|
||||
function coreMD5(x: number[], len: number): number[] {
|
||||
x[len >> 5] |= 0x80 << (len % 32);
|
||||
x[(((len + 64) >>> 9) << 4) + 14] = len;
|
||||
|
||||
let a = 1732584193;
|
||||
let b = -271733879;
|
||||
let c = -1732584194;
|
||||
let d = 271733878;
|
||||
|
||||
for (let i = 0; i < x.length; i += 16) {
|
||||
const olda = a;
|
||||
const oldb = b;
|
||||
const oldc = c;
|
||||
const oldd = d;
|
||||
|
||||
a = ff(a, b, c, d, x[i], 7, -680876936);
|
||||
d = ff(d, a, b, c, x[i + 1], 12, -389564586);
|
||||
c = ff(c, d, a, b, x[i + 2], 17, 606105819);
|
||||
b = ff(b, c, d, a, x[i + 3], 22, -1044525330);
|
||||
a = ff(a, b, c, d, x[i + 4], 7, -176418897);
|
||||
d = ff(d, a, b, c, x[i + 5], 12, 1200080426);
|
||||
c = ff(c, d, a, b, x[i + 6], 17, -1473231341);
|
||||
b = ff(b, c, d, a, x[i + 7], 22, -45705983);
|
||||
a = ff(a, b, c, d, x[i + 8], 7, 1770035416);
|
||||
d = ff(d, a, b, c, x[i + 9], 12, -1958414417);
|
||||
c = ff(c, d, a, b, x[i + 10], 17, -42063);
|
||||
b = ff(b, c, d, a, x[i + 11], 22, -1990404162);
|
||||
a = ff(a, b, c, d, x[i + 12], 7, 1804603682);
|
||||
d = ff(d, a, b, c, x[i + 13], 12, -40341101);
|
||||
c = ff(c, d, a, b, x[i + 14], 17, -1502002290);
|
||||
b = ff(b, c, d, a, x[i + 15], 22, 1236535329);
|
||||
|
||||
a = gg(a, b, c, d, x[i + 1], 5, -165796510);
|
||||
d = gg(d, a, b, c, x[i + 6], 9, -1069501632);
|
||||
c = gg(c, d, a, b, x[i + 11], 14, 643717713);
|
||||
b = gg(b, c, d, a, x[i], 20, -373897302);
|
||||
a = gg(a, b, c, d, x[i + 5], 5, -701558691);
|
||||
d = gg(d, a, b, c, x[i + 10], 9, 38016083);
|
||||
c = gg(c, d, a, b, x[i + 15], 14, -660478335);
|
||||
b = gg(b, c, d, a, x[i + 4], 20, -405537848);
|
||||
a = gg(a, b, c, d, x[i + 9], 5, 568446438);
|
||||
d = gg(d, a, b, c, x[i + 14], 9, -1019803690);
|
||||
c = gg(c, d, a, b, x[i + 3], 14, -187363961);
|
||||
b = gg(b, c, d, a, x[i + 8], 20, 1163531501);
|
||||
a = gg(a, b, c, d, x[i + 13], 5, -1444681467);
|
||||
d = gg(d, a, b, c, x[i + 2], 9, -51403784);
|
||||
c = gg(c, d, a, b, x[i + 7], 14, 1735328473);
|
||||
b = gg(b, c, d, a, x[i + 12], 20, -1926607734);
|
||||
|
||||
a = hh(a, b, c, d, x[i + 5], 4, -378558);
|
||||
d = hh(d, a, b, c, x[i + 8], 11, -2022574463);
|
||||
c = hh(c, d, a, b, x[i + 11], 16, 1839030562);
|
||||
b = hh(b, c, d, a, x[i + 14], 23, -35309556);
|
||||
a = hh(a, b, c, d, x[i + 1], 4, -1530992060);
|
||||
d = hh(d, a, b, c, x[i + 4], 11, 1272893353);
|
||||
c = hh(c, d, a, b, x[i + 7], 16, -155497632);
|
||||
b = hh(b, c, d, a, x[i + 10], 23, -1094730640);
|
||||
a = hh(a, b, c, d, x[i + 13], 4, 681279174);
|
||||
d = hh(d, a, b, c, x[i], 11, -358537222);
|
||||
c = hh(c, d, a, b, x[i + 3], 16, -722521979);
|
||||
b = hh(b, c, d, a, x[i + 6], 23, 76029189);
|
||||
a = hh(a, b, c, d, x[i + 9], 4, -640364487);
|
||||
d = hh(d, a, b, c, x[i + 12], 11, -421815835);
|
||||
c = hh(c, d, a, b, x[i + 15], 16, 530742520);
|
||||
b = hh(b, c, d, a, x[i + 2], 23, -995338651);
|
||||
|
||||
a = ii(a, b, c, d, x[i], 6, -198630844);
|
||||
d = ii(d, a, b, c, x[i + 7], 10, 1126891415);
|
||||
c = ii(c, d, a, b, x[i + 14], 15, -1416354905);
|
||||
b = ii(b, c, d, a, x[i + 5], 21, -57434055);
|
||||
a = ii(a, b, c, d, x[i + 12], 6, 1700485571);
|
||||
d = ii(d, a, b, c, x[i + 3], 10, -1894986606);
|
||||
c = ii(c, d, a, b, x[i + 10], 15, -1051523);
|
||||
b = ii(b, c, d, a, x[i + 1], 21, -2054922799);
|
||||
a = ii(a, b, c, d, x[i + 8], 6, 1873313359);
|
||||
d = ii(d, a, b, c, x[i + 15], 10, -30611744);
|
||||
c = ii(c, d, a, b, x[i + 6], 15, -1560198380);
|
||||
b = ii(b, c, d, a, x[i + 13], 21, 1309151649);
|
||||
a = ii(a, b, c, d, x[i + 4], 6, -145523070);
|
||||
d = ii(d, a, b, c, x[i + 11], 10, -1120210379);
|
||||
c = ii(c, d, a, b, x[i + 2], 15, 718787259);
|
||||
b = ii(b, c, d, a, x[i + 9], 21, -343485551);
|
||||
|
||||
a = safeAdd(a, olda);
|
||||
b = safeAdd(b, oldb);
|
||||
c = safeAdd(c, oldc);
|
||||
d = safeAdd(d, oldd);
|
||||
}
|
||||
return [a, b, c, d];
|
||||
}
|
||||
|
||||
// Pack the 4 state words into a binary-style string (16 chars, one per byte, big-endian per word).
|
||||
function binl2rstr(input: number[]): string {
|
||||
let output = '';
|
||||
for (let i = 0; i < input.length * 32; i += 8) {
|
||||
output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function bytes2binl(bytes: Uint8Array): number[] {
|
||||
const output: number[] = [];
|
||||
for (let i = 0; i < bytes.length * 8; i += 8) {
|
||||
output[i >> 5] |= (bytes[i / 8] & 0xff) << (i % 32);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function digestBytes(bytes: Uint8Array): string {
|
||||
return binl2rstr(coreMD5(bytes2binl(bytes), bytes.length * 8));
|
||||
}
|
||||
|
||||
function utf8Bytes(input: string): Uint8Array {
|
||||
return new TextEncoder().encode(input);
|
||||
}
|
||||
|
||||
function rstr2hex(input: string): string {
|
||||
const hexTab = '0123456789abcdef';
|
||||
let output = '';
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const x = input.charCodeAt(i);
|
||||
output += hexTab.charAt((x >>> 4) & 0x0f) + hexTab.charAt(x & 0x0f);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// Public entry point: MD5 hex digest of a UTF-8 encoded string.
|
||||
export function md5(s: string): string {
|
||||
return rstr2hex(digestBytes(utf8Bytes(s)));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
// Minimal HTML allowlist sanitizer for the mock HTML email card.
|
||||
// Strips <script>, <iframe>, on* attributes and a small denylist of tags.
|
||||
// This is NOT a full sanitizer, only a "simple allowlist" as requested for the mock UI.
|
||||
|
||||
const ALLOWED_TAGS = new Set([
|
||||
'a', 'b', 'i', 'u', 's', 'em', 'strong', 'p', 'br', 'hr',
|
||||
'ul', 'ol', 'li', 'blockquote', 'pre', 'code',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'div', 'span', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'img', 'hr',
|
||||
]);
|
||||
|
||||
export function sanitizeHtml(input: string): string {
|
||||
// Wrapping root container is a fixed trusted literal, not user input.
|
||||
// eslint-disable-next-line gitea/unescaped-html-literal
|
||||
const doc = new DOMParser().parseFromString(`<div id="root">${input}</div>`, 'text/html');
|
||||
const root = doc.querySelector<HTMLElement>('#root')!;
|
||||
cleanNode(root);
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
function cleanNode(node: HTMLElement): void {
|
||||
// iterate over a static snapshot since we mutate during traversal
|
||||
const children = Array.from(node.children) as HTMLElement[];
|
||||
for (const child of children) {
|
||||
const tag = child.tagName.toLowerCase();
|
||||
|
||||
// drop forbidden elements entirely (including children) by spec: script/iframe/object/embed
|
||||
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed' || tag === 'style') {
|
||||
child.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// for tags not on the allowlist, unwrap: keep children, drop the wrapper
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
unwrap(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
// scrub attributes: remove on* handlers and javascript: URLs
|
||||
for (const attr of Array.from(child.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = attr.value.trim().toLowerCase();
|
||||
if (name.startsWith('on')) {
|
||||
child.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
// Block javascript: navigation/handlers; the literal below is a denylist prefix, not a URL.
|
||||
// eslint-disable-next-line no-script-url
|
||||
if ((name === 'href' || name === 'src') && value.startsWith('javascript:')) {
|
||||
child.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
|
||||
// recurse into surviving element
|
||||
cleanNode(child);
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(el: HTMLElement): void {
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return;
|
||||
// move children before the element, then remove the element
|
||||
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
||||
el.remove();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
import type {Thread} from './types.ts';
|
||||
|
||||
// ---- Thread display-name resolution ---------------------------------------
|
||||
// Centralised so the backend merge is obvious: swap these functions with
|
||||
// API-derived fields without touching component templates.
|
||||
|
||||
// otherParticipant returns the non-self participant for a DM thread.
|
||||
export function otherParticipant(t: Thread) {
|
||||
return t.participants.find((p) => !p.self) ?? t.participants[0];
|
||||
}
|
||||
|
||||
// lastGroupTheme returns the most recent name for a group thread, derived from
|
||||
// the last "name-change" message, falling back to the thread subject. This makes
|
||||
// the list title track renames that happened mid-conversation.
|
||||
export function lastGroupTheme(t: Thread): string {
|
||||
for (let i = t.messages.length - 1; i >= 0; i--) {
|
||||
const m = t.messages[i];
|
||||
if (m.kind === 'name-change') return m.body;
|
||||
}
|
||||
return t.subject;
|
||||
}
|
||||
|
||||
// participantLabel returns the display name for a DM participant, falling back
|
||||
// to the email localpart. This maps to the email "From:" field for the backend merge.
|
||||
export function participantLabel(t: Thread): string {
|
||||
const other = otherParticipant(t);
|
||||
return other.name || other.email.slice(0, other.email.indexOf('@'));
|
||||
}
|
||||
|
||||
// threadDisplayName returns the compact label for the thread list (left bar):
|
||||
// name only, no email address. Group: last theme; DM: participant label.
|
||||
export function threadDisplayName(t: Thread): string {
|
||||
if (t.isGroup) return lastGroupTheme(t);
|
||||
return participantLabel(t);
|
||||
}
|
||||
|
||||
// threadHeaderTitle returns the full label for the chat header (right panel):
|
||||
// includes the email address for DMs. Group: last theme; DM: "Name <email>".
|
||||
export function threadHeaderTitle(t: Thread): string {
|
||||
if (t.isGroup) return lastGroupTheme(t);
|
||||
const other = otherParticipant(t);
|
||||
return `${participantLabel(t)} <${other.email}>`;
|
||||
}
|
||||
|
||||
// threadAvatarEmail returns the email used for avatar resolution.
|
||||
export function threadAvatarEmail(t: Thread): string {
|
||||
if (t.isGroup) return '';
|
||||
return otherParticipant(t).email;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
// Core domain types for the M8SH Mail frontend. All data is mock-only,
|
||||
// there is no real backend yet, so everything lives in the MOCK constant below.
|
||||
|
||||
export type Participant = {
|
||||
email: string;
|
||||
name?: string; // display name override, otherwise derived from localpart
|
||||
self?: boolean; // marks the current user (Danila)
|
||||
};
|
||||
|
||||
export type MessageKind = 'text' | 'html' | 'subject-change' | 'name-change';
|
||||
|
||||
// Attachment is a pure-frontend mock: the data URL lives in memory for the session.
|
||||
// In a real app this would be a server-uploaded blob reference.
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number; // bytes
|
||||
url: string; // object URL or data URL (in-memory, valid for the session)
|
||||
kind: 'image' | 'video' | 'audio' | 'file'; // derived from mime for rendering
|
||||
};
|
||||
|
||||
// ForwardedContent is a recursive tree: a forwarded message can itself contain
|
||||
// forwarded content (a forward of a forward). The UI renders up to MAX_FORWARD_DEPTH
|
||||
// layers visibly; deeper layers collapse into a folded "N more layers" card.
|
||||
export type ForwardedContent = {
|
||||
from: Participant; // original sender of the forwarded message
|
||||
subject?: string; // subject at the time of the original
|
||||
body: string; // original body (plain text; HTML forwards keep kind='html' on the carrier)
|
||||
timestamp: number; // original timestamp
|
||||
forwarded?: ForwardedContent; // nested forwarded content (one level deeper)
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
sender: Participant; // ignored for subject/name change meta messages
|
||||
kind: MessageKind;
|
||||
body: string; // plain text for 'text', sanitized HTML for 'html', the new value for changes
|
||||
subject?: string; // subject in effect at this message (for DM context)
|
||||
timestamp: number; // unix ms
|
||||
attachments?: Attachment[]; // attached files (images, videos, audio, documents)
|
||||
forwarded?: ForwardedContent; // present when this message is a forward envelope (single)
|
||||
quotes?: ForwardedContent[]; // multiple forwarded messages grouped together (siblings, same level)
|
||||
};
|
||||
|
||||
export type Thread = {
|
||||
id: string;
|
||||
isGroup: boolean;
|
||||
subject: string; // current subject (DM) or group title
|
||||
participants: Participant[]; // for DM: [self, other]; for group: [self, ...members]
|
||||
messages: Message[];
|
||||
pinned: boolean;
|
||||
unread: boolean;
|
||||
};
|
||||
|
||||
export type AvatarCache = {
|
||||
// resolved avatar URL per email ('' means resolved-but-fallback, undefined = pending)
|
||||
get(email: string): string | undefined;
|
||||
set(email: string, url: string): void;
|
||||
};
|
||||
|
||||
// ---- Block feature -------------------------------------------------------
|
||||
// Mock-only: the block list lives in memory for this session. A real backend
|
||||
// would persist this. 'user' blocks an email; 'domain' blocks the whole domain.
|
||||
|
||||
export type BlockScope = 'user' | 'domain';
|
||||
|
||||
export type BlockEntry = {
|
||||
email: string; // exact address for 'user', the domain (no @) for 'domain'
|
||||
scope: BlockScope;
|
||||
};
|
||||
|
||||
// domainOf returns the part after '@', lowercased, or '' if none.
|
||||
export function domainOf(email: string): string {
|
||||
const at = email.indexOf('@');
|
||||
return at < 0 ? '' : email.slice(at + 1).toLowerCase();
|
||||
}
|
||||
|
||||
// ---- Forwarding feature --------------------------------------------------
|
||||
// Forwarded messages render recursively. We show up to MAX_FORWARD_DEPTH
|
||||
// nested layers visibly; anything deeper collapses into a folded "N more
|
||||
// forwarded layers" card that can be expanded inline.
|
||||
export const MAX_FORWARD_DEPTH = 2;
|
||||
|
||||
// forwardedDepth counts how many nested ForwardedContent layers exist.
|
||||
export function forwardedDepth(fc: ForwardedContent): number {
|
||||
let depth = 1;
|
||||
let cur: ForwardedContent | undefined = fc.forwarded;
|
||||
while (cur) { depth++; cur = cur.forwarded }
|
||||
return depth;
|
||||
}
|
||||
|
||||
// ---- Mock data ----------------------------------------------------------
|
||||
// Hardcoded exactly as specified. Timestamps are fixed offsets from a base so
|
||||
// the relative ordering reads naturally in the UI.
|
||||
|
||||
const now = Date.UTC(2026, 5, 25, 7, 0, 0); // stable base for reproducible previews
|
||||
const min = 60_000;
|
||||
const hour = 60 * min;
|
||||
const day = 24 * hour;
|
||||
|
||||
const self: Participant = {email: 'd@m8sh.su', name: 'Danila', self: true};
|
||||
|
||||
// The HTML body for the OpenAI mock email. It is a fixture string that is later
|
||||
// passed through the allowlist sanitizer (sanitizeHtml) before v-html rendering,
|
||||
// so the literals here are trusted input, not rendered directly.
|
||||
/* eslint-disable gitea/unescaped-html-literal */
|
||||
const openaiHtmlBody = [
|
||||
'<div style="font-family: var(--fonts-regular, sans-serif);">',
|
||||
'<h2 style="margin:0 0 8px;">Your weekly summary</h2>',
|
||||
'<p style="margin:0 0 12px;">Here is what happened across your workspace this week.</p>',
|
||||
'<table style="width:100%;border-collapse:collapse;">',
|
||||
'<tr><th style="text-align:left;border-bottom:0.5px solid #ccc;padding:6px 0;">Metric</th>',
|
||||
'<th style="text-align:right;border-bottom:0.5px solid #ccc;padding:6px 0;">Value</th></tr>',
|
||||
'<tr><td style="padding:6px 0;">Messages sent</td><td style="text-align:right;">128</td></tr>',
|
||||
'<tr><td style="padding:6px 0;">Replies received</td><td style="text-align:right;">42</td></tr>',
|
||||
'</table>',
|
||||
'<pre style="background:#f2f5f8;padding:8px;border-radius:4px;">commit a1b2c3d\nAuthor: you\nDate: Mon\n ship it</pre>',
|
||||
'<p style="margin:12px 0 0;">Reply to this email if you have questions.</p>',
|
||||
'</div>',
|
||||
].join('');
|
||||
/* eslint-enable gitea/unescaped-html-literal */
|
||||
|
||||
const MOCK: Thread[] = [
|
||||
{
|
||||
id: 'dm-danila',
|
||||
isGroup: false,
|
||||
subject: 'Weekend plans',
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'd@m8sh.su', name: 'Danila'}, // echoed as the other side is also danila here per spec
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'm1',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila'},
|
||||
kind: 'text',
|
||||
subject: 'Weekend plans',
|
||||
body: 'Hey, are you free this Saturday? Thinking about a hike up the ridge.',
|
||||
timestamp: now - 3 * day,
|
||||
},
|
||||
{
|
||||
id: 'm2',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'Weekend plans',
|
||||
body: 'Saturday works. Bring water and the old map, the trailhead signal is bad.',
|
||||
timestamp: now - 3 * day + 12 * min,
|
||||
},
|
||||
{
|
||||
id: 'm3',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila'},
|
||||
kind: 'subject-change',
|
||||
subject: 'Sunday backup plan',
|
||||
body: 'Sunday backup plan',
|
||||
timestamp: now - 3 * day + 40 * min,
|
||||
},
|
||||
{
|
||||
id: 'm4',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila'},
|
||||
kind: 'text',
|
||||
subject: 'Sunday backup plan',
|
||||
body: 'If it rains we reschedule to Sunday morning, same time. Sound good?',
|
||||
timestamp: now - 3 * day + 42 * min,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dm-d1nch8g',
|
||||
isGroup: false,
|
||||
subject: 'Server logs',
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'd1',
|
||||
sender: {email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
|
||||
kind: 'text',
|
||||
subject: 'Server logs',
|
||||
body: 'Pulled last nights logs, the worker pool is OOMing around 03:00.',
|
||||
timestamp: now - day,
|
||||
},
|
||||
{
|
||||
id: 'd2',
|
||||
sender: {email: 'd1nch8g@gmail.com', name: 'd1nch8g'},
|
||||
kind: 'text',
|
||||
subject: 'Server logs',
|
||||
body: 'Looks like the queue depth spike lines up with the backup job. Coincidence?',
|
||||
timestamp: now - day + 9 * min,
|
||||
},
|
||||
{
|
||||
id: 'd3',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'Server logs',
|
||||
body: 'Not a coincidence. I will cap the backup concurrency tonight and watch it.',
|
||||
timestamp: now - day + 25 * min,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dm-openai',
|
||||
isGroup: false,
|
||||
subject: 'Your weekly summary',
|
||||
pinned: true,
|
||||
unread: true,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'noreply@email.anthropic.com', name: 'OpenAI'},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'o1',
|
||||
sender: {email: 'noreply@email.openai.com', name: 'OpenAI'},
|
||||
kind: 'html',
|
||||
subject: 'Your weekly summary',
|
||||
body: openaiHtmlBody,
|
||||
timestamp: now - 6 * hour,
|
||||
},
|
||||
{
|
||||
id: 'o2',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'Your weekly summary',
|
||||
body: 'Thanks, the reply count looks lower than usual, any known issue?',
|
||||
timestamp: now - 5 * hour,
|
||||
},
|
||||
{
|
||||
id: 'o3',
|
||||
sender: {email: 'noreply@email.openai.com', name: 'OpenAI'},
|
||||
kind: 'text',
|
||||
subject: 'Your weekly summary',
|
||||
body: 'No known issue, counts exclude automated traffic by design.',
|
||||
timestamp: now - 4 * hour,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'group-backend-sync',
|
||||
isGroup: true,
|
||||
subject: 'Backend sync',
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'mem@first.com', name: 'mem'},
|
||||
{email: 'jam@second.com', name: 'jam'},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'g1',
|
||||
sender: {email: 'mem@first.com', name: 'mem'},
|
||||
kind: 'text',
|
||||
subject: 'Backend sync',
|
||||
body: 'Standup in 5. I am blocked on the auth migration, need a second pair of eyes.',
|
||||
timestamp: now - 2 * day,
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
sender: {email: 'jam@second.com', name: 'jam'},
|
||||
kind: 'text',
|
||||
subject: 'Backend sync',
|
||||
body: 'I can pair after standup. Do you have a failing test I can reproduce?',
|
||||
timestamp: now - 2 * day + 5 * min,
|
||||
},
|
||||
{
|
||||
id: 'g3',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'Backend sync',
|
||||
body: 'Pushed a repro branch: backend/auth-migration-repro. CI is red, logs attached.',
|
||||
timestamp: now - 2 * day + 11 * min,
|
||||
},
|
||||
{
|
||||
id: 'g4',
|
||||
sender: {email: 'mem@first.com', name: 'mem'},
|
||||
kind: 'name-change',
|
||||
subject: 'Backend sync',
|
||||
body: 'Backend sync',
|
||||
timestamp: now - 2 * day + 30 * min,
|
||||
},
|
||||
{
|
||||
id: 'g5',
|
||||
sender: {email: 'jam@second.com', name: 'jam'},
|
||||
kind: 'text',
|
||||
subject: 'Backend sync',
|
||||
body: 'Found it, the migration was missing a NOT NULL default. Fix incoming.',
|
||||
timestamp: now - 2 * day + 48 * min,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dm-random',
|
||||
isGroup: false,
|
||||
subject: 'Hello',
|
||||
pinned: false,
|
||||
unread: false,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'random@example.com', name: 'random'},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'r1',
|
||||
sender: {email: 'random@example.com', name: 'random'},
|
||||
kind: 'text',
|
||||
subject: 'Hello',
|
||||
body: 'Hi, found your project through search. Is it open to external contributors?',
|
||||
timestamp: now - 5 * hour,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'Hello',
|
||||
body: 'Yes, always. Open an issue first so we can scope it together.',
|
||||
timestamp: now - 4 * hour,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Forwarding demo: shows a deeply-nested forward chain. The outer message is
|
||||
// forwarded (layer 1), which itself contains a forwarded message (layer 2),
|
||||
// which contains another (layer 3) and another (layer 4). Per spec, only the
|
||||
// first MAX_FORWARD_DEPTH (2) layers render openly; the rest collapse into a
|
||||
// "N more forwarded layers" card.
|
||||
id: 'dm-forward-demo',
|
||||
isGroup: false,
|
||||
subject: 'FW: nested context',
|
||||
pinned: false,
|
||||
unread: true,
|
||||
participants: [
|
||||
self,
|
||||
{email: 'forward@example.com', name: 'forward'},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: 'f0',
|
||||
sender: {email: 'forward@example.com', name: 'forward'},
|
||||
kind: 'text',
|
||||
subject: 'FW: nested context',
|
||||
body: 'Here is the full chain for context, see the nested forwards below.',
|
||||
timestamp: now - 30 * min,
|
||||
// depth 4: forward(forward(forward(forward(original))))
|
||||
forwarded: {
|
||||
from: {email: 'layer1@example.com', name: 'Alice'},
|
||||
subject: 'Re: spec clarification',
|
||||
body: 'Layer 1 forward: agreed, let me pull in the original discussion.',
|
||||
timestamp: now - 2 * hour,
|
||||
forwarded: {
|
||||
from: {email: 'layer2@example.com', name: 'Bob'},
|
||||
subject: 'Re: spec clarification',
|
||||
body: 'Layer 2 forward: here is the thread that started this, for reference.',
|
||||
timestamp: now - 5 * hour,
|
||||
forwarded: {
|
||||
from: {email: 'layer3@example.com', name: 'Carol'},
|
||||
subject: 'spec clarification',
|
||||
body: 'Layer 3 forward: the original question was about depth limits.',
|
||||
timestamp: now - 9 * hour,
|
||||
forwarded: {
|
||||
from: {email: 'layer4@example.com', name: 'Dave'},
|
||||
subject: 'spec clarification',
|
||||
body: 'Layer 4 (original): how deep should forwarded nesting render?',
|
||||
timestamp: now - day,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'f1',
|
||||
sender: {email: 'd@m8sh.su', name: 'Danila', self: true},
|
||||
kind: 'text',
|
||||
subject: 'FW: nested context',
|
||||
body: 'Got it. The depth-2 collapse looks right, the rest stays folded.',
|
||||
timestamp: now - 10 * min,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function cloneMock(): Thread[] {
|
||||
// deep clone so in-memory mutations during the session never leak back into MOCK
|
||||
return structuredClone(MOCK);
|
||||
}
|
||||
|
||||
export const SELF_EMAIL = 'd@m8sh.su';
|
||||
|
||||
// attachmentKindOf classifies a mime type (or filename extension) into a rendering category.
|
||||
// Falls back to extension parsing when the browser doesn't provide a useful mime.
|
||||
export function attachmentKindOf(mime: string, filename?: string): Attachment['kind'] {
|
||||
const m = mime.toLowerCase();
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
if (m.startsWith('video/')) return 'video';
|
||||
if (m.startsWith('audio/')) return 'audio';
|
||||
// extension fallback for files where browser reports empty/generic mime
|
||||
if (filename) {
|
||||
const ext = filename.toLowerCase().split('.').pop() ?? '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'ogv', 'mov', 'avi', 'mkv', 'm4v'].includes(ext)) return 'video';
|
||||
if (['mp3', 'ogg', 'wav', 'flac', 'aac', 'm4a', 'oga', 'opus'].includes(ext)) return 'audio';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
// formatFileSize formats bytes into a human-readable string.
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright 2026 The M8SH Authors.
|
||||
// SPDX-License-Identifier: GPL-3.0
|
||||
|
||||
import {reactive} from 'vue';
|
||||
import {md5} from './md5.ts';
|
||||
|
||||
// Resolution state per email: undefined = pending, '' = resolved-to-fallback, otherwise the URL.
|
||||
export type ResolvedAvatar = string | undefined;
|
||||
|
||||
// Module-level shared reactive cache so all AvatarCircle instances reuse results
|
||||
// and re-render automatically when a resolution lands in the same session.
|
||||
const cache = reactive(new Map<string, ResolvedAvatar>());
|
||||
// Track in-flight promises to avoid duplicate network work for the same email.
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
|
||||
// Kick off (idempotent) resolution for an email. Safe to call repeatedly.
|
||||
export function resolveAvatar(email: string): void {
|
||||
const key = email.toLowerCase();
|
||||
if (cache.has(key) || inflight.has(key)) return;
|
||||
const p = runChain(key).finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
|
||||
export function getAvatar(email: string): ResolvedAvatar {
|
||||
return cache.get(email.toLowerCase());
|
||||
}
|
||||
|
||||
// Composable returning the reactive cache for components that read many avatars.
|
||||
export function useAvatarResolver() {
|
||||
return {cache, resolve: resolveAvatar, get: getAvatar};
|
||||
}
|
||||
|
||||
// The instance default avatar URL (served from the standard Gitea assets path).
|
||||
// Shown immediately as the fallback; a real avatar replaces it once confirmed.
|
||||
export const DEFAULT_AVATAR_URL = `${window.config?.assetUrlPrefix ?? ''}/img/avatar_default.png`;
|
||||
|
||||
// Avatar resolution pipeline: Gitea -> Gravatar -> Favicon -> Instance default.
|
||||
async function runChain(email: string): Promise<void> {
|
||||
const [localpart, domain] = splitEmail(email);
|
||||
|
||||
// 1. Gitea avatar: https://<domain>/<localpart>.png (303-redirects to real avatar on m8sh.su).
|
||||
if (domain && localpart) {
|
||||
const giteaUrl = `https://${domain}/${localpart}.png`;
|
||||
if (await probeImage(giteaUrl)) {
|
||||
cache.set(email, giteaUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Gravatar (d=404: only real avatars return 200).
|
||||
const gravatarUrl = `https://www.gravatar.com/avatar/${md5(email)}?d=404&s=72`;
|
||||
if (await probeImage(gravatarUrl)) {
|
||||
cache.set(email, gravatarUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Company favicon via DuckDuckGo on the registrable domain.
|
||||
// DDG returns 404 for non-existent domains (<img> fires onerror -> probeImage fails)
|
||||
// and 200 for real domains (<img> fires onload -> probeImage succeeds).
|
||||
// No CORS issue: <img> loads cross-origin images regardless of CORS headers.
|
||||
if (domain) {
|
||||
const ddgUrl = `https://${registrableDomain(domain)}/favicon.ico`;
|
||||
if (await probeImage(ddgUrl)) {
|
||||
cache.set(email, ddgUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: the instance's default avatar (the gopher).
|
||||
cache.set(email, '');
|
||||
}
|
||||
|
||||
function splitEmail(email: string): [string, string] {
|
||||
const at = email.indexOf('@');
|
||||
if (at < 0) return ['', ''];
|
||||
return [email.slice(0, at), email.slice(at + 1)];
|
||||
}
|
||||
|
||||
// Load a URL as an Image and resolve to true only if it actually renders.
|
||||
// <img> tags follow redirects and can display cross-origin images without CORS headers.
|
||||
function probeImage(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.addEventListener('load', () => resolve(true));
|
||||
img.addEventListener('error', () => resolve(false));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
// registrableDomain extracts the last two labels of a domain (e.g. "openai.com" from "email.openai.com").
|
||||
function registrableDomain(domain: string): string {
|
||||
const parts = domain.split('.');
|
||||
if (parts.length <= 2) return domain;
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {showBrowserNotification} from './messenger.ts';
|
||||
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {UserEventsSharedWorker} from '../modules/worker.ts';
|
||||
|
||||
@@ -110,17 +109,12 @@ async function updateNotificationCount(): Promise<number> {
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const prevCount = getCurrentCount();
|
||||
toggleElem('.notification_count', data.new !== 0);
|
||||
|
||||
for (const el of document.querySelectorAll('.notification_count')) {
|
||||
el.textContent = `${data.new}`;
|
||||
}
|
||||
|
||||
if (data.new > prevCount) {
|
||||
showBrowserNotification('New notification', `You have ${data.new} unread notification${data.new !== 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
return data.new as number;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {makeNonce, buildSignCommand, validateSignature} from './gpg-nonce.ts';
|
||||
import {copyToClipboard} from '../modules/clipboard.ts';
|
||||
import { makeNonce, buildSignCommand, validateSignature } from './gpg-nonce.ts';
|
||||
import { copyToClipboard } from './../modules/clipboard.ts'
|
||||
|
||||
export function initGpgSignin() {
|
||||
const tokenField = document.querySelector<HTMLInputElement>('#token-field');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {makeNonce, buildSignCommand, validateSignature} from './gpg-nonce.ts';
|
||||
import {copyToClipboard} from '../modules/clipboard.ts';
|
||||
import { makeNonce, buildSignCommand, validateSignature } from './gpg-nonce.ts';
|
||||
import { copyToClipboard } from './../modules/clipboard.ts'
|
||||
|
||||
export function initGpgSignup() {
|
||||
const btnProceed = document.querySelector('#btn-proceed');
|
||||
|
||||
+2
-2
@@ -2,6 +2,7 @@ import '../fomantic/build/fomantic.js';
|
||||
import '../css/index.css';
|
||||
|
||||
import {initDashboardRepoList} from './features/dashboard.ts';
|
||||
import {initMailApp} from './features/mail/index.ts';
|
||||
import {initGlobalCopyToClipboardListener} from './modules/clipboard.ts';
|
||||
import {initCopyContent} from './features/copycontent.ts';
|
||||
import {initRepoGraphGit} from './features/repo-graph.ts';
|
||||
@@ -14,7 +15,6 @@ 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';
|
||||
@@ -118,9 +118,9 @@ const initPerformanceTracer = callInitFunctions([
|
||||
initAdminSelfCheck,
|
||||
|
||||
initDashboardRepoList,
|
||||
initMailApp,
|
||||
|
||||
initNotificationCount,
|
||||
initMessenger,
|
||||
|
||||
initOrgTeam,
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import octiconCheckCircleFill from '../../public/assets/img/svg/octicon-check-ci
|
||||
import octiconChevronDown from '../../public/assets/img/svg/octicon-chevron-down.svg';
|
||||
import octiconChevronLeft from '../../public/assets/img/svg/octicon-chevron-left.svg';
|
||||
import octiconChevronRight from '../../public/assets/img/svg/octicon-chevron-right.svg';
|
||||
import octiconChevronUp from '../../public/assets/img/svg/octicon-chevron-up.svg';
|
||||
import octiconCircle from '../../public/assets/img/svg/octicon-circle.svg';
|
||||
import octiconClock from '../../public/assets/img/svg/octicon-clock.svg';
|
||||
import octiconCode from '../../public/assets/img/svg/octicon-code.svg';
|
||||
@@ -59,12 +60,18 @@ import octiconLink from '../../public/assets/img/svg/octicon-link.svg';
|
||||
import octiconListOrdered from '../../public/assets/img/svg/octicon-list-ordered.svg';
|
||||
import octiconListUnordered from '../../public/assets/img/svg/octicon-list-unordered.svg';
|
||||
import octiconLock from '../../public/assets/img/svg/octicon-lock.svg';
|
||||
import octiconMail from '../../public/assets/img/svg/octicon-mail.svg';
|
||||
import octiconMeter from '../../public/assets/img/svg/octicon-meter.svg';
|
||||
import octiconMilestone from '../../public/assets/img/svg/octicon-milestone.svg';
|
||||
import octiconMirror from '../../public/assets/img/svg/octicon-mirror.svg';
|
||||
import octiconOrganization from '../../public/assets/img/svg/octicon-organization.svg';
|
||||
import octiconPaperclip from '../../public/assets/img/svg/octicon-paperclip.svg';
|
||||
import octiconPaperAirplane from '../../public/assets/img/svg/octicon-paper-airplane.svg';
|
||||
import octiconPin from '../../public/assets/img/svg/octicon-pin.svg';
|
||||
import octiconPinSlash from '../../public/assets/img/svg/octicon-pin-slash.svg';
|
||||
import octiconPlay from '../../public/assets/img/svg/octicon-play.svg';
|
||||
import octiconPlus from '../../public/assets/img/svg/octicon-plus.svg';
|
||||
import octiconSmiley from '../../public/assets/img/svg/octicon-smiley.svg';
|
||||
import octiconProject from '../../public/assets/img/svg/octicon-project.svg';
|
||||
import octiconQuote from '../../public/assets/img/svg/octicon-quote.svg';
|
||||
import octiconRepo from '../../public/assets/img/svg/octicon-repo.svg';
|
||||
@@ -107,6 +114,7 @@ const svgs = {
|
||||
'octicon-chevron-down': octiconChevronDown,
|
||||
'octicon-chevron-left': octiconChevronLeft,
|
||||
'octicon-chevron-right': octiconChevronRight,
|
||||
'octicon-chevron-up': octiconChevronUp,
|
||||
'octicon-circle': octiconCircle,
|
||||
'octicon-clock': octiconClock,
|
||||
'octicon-code': octiconCode,
|
||||
@@ -148,13 +156,19 @@ const svgs = {
|
||||
'octicon-list-ordered': octiconListOrdered,
|
||||
'octicon-list-unordered': octiconListUnordered,
|
||||
'octicon-lock': octiconLock,
|
||||
'octicon-mail': octiconMail,
|
||||
'octicon-meter': octiconMeter,
|
||||
'octicon-milestone': octiconMilestone,
|
||||
'octicon-mirror': octiconMirror,
|
||||
'octicon-organization': octiconOrganization,
|
||||
'octicon-paperclip': octiconPaperclip,
|
||||
'octicon-paper-airplane': octiconPaperAirplane,
|
||||
'octicon-pin': octiconPin,
|
||||
'octicon-pin-slash': octiconPinSlash,
|
||||
'octicon-play': octiconPlay,
|
||||
'octicon-plus': octiconPlus,
|
||||
'octicon-project': octiconProject,
|
||||
'octicon-smiley': octiconSmiley,
|
||||
'octicon-quote': octiconQuote,
|
||||
'octicon-repo': octiconRepo,
|
||||
'octicon-repo-forked': octiconRepoForked,
|
||||
|
||||
Reference in New Issue
Block a user