first commit
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/oauth2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LinkIMAPAccount registers a generic IMAP/SMTP account for a user, storing
|
||||
// the password encrypted (same HKDF-per-record scheme as messages/contacts —
|
||||
// "linked-account-cred" is the purpose namespace, keyed by the new account's
|
||||
// own ID so a compromise of one linked account's credential doesn't expose
|
||||
// any other record).
|
||||
func LinkIMAPAccount(database *db.DB, mk *crypto.MasterKey, userID, displayName, email, password,
|
||||
imapHost string, imapPort int, imapTLS string,
|
||||
smtpHost string, smtpPort int, smtpTLS string) (*db.LinkedAccount, error) {
|
||||
|
||||
accountID := uuid.NewString()
|
||||
credJSON, err := json.Marshal(IMAPCredential{Password: password})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling credential: %w", err)
|
||||
}
|
||||
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypting credential: %w", err)
|
||||
}
|
||||
|
||||
account := &db.LinkedAccount{
|
||||
ID: accountID,
|
||||
UserID: userID,
|
||||
Provider: db.ProviderIMAP,
|
||||
DisplayName: displayName,
|
||||
EmailAddress: email,
|
||||
AuthType: db.AuthTypePassword,
|
||||
IMAPHost: imapHost,
|
||||
IMAPPort: imapPort,
|
||||
IMAPTLS: imapTLS,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPTLS: smtpTLS,
|
||||
CredentialEnc: encCred,
|
||||
Active: true,
|
||||
}
|
||||
if err := database.InsertLinkedAccount(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// wellKnownIMAPHost returns the fixed IMAP+SMTP host/port/TLS settings for
|
||||
// Gmail and M365 — these aren't operator- or user-configurable, since
|
||||
// they're the provider's actual documented endpoints, exactly like
|
||||
// oauth2.WellKnownEndpoints.
|
||||
func wellKnownIMAPHost(provider db.LinkedAccountProvider) (imapHost string, imapPort int, imapTLS, smtpHost string, smtpPort int, smtpTLS string, err error) {
|
||||
switch provider {
|
||||
case db.ProviderGmail:
|
||||
return "imap.gmail.com", 993, "implicit", "smtp.gmail.com", 587, "starttls", nil
|
||||
case db.ProviderM365:
|
||||
return "outlook.office365.com", 993, "implicit", "smtp.office365.com", 587, "starttls", nil
|
||||
default:
|
||||
return "", 0, "", "", 0, "", fmt.Errorf("no well-known IMAP host for provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
// LinkOAuth2Account registers a Gmail or M365 account, storing the OAuth2
|
||||
// tokens (from a completed authorization code exchange) encrypted the same
|
||||
// way as everything else. Mail access goes through IMAP+OAuth2 (XOAUTH2),
|
||||
// per the plan's decision to start there before adding native Gmail/Graph
|
||||
// API push in a later pass — see accounts.IMAPProvider.loginOAuth2.
|
||||
func LinkOAuth2Account(database *db.DB, mk *crypto.MasterKey, userID, displayName, email string,
|
||||
provider db.LinkedAccountProvider, token *oauth2.Token) (*db.LinkedAccount, error) {
|
||||
|
||||
imapHost, imapPort, imapTLS, smtpHost, smtpPort, smtpTLS, err := wellKnownIMAPHost(provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accountID := uuid.NewString()
|
||||
credJSON, err := json.Marshal(OAuth2Credential{
|
||||
AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresAt: token.ExpiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling OAuth2 credential: %w", err)
|
||||
}
|
||||
encCred, err := crypto.Encrypt(mk, accountID, "linked-account-cred", credJSON)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypting OAuth2 credential: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := token.ExpiresAt
|
||||
account := &db.LinkedAccount{
|
||||
ID: accountID,
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
DisplayName: displayName,
|
||||
EmailAddress: email,
|
||||
AuthType: db.AuthTypeOAuth2,
|
||||
IMAPHost: imapHost,
|
||||
IMAPPort: imapPort,
|
||||
IMAPTLS: imapTLS,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPTLS: smtpTLS,
|
||||
CredentialEnc: encCred,
|
||||
OAuthExpiresAt: &expiresAt,
|
||||
Active: true,
|
||||
}
|
||||
if err := database.InsertLinkedAccount(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ProviderFor returns the right MailProvider implementation for a linked
|
||||
// account row — the single place that decides which backend handles which
|
||||
// provider string, so callers (webmail API, sync workers) never need a
|
||||
// switch statement of their own. The local "gomail" provider isn't built
|
||||
// through here since it needs a *db.User + *mailstore.Store, not a
|
||||
// LinkedAccount row — callers construct it directly via NewGoMailProvider.
|
||||
//
|
||||
// oauthConfigs is keyed by provider name ("google", "microsoft") — only
|
||||
// consulted for accounts.AuthTypeOAuth2 rows, to refresh an expired access
|
||||
// token. Pass nil if the account is known to be password-based.
|
||||
func ProviderFor(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfigs map[string]*oauth2.Config) (MailProvider, error) {
|
||||
switch account.Provider {
|
||||
case db.ProviderIMAP:
|
||||
return NewIMAPProvider(account, mk), nil
|
||||
case db.ProviderGmail, db.ProviderM365:
|
||||
providerKey := "google"
|
||||
if account.Provider == db.ProviderM365 {
|
||||
providerKey = "microsoft"
|
||||
}
|
||||
cfg := oauthConfigs[providerKey]
|
||||
return NewIMAPProviderOAuth2(account, mk, database, cfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("provider %q not supported", account.Provider)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package accounts implements the MailProvider abstraction: one interface,
|
||||
// multiple backends (local GoMail account, generic IMAP/SMTP, and — Phase 10 —
|
||||
// Gmail/M365 native APIs). The webmail client (later phase) talks to every
|
||||
// linked account through this same interface regardless of where the mail
|
||||
// actually lives.
|
||||
package accounts
|
||||
|
||||
import "context"
|
||||
|
||||
type Folder struct {
|
||||
ID string `json:"id"` // provider-native folder identifier (IMAP mailbox name, etc.)
|
||||
DisplayName string `json:"display_name"`
|
||||
Type string `json:"type"` // inbox|sent|drafts|trash|junk|custom
|
||||
UnreadCount int `json:"unread_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
type MessageHeader struct {
|
||||
ID string `json:"id"` // provider-native message identifier (IMAP UID, etc.)
|
||||
FolderID string `json:"folder_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Date string `json:"date"`
|
||||
Flags []string `json:"flags"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type FullMessage struct {
|
||||
MessageHeader
|
||||
Raw []byte `json:"raw"` // full RFC 5322 message; json.Marshal base64-encodes []byte automatically
|
||||
}
|
||||
|
||||
type OutgoingMessage struct {
|
||||
From string `json:"from"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"` // plain text; HTML composer is a webmail-phase concern
|
||||
}
|
||||
|
||||
type ListOpts struct {
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
NewCursor string
|
||||
NewMessages []MessageHeader
|
||||
DeletedIDs []string
|
||||
FlagsChanged map[string][]string // messageID -> new flags
|
||||
}
|
||||
|
||||
// MailProvider is implemented once per account type. Every method takes a
|
||||
// context so network-backed implementations (IMAP, and later Gmail/Graph
|
||||
// API) can be cancelled/timed-out uniformly with the local implementation.
|
||||
type MailProvider interface {
|
||||
ListFolders(ctx context.Context) ([]Folder, error)
|
||||
ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error)
|
||||
GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error)
|
||||
SendMessage(ctx context.Context, msg *OutgoingMessage) error
|
||||
SetFlags(ctx context.Context, folderID, messageID string, flags []string) error
|
||||
Move(ctx context.Context, folderID, messageID, destFolderID string) error
|
||||
Delete(ctx context.Context, folderID, messageID string) error
|
||||
Sync(ctx context.Context, since string) (*SyncResult, error)
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/mailstore"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GoMailProvider implements MailProvider for the user's own local account —
|
||||
// direct function calls against the database and Maildir store, no network
|
||||
// round-trip. This is what the webmail client uses for "your own" mailbox;
|
||||
// Phase 9's JMAP server exposes the same data over HTTP for third-party
|
||||
// clients, but the webmail's internal path stays this direct route since it's
|
||||
// strictly faster for same-process access.
|
||||
type GoMailProvider struct {
|
||||
database *db.DB
|
||||
store *mailstore.Store
|
||||
user *db.User
|
||||
}
|
||||
|
||||
func NewGoMailProvider(database *db.DB, store *mailstore.Store, user *db.User) *GoMailProvider {
|
||||
return &GoMailProvider{database: database, store: store, user: user}
|
||||
}
|
||||
|
||||
func (p *GoMailProvider) ListFolders(_ context.Context) ([]Folder, error) {
|
||||
names, err := p.database.ListMailboxNames(p.user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var folders []Folder
|
||||
for _, name := range names {
|
||||
entries, err := p.database.ListMailboxEntries(p.user.ID, name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
unread := 0
|
||||
for _, e := range entries {
|
||||
if !strings.Contains(e.Flags, "\\Seen") {
|
||||
unread++
|
||||
}
|
||||
}
|
||||
folders = append(folders, Folder{
|
||||
ID: name,
|
||||
DisplayName: name,
|
||||
Type: folderType(name),
|
||||
UnreadCount: unread,
|
||||
TotalCount: len(entries),
|
||||
})
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (p *GoMailProvider) ListMessages(_ context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
|
||||
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Newest first, matching typical mail client default sort.
|
||||
for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
|
||||
entries[i], entries[j] = entries[j], entries[i]
|
||||
}
|
||||
|
||||
start := opts.Offset
|
||||
if start > len(entries) {
|
||||
start = len(entries)
|
||||
}
|
||||
end := len(entries)
|
||||
if opts.Limit > 0 && start+opts.Limit < end {
|
||||
end = start + opts.Limit
|
||||
}
|
||||
page := entries[start:end]
|
||||
|
||||
var headers []MessageHeader
|
||||
for _, e := range page {
|
||||
raw, err := p.store.Read(e.EMLPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, headerFromRaw(strconv.Itoa(e.UID), folderID, raw, e.Flags, e.SizeBytes))
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func (p *GoMailProvider) GetMessage(_ context.Context, folderID, messageID string) (*FullMessage, error) {
|
||||
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, _ := strconv.Atoi(messageID)
|
||||
for _, e := range entries {
|
||||
if e.UID == uid {
|
||||
raw, err := p.store.Read(e.EMLPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdr := headerFromRaw(messageID, folderID, raw, e.Flags, e.SizeBytes)
|
||||
return &FullMessage{MessageHeader: hdr, Raw: raw}, nil
|
||||
}
|
||||
}
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
|
||||
// SendMessage delivers locally if the recipient is a GoMail user on this
|
||||
// instance, otherwise stages it in the outbound queue — same routing logic
|
||||
// SMTP submission uses, exposed here so webmail compose doesn't need to loop
|
||||
// back through the SMTP port to send its own account's mail.
|
||||
func (p *GoMailProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
|
||||
raw := buildRFC5322(p.user.Email, msg)
|
||||
|
||||
for _, to := range msg.To {
|
||||
domain := domainOf(to)
|
||||
localDomain, err := p.database.LookupDomainByName(domain)
|
||||
if err == nil && localDomain != nil {
|
||||
if recipUser, err := p.database.LookupUserByEmail(to); err == nil {
|
||||
if _, err := p.store.Deliver(recipUser.ID, recipUser.Email, "INBOX", raw); err != nil {
|
||||
return fmt.Errorf("local delivery to %s failed: %w", to, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
_, queuePath, err := p.store.WriteQueueFile(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("staging outbound message: %w", err)
|
||||
}
|
||||
entry := &db.OutboundQueueEntry{
|
||||
ID: uuid.NewString(),
|
||||
UserID: p.user.ID,
|
||||
FromAddress: p.user.Email,
|
||||
ToAddress: to,
|
||||
EMLPath: queuePath,
|
||||
NextAttemptAt: time.Now().UTC(),
|
||||
}
|
||||
if err := p.database.InsertOutboundQueueEntry(entry); err != nil {
|
||||
return fmt.Errorf("enqueueing outbound message to %s: %w", to, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *GoMailProvider) SetFlags(_ context.Context, folderID, messageID string, flags []string) error {
|
||||
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, _ := strconv.Atoi(messageID)
|
||||
for _, e := range entries {
|
||||
if e.UID == uid {
|
||||
return p.database.UpdateMailboxFlags(e.ID, strings.Join(flags, " "))
|
||||
}
|
||||
}
|
||||
return db.ErrNotFound
|
||||
}
|
||||
|
||||
// Move re-delivers the message into the destination folder and removes it
|
||||
// from the source — GoMail's Maildir store has no native "move" primitive
|
||||
// (each mailbox is its own directory tree), so this is copy+delete rather
|
||||
// than an atomic rename. Acceptable since both halves are local and fast;
|
||||
// a future optimization could hardlink instead of re-encrypting.
|
||||
func (p *GoMailProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
|
||||
full, err := p.GetMessage(ctx, folderID, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := p.store.Deliver(p.user.ID, p.user.Email, destFolderID, full.Raw); err != nil {
|
||||
return fmt.Errorf("delivering to destination folder: %w", err)
|
||||
}
|
||||
return p.Delete(ctx, folderID, messageID)
|
||||
}
|
||||
|
||||
func (p *GoMailProvider) Delete(_ context.Context, folderID, messageID string) error {
|
||||
entries, err := p.database.ListMailboxEntries(p.user.ID, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uid, _ := strconv.Atoi(messageID)
|
||||
for _, e := range entries {
|
||||
if e.UID == uid {
|
||||
return p.database.DeleteMailboxEntry(e.ID)
|
||||
}
|
||||
}
|
||||
return db.ErrNotFound
|
||||
}
|
||||
|
||||
// Sync for the local provider is a no-op in the SyncResult sense — the
|
||||
// caller already has live DB access via ListMessages, there's no remote
|
||||
// state to reconcile. Implemented to satisfy the interface for callers that
|
||||
// treat every linked account uniformly (the future unified-inbox sync loop).
|
||||
func (p *GoMailProvider) Sync(_ context.Context, _ string) (*SyncResult, error) {
|
||||
return &SyncResult{NewCursor: ""}, nil
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func folderType(name string) string {
|
||||
switch strings.ToUpper(name) {
|
||||
case "INBOX":
|
||||
return "inbox"
|
||||
case "SENT":
|
||||
return "sent"
|
||||
case "DRAFTS":
|
||||
return "drafts"
|
||||
case "TRASH":
|
||||
return "trash"
|
||||
case "JUNK":
|
||||
return "junk"
|
||||
default:
|
||||
return "custom"
|
||||
}
|
||||
}
|
||||
|
||||
func headerFromRaw(id, folderID string, raw []byte, flags string, size int64) MessageHeader {
|
||||
h := MessageHeader{ID: id, FolderID: folderID, SizeBytes: size}
|
||||
if flags != "" {
|
||||
h.Flags = strings.Fields(flags)
|
||||
}
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err == nil {
|
||||
h.From = msg.Header.Get("From")
|
||||
h.To = msg.Header.Get("To")
|
||||
h.Subject = msg.Header.Get("Subject")
|
||||
h.Date = msg.Header.Get("Date")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func domainOf(email string) string {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildRFC5322(from string, msg *OutgoingMessage) []byte {
|
||||
var b strings.Builder
|
||||
b.WriteString("From: " + from + "\r\n")
|
||||
b.WriteString("To: " + strings.Join(msg.To, ", ") + "\r\n")
|
||||
if len(msg.CC) > 0 {
|
||||
b.WriteString("Cc: " + strings.Join(msg.CC, ", ") + "\r\n")
|
||||
}
|
||||
b.WriteString("Subject: " + msg.Subject + "\r\n")
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(msg.Body)
|
||||
b.WriteString("\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/imapclient"
|
||||
"gomail/internal/oauth2"
|
||||
)
|
||||
|
||||
const dialTimeout = 20 * time.Second
|
||||
|
||||
// IMAPCredential is what's stored (encrypted) in linked_accounts.credential_enc
|
||||
// for AuthTypePassword accounts.
|
||||
type IMAPCredential struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// OAuth2Credential is what's stored (encrypted) in
|
||||
// linked_accounts.credential_enc for AuthTypeOAuth2 accounts (Gmail, M365,
|
||||
// or any provider using XOAUTH2). RefreshToken is used to obtain a new
|
||||
// AccessToken transparently once the stored one expires.
|
||||
type OAuth2Credential struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// IMAPProvider implements MailProvider for a generic external IMAP account.
|
||||
// Every method dials fresh — IMAP has no cheap "keep a pool of idle
|
||||
// connections" story without IDLE/pooling machinery this pass doesn't build
|
||||
// yet, so simplicity wins: connect, do the one operation, disconnect. A
|
||||
// later pass can add persistent connections if per-operation latency matters.
|
||||
type IMAPProvider struct {
|
||||
account *db.LinkedAccount
|
||||
mk *crypto.MasterKey
|
||||
database *db.DB // needed to persist a refreshed access token
|
||||
oauthConfig *oauth2.Config // nil for password-auth accounts
|
||||
}
|
||||
|
||||
func NewIMAPProvider(account *db.LinkedAccount, mk *crypto.MasterKey) *IMAPProvider {
|
||||
return &IMAPProvider{account: account, mk: mk}
|
||||
}
|
||||
|
||||
// NewIMAPProviderOAuth2 is used for accounts.AuthTypeOAuth2 — the caller
|
||||
// supplies the provider's oauth2.Config (built from operator-configured
|
||||
// Client ID/Secret) so a stored access token can be refreshed transparently
|
||||
// on expiry. database is used to persist the refreshed token — refreshing
|
||||
// silently in memory only would force a re-refresh on every single
|
||||
// operation instead of once per real expiry.
|
||||
func NewIMAPProviderOAuth2(account *db.LinkedAccount, mk *crypto.MasterKey, database *db.DB, oauthConfig *oauth2.Config) *IMAPProvider {
|
||||
return &IMAPProvider{account: account, mk: mk, database: database, oauthConfig: oauthConfig}
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) connect(ctx context.Context) (*imapclient.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", p.account.IMAPHost, p.account.IMAPPort)
|
||||
// InsecureSkipVerify is a known gap, not a silent one: real ACME
|
||||
// issuance now exists (internal/acme, Phase 13), but this instance's own
|
||||
// IMAP server still falls back to a self-signed cert whenever the
|
||||
// operator hasn't configured tls.acme_domains — and a user's *other*
|
||||
// linked IMAP server (their actual Gmail/M365/self-hosted account) is
|
||||
// entirely outside our control regardless. Real clients solve the
|
||||
// latter with an explicit "accept this certificate" trust step
|
||||
// (pinning by fingerprint) — that UI flow belongs in the webmail
|
||||
// account-linking phase, not here. Tracked as a gap, not swept under
|
||||
// the rug.
|
||||
tlsConf := &tls.Config{ServerName: p.account.IMAPHost, InsecureSkipVerify: true}
|
||||
|
||||
implicit := p.account.IMAPTLS == "implicit"
|
||||
client, err := imapclient.Dial(addr, implicit, tlsConf, dialTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.account.IMAPTLS == "starttls" {
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
return nil, fmt.Errorf("STARTTLS: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.account.AuthType == db.AuthTypeOAuth2 {
|
||||
if err := p.loginOAuth2(ctx, client); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypting stored credential: %w", err)
|
||||
}
|
||||
var cred IMAPCredential
|
||||
if err := json.Unmarshal(plain, &cred); err != nil {
|
||||
return nil, fmt.Errorf("parsing stored credential: %w", err)
|
||||
}
|
||||
if err := client.Login(p.account.EmailAddress, cred.Password); err != nil {
|
||||
return nil, fmt.Errorf("IMAP login: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// loginOAuth2 decrypts the stored OAuth2 credential, transparently refreshes
|
||||
// it if expired (persisting the new token so the next call doesn't have to
|
||||
// refresh again), and authenticates via SASL XOAUTH2.
|
||||
func (p *IMAPProvider) loginOAuth2(ctx context.Context, client *imapclient.Client) error {
|
||||
plain, err := crypto.Decrypt(p.mk, p.account.ID, "linked-account-cred", p.account.CredentialEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypting stored OAuth2 credential: %w", err)
|
||||
}
|
||||
var cred OAuth2Credential
|
||||
if err := json.Unmarshal(plain, &cred); err != nil {
|
||||
return fmt.Errorf("parsing stored OAuth2 credential: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().UTC().After(cred.ExpiresAt) {
|
||||
if p.oauthConfig == nil {
|
||||
return fmt.Errorf("access token expired and no oauth2.Config available to refresh it")
|
||||
}
|
||||
newTok, err := p.oauthConfig.RefreshToken(ctx, cred.RefreshToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refreshing OAuth2 token: %w", err)
|
||||
}
|
||||
cred.AccessToken = newTok.AccessToken
|
||||
cred.RefreshToken = newTok.RefreshToken
|
||||
cred.ExpiresAt = newTok.ExpiresAt
|
||||
|
||||
if p.database != nil {
|
||||
updated, err := json.Marshal(cred)
|
||||
if err == nil {
|
||||
if encUpdated, encErr := crypto.Encrypt(p.mk, p.account.ID, "linked-account-cred", updated); encErr == nil {
|
||||
p.database.Exec(`UPDATE linked_accounts SET credential_enc = ?, oauth_expires_at = ? WHERE id = ?`,
|
||||
encUpdated, cred.ExpiresAt, p.account.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sasl := oauth2.XOAUTH2SASLString(p.account.EmailAddress, cred.AccessToken)
|
||||
if err := client.LoginXOAUTH2(sasl); err != nil {
|
||||
return fmt.Errorf("XOAUTH2 login: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) ListFolders(ctx context.Context) ([]Folder, error) {
|
||||
client, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
list, err := client.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var folders []Folder
|
||||
for _, f := range list {
|
||||
info, err := client.Select(f.Name)
|
||||
total := 0
|
||||
if err == nil {
|
||||
total = info.Exists
|
||||
}
|
||||
folders = append(folders, Folder{
|
||||
ID: f.Name, DisplayName: f.Name, Type: folderType(f.Name), TotalCount: total,
|
||||
})
|
||||
}
|
||||
return folders, nil
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) ListMessages(ctx context.Context, folderID string, opts ListOpts) ([]MessageHeader, error) {
|
||||
client, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
info, err := client.Select(folderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Exists == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > info.Exists {
|
||||
limit = info.Exists
|
||||
}
|
||||
lo := info.Exists - limit + 1 - opts.Offset
|
||||
if lo < 1 {
|
||||
lo = 1
|
||||
}
|
||||
hi := info.Exists - opts.Offset
|
||||
if hi < lo {
|
||||
return nil, nil
|
||||
}
|
||||
seqSet := fmt.Sprintf("%d:%d", lo, hi)
|
||||
|
||||
fetched, err := client.Fetch(seqSet, "(UID FLAGS BODY.PEEK[HEADER])")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var headers []MessageHeader
|
||||
for _, f := range fetched {
|
||||
h := MessageHeader{
|
||||
ID: strconv.Itoa(f.UID),
|
||||
FolderID: folderID,
|
||||
Flags: f.Flags,
|
||||
}
|
||||
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); err == nil {
|
||||
h.From = msg.Header.Get("From")
|
||||
h.To = msg.Header.Get("To")
|
||||
h.Subject = msg.Header.Get("Subject")
|
||||
h.Date = msg.Header.Get("Date")
|
||||
}
|
||||
headers = append(headers, h)
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) GetMessage(ctx context.Context, folderID, messageID string) (*FullMessage, error) {
|
||||
client, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
if _, err := client.Select(folderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched, err := client.UIDFetch(messageID, "(UID FLAGS BODY.PEEK[])")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fetched) == 0 {
|
||||
return nil, db.ErrNotFound
|
||||
}
|
||||
f := fetched[0]
|
||||
|
||||
h := MessageHeader{ID: messageID, FolderID: folderID, Flags: f.Flags, SizeBytes: int64(len(f.Body))}
|
||||
if msg, err := mail.ReadMessage(strings.NewReader(string(f.Body))); err == nil {
|
||||
h.From = msg.Header.Get("From")
|
||||
h.To = msg.Header.Get("To")
|
||||
h.Subject = msg.Header.Get("Subject")
|
||||
h.Date = msg.Header.Get("Date")
|
||||
}
|
||||
return &FullMessage{MessageHeader: h, Raw: f.Body}, nil
|
||||
}
|
||||
|
||||
// SendMessage for a generic IMAP account routes through its paired SMTP
|
||||
// settings — IMAP itself has no send capability, so this dials the account's
|
||||
// smtp_host/port using the same stored credential, via stdlib net/smtp,
|
||||
// mirroring the approach in internal/queue's MXDeliverer.
|
||||
func (p *IMAPProvider) SendMessage(_ context.Context, msg *OutgoingMessage) error {
|
||||
return sendViaSMTP(p.account, p.mk, msg)
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) SetFlags(ctx context.Context, folderID, messageID string, flags []string) error {
|
||||
client, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
if _, err := client.Select(folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.UIDStore(messageID, "FLAGS", strings.Join(flags, " "))
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) Move(ctx context.Context, folderID, messageID, destFolderID string) error {
|
||||
// No native IMAP MOVE issued here (RFC 6851 COPY+EXPUNGE equivalent) —
|
||||
// implemented as fetch-from-source + append-style re-delivery is not
|
||||
// available without APPEND support (deferred). For now: mark \Deleted
|
||||
// and expunge in the source; true cross-folder move needs APPEND, noted
|
||||
// as a gap rather than silently mis-behaving.
|
||||
return fmt.Errorf("Move not yet implemented for generic IMAP accounts (requires APPEND, deferred)")
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) Delete(ctx context.Context, folderID, messageID string) error {
|
||||
client, err := p.connect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Logout()
|
||||
|
||||
if _, err := client.Select(folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.UIDStore(messageID, "+FLAGS", `\Deleted`); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Expunge()
|
||||
}
|
||||
|
||||
func (p *IMAPProvider) Sync(ctx context.Context, _ string) (*SyncResult, error) {
|
||||
// Full re-list — no CONDSTORE/QRESYNC support yet (deferred, noted in
|
||||
// package docs). Correct, just not incremental.
|
||||
folders, err := p.ListFolders(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []MessageHeader
|
||||
for _, f := range folders {
|
||||
msgs, err := p.ListMessages(ctx, f.ID, ListOpts{})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
all = append(all, msgs...)
|
||||
}
|
||||
return &SyncResult{NewMessages: all}, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package accounts
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/db"
|
||||
)
|
||||
|
||||
// sendViaSMTP delivers an outgoing message through a linked account's own
|
||||
// SMTP settings — stdlib net/smtp, same choice as internal/queue's
|
||||
// MXDeliverer, so outbound for both "GoMail relays for me" and "I'm using my
|
||||
// own IMAP+SMTP provider" stay on the same dependency-free foundation.
|
||||
func sendViaSMTP(account *db.LinkedAccount, mk *crypto.MasterKey, msg *OutgoingMessage) error {
|
||||
plain, err := crypto.Decrypt(mk, account.ID, "linked-account-cred", account.CredentialEnc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decrypting stored credential: %w", err)
|
||||
}
|
||||
var cred IMAPCredential // same password shape reused for the paired SMTP auth
|
||||
if err := json.Unmarshal(plain, &cred); err != nil {
|
||||
return fmt.Errorf("parsing stored credential: %w", err)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(account.SMTPHost, strconv.Itoa(account.SMTPPort))
|
||||
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if account.SMTPTLS == "implicit" {
|
||||
// See provider_imap.go's connect() comment — same gap, same reason.
|
||||
conn = tls.Client(conn, &tls.Config{ServerName: account.SMTPHost, InsecureSkipVerify: true})
|
||||
}
|
||||
|
||||
client, err := smtp.NewClient(conn, account.SMTPHost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP handshake: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if account.SMTPTLS == "starttls" {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: account.SMTPHost}); err != nil {
|
||||
return fmt.Errorf("STARTTLS: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", account.EmailAddress, cred.Password, account.SMTPHost)
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP auth: %w", err)
|
||||
}
|
||||
|
||||
if err := client.Mail(account.EmailAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, to := range msg.To {
|
||||
if err := client.Rcpt(to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw := buildRFC5322(account.EmailAddress, msg)
|
||||
if _, err := w.Write(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
Reference in New Issue
Block a user