feat(mail): 支持外部 IMAP 账号接入。
- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。 - Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。 - 补充相关配置项、环境变量说明和使用文档。
This commit is contained in:
@@ -29,6 +29,7 @@ type App struct {
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -49,6 +50,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -76,6 +78,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
go a.maildirWorker(workerCtx)
|
||||
}
|
||||
go a.sendQueueWorker(workerCtx)
|
||||
go a.externalIMAPWorker(workerCtx)
|
||||
go a.smtpEventsCleanupWorker(workerCtx)
|
||||
return a, nil
|
||||
}
|
||||
@@ -344,6 +347,63 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
|
||||
username TEXT NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
|
||||
sync_read_state INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_sync_at TEXT,
|
||||
last_status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_user_mailbox ON external_imap_accounts(user_id, mailbox_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_enabled ON external_imap_accounts(enabled, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_folder_states (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
local_folder_id TEXT NOT NULL DEFAULT '',
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
last_uid INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_messages (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
uid_validity INTEGER NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
local_message_id TEXT NOT NULL DEFAULT '',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder, uid_validity, uid)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_messages_local ON external_imap_messages(local_message_id) WHERE local_message_id <> ''`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
imported INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_sync_runs_account_started ON external_imap_sync_runs(account_id, started_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -48,6 +48,11 @@ func newTestApp(t *testing.T) *App {
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
return newTestAppWithConfig(t, cfg)
|
||||
}
|
||||
|
||||
func newTestAppWithConfig(t *testing.T, cfg Config) *App {
|
||||
t.Helper()
|
||||
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -424,6 +429,127 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ExternalIMAPSecretKey: "test-secret",
|
||||
ExternalIMAPAllowPrivateHosts: true,
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
_, mb := defaultAdminUserAndMailbox(t, a)
|
||||
var created ExternalIMAPAccount
|
||||
payload := map[string]any{"mailboxId": mb.ID, "name": "Gmail", "host": "imap.gmail.com", "port": 993, "tlsMode": "tls", "username": "user@gmail.com", "password": "app-password", "storageMode": "remote", "syncReadState": true, "enabled": true}
|
||||
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create external imap code=%d account=%+v", code, created)
|
||||
}
|
||||
raw, err := json.Marshal(created)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "app-password") {
|
||||
t.Fatalf("external account response leaked password: %s", string(raw))
|
||||
}
|
||||
var ciphertext string
|
||||
if err := a.db.QueryRow(`SELECT password_ciphertext FROM external_imap_accounts WHERE id=?`, created.ID).Scan(&ciphertext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ciphertext == "" || ciphertext == "app-password" {
|
||||
t.Fatalf("password was not encrypted: %q", ciphertext)
|
||||
}
|
||||
plain, err := a.decryptExternalIMAPPassword(ciphertext)
|
||||
if err != nil || plain != "app-password" {
|
||||
t.Fatalf("decrypt password=%q err=%v", plain, err)
|
||||
}
|
||||
var list struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/me/external-imap-accounts?mailboxId="+mb.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 {
|
||||
t.Fatalf("list external imap code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
if _, ok := list.Items[0]["password"]; ok {
|
||||
t.Fatalf("list response exposed password field: %+v", list.Items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.cfg.ExternalIMAPSecretKey = "test-secret"
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
_, mb := defaultAdminUserAndMailbox(t, a)
|
||||
var out map[string]any
|
||||
payload := map[string]any{"mailboxId": mb.ID, "name": "Local", "host": "127.0.0.1", "port": 143, "tlsMode": "plain", "username": "local", "password": "secret", "storageMode": "remote"}
|
||||
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &out); code != http.StatusBadRequest {
|
||||
t.Fatalf("private host should be rejected code=%d body=%v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ExternalIMAPSecretKey: "test-secret",
|
||||
ExternalIMAPAllowPrivateHosts: true,
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login admin code=%d", code)
|
||||
}
|
||||
domainID := mustDefaultDomainID(t, a)
|
||||
owner := createTestMailbox(t, admin, domainID, "ximap-owner", "Owner", "Password123!", nil)
|
||||
other := createTestMailbox(t, admin, domainID, "ximap-other", "Other", "Password123!", nil)
|
||||
ownerClient := &testClient{t: t, server: ts}
|
||||
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login owner code=%d", code)
|
||||
}
|
||||
otherClient := &testClient{t: t, server: ts}
|
||||
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login other code=%d", code)
|
||||
}
|
||||
var created ExternalIMAPAccount
|
||||
payload := map[string]any{"mailboxId": owner.ID, "name": "Owner external", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "owner@example.com", "password": "secret", "storageMode": "remote"}
|
||||
if code := ownerClient.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
|
||||
t.Fatalf("create code=%d account=%+v", code, created)
|
||||
}
|
||||
var denied map[string]any
|
||||
if code := otherClient.do("POST", "/api/me/external-imap-accounts/"+created.ID, map[string]any{"mailboxId": other.ID, "name": "steal", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "other@example.com", "storageMode": "remote"}, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user update should be hidden code=%d body=%v", code, denied)
|
||||
}
|
||||
if code := otherClient.do("DELETE", "/api/me/external-imap-accounts/"+created.ID, nil, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user delete should be hidden code=%d body=%v", code, denied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMailAuthenticationResults(t *testing.T) {
|
||||
header := textproto.MIMEHeader{}
|
||||
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
|
||||
|
||||
@@ -8,77 +8,83 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
dataDir := getenv("LANQIN_DATA_DIR", "./data")
|
||||
return Config{
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
|
||||
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
|
||||
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,12 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -74,6 +80,12 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts", a.handleMailExternalAccounts)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
|
||||
@@ -77,37 +77,38 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ExternalAccountID string `json:"externalAccountId,omitempty"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
@@ -227,6 +228,44 @@ type MailStatsFolderCount struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type ExternalIMAPAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLSMode string `json:"tlsMode"`
|
||||
Username string `json:"username"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ExternalIMAPFolder struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type ExternalIMAPSyncRun struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
Status string `json:"status"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
|
||||
Reference in New Issue
Block a user