Replace go-imap library with custom IMAP client, simplify CV layout styles, bump vite, move SEED_DB to backend
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m19s

- Rewrite email_imap.go to use a minimal hand-rolled IMAP client instead of go-imap/go-message,
  for better compatibility with Outlook's non-standard responses
- Consolidate and simplify CVLayout.vue CSS overrides
- Bump vite from 7.1.11 to 7.3.2
- Move SEED_DB env var from nginx to backend in dev compose
- Add /app/src/wasm volume exclusion in dev compose

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 08:53:34 +01:00
parent c20b1c2691
commit 8636dfedb9
6 changed files with 314 additions and 130 deletions

View File

@@ -5,8 +5,6 @@ go 1.25
require ( require (
github.com/99designs/gqlgen v0.17.88 github.com/99designs/gqlgen v0.17.88
github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.18.2
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3

View File

@@ -1,14 +1,18 @@
package services package services
import ( import (
"bufio"
"crypto/tls"
"fmt" "fmt"
"io" "io"
"log" "log"
"mime"
"mime/multipart"
"net"
"net/mail"
"strings"
"sync/atomic"
"time" "time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-message/mail"
) )
type IMAPConfig struct { type IMAPConfig struct {
@@ -18,108 +22,316 @@ type IMAPConfig struct {
Password string Password string
} }
// imapClient is a minimal, lenient IMAP client that tolerates
// Outlook's non-standard response formatting.
type imapClient struct {
conn net.Conn
reader *bufio.Reader
tag atomic.Int64
}
func imapDial(addr string) (*imapClient, error) {
conn, err := tls.Dial("tcp", addr, nil)
if err != nil {
return nil, err
}
c := &imapClient{
conn: conn,
reader: bufio.NewReader(conn),
}
// Read server greeting
if _, err := c.readLine(); err != nil {
conn.Close()
return nil, fmt.Errorf("reading greeting: %w", err)
}
return c, nil
}
func (c *imapClient) Close() error {
return c.conn.Close()
}
func (c *imapClient) nextTag() string {
return fmt.Sprintf("A%04d", c.tag.Add(1))
}
func (c *imapClient) readLine() (string, error) {
line, err := c.reader.ReadString('\n')
return strings.TrimRight(line, "\r\n"), err
}
// sendCommand sends a tagged command and reads lines until the tagged response.
// Returns all untagged response lines and the final tagged status line.
func (c *imapClient) sendCommand(cmd string) (untagged []string, status string, err error) {
tag := c.nextTag()
_, err = fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
if err != nil {
return nil, "", err
}
for {
line, err := c.readLine()
if err != nil {
return untagged, "", err
}
if strings.HasPrefix(line, tag+" ") {
return untagged, line, nil
}
untagged = append(untagged, line)
}
}
// sendCommandOK sends a command and returns an error if the response is not OK.
func (c *imapClient) sendCommandOK(cmd string) ([]string, error) {
untagged, status, err := c.sendCommand(cmd)
if err != nil {
return untagged, err
}
// Status line is like: A0001 OK ... or A0001 NO ...
parts := strings.SplitN(status, " ", 3)
if len(parts) < 2 || parts[1] != "OK" {
return untagged, fmt.Errorf("command %q failed: %s", cmd, status)
}
return untagged, nil
}
// fetchLiteral reads an IMAP literal {N}\r\n followed by N bytes.
func (c *imapClient) readLiteral(size int) (string, error) {
buf := make([]byte, size)
_, err := io.ReadFull(c.reader, buf)
if err != nil {
return "", err
}
return string(buf), nil
}
// sendFetch sends a FETCH command and collects the full response including literals.
// Returns raw response lines (with literals inlined after their header lines).
func (c *imapClient) sendFetch(cmd string) ([]string, error) {
tag := c.nextTag()
_, err := fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
if err != nil {
return nil, err
}
var lines []string
for {
line, err := c.readLine()
if err != nil {
return lines, err
}
// Check for literal marker {N} at end of line
if idx := strings.LastIndex(line, "{"); idx >= 0 && strings.HasSuffix(line, "}") {
sizeStr := line[idx+1 : len(line)-1]
var size int
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil && size > 0 {
literal, err := c.readLiteral(size)
if err != nil {
return lines, fmt.Errorf("reading literal of %d bytes: %w", size, err)
}
lines = append(lines, line)
lines = append(lines, literal)
continue
}
}
if strings.HasPrefix(line, tag+" ") {
// Check for OK
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 2 && parts[1] != "OK" {
return lines, fmt.Errorf("FETCH failed: %s", line)
}
return lines, nil
}
lines = append(lines, line)
}
}
// fetchEmailsIMAP connects to an IMAP server and retrieves emails since the given time. // fetchEmailsIMAP connects to an IMAP server and retrieves emails since the given time.
func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) { func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) {
addr := fmt.Sprintf("%s:%s", s.Config.IMAP.Host, s.Config.IMAP.Port) addr := fmt.Sprintf("%s:%s", s.Config.IMAP.Host, s.Config.IMAP.Port)
c, err := client.DialTLS(addr, nil) c, err := imapDial(addr)
if err != nil { if err != nil {
return nil, fmt.Errorf("IMAP connect: %w", err) return nil, fmt.Errorf("IMAP connect: %w", err)
} }
defer c.Logout() defer c.Close()
if err := c.Login(s.Config.IMAP.Email, s.Config.IMAP.Password); err != nil { // Quote the password to handle special characters
quotedPass := fmt.Sprintf("%q", s.Config.IMAP.Password)
if _, err := c.sendCommandOK(fmt.Sprintf("LOGIN %s %s", s.Config.IMAP.Email, quotedPass)); err != nil {
return nil, fmt.Errorf("IMAP login: %w", err) return nil, fmt.Errorf("IMAP login: %w", err)
} }
_, err = c.Select("INBOX", false) if _, err := c.sendCommandOK("SELECT INBOX"); err != nil {
if err != nil {
return nil, fmt.Errorf("IMAP select INBOX: %w", err) return nil, fmt.Errorf("IMAP select INBOX: %w", err)
} }
// Search for emails since the given time // SEARCH SINCE uses date only (no time), per IMAP spec
criteria := imap.NewSearchCriteria() dateStr := since.UTC().Format("02-Jan-2006")
criteria.Since = since.UTC() untagged, err := c.sendCommandOK(fmt.Sprintf("SEARCH SINCE %s", dateStr))
seqNums, err := c.Search(criteria)
if err != nil { if err != nil {
return nil, fmt.Errorf("IMAP search: %w", err) return nil, fmt.Errorf("IMAP search: %w", err)
} }
// Parse sequence numbers from "* SEARCH 1 2 3 ..."
var seqNums []string
for _, line := range untagged {
if strings.HasPrefix(line, "* SEARCH") {
parts := strings.Fields(line)
if len(parts) > 2 {
seqNums = append(seqNums, parts[2:]...)
}
}
}
if len(seqNums) == 0 { if len(seqNums) == 0 {
log.Printf("[EmailSync/IMAP] No messages found since %s", dateStr)
c.sendCommand("LOGOUT")
return nil, nil return nil, nil
} }
log.Printf("[EmailSync/IMAP] Found %d messages since %s", len(seqNums), since.UTC().Format(time.RFC3339)) log.Printf("[EmailSync/IMAP] Found %d messages since %s", len(seqNums), dateStr)
seqSet := new(imap.SeqSet) seqSet := strings.Join(seqNums, ",")
seqSet.AddNum(seqNums...) fetchLines, err := c.sendFetch(fmt.Sprintf("FETCH %s (BODY[])", seqSet))
if err != nil {
section := &imap.BodySectionName{}
items := []imap.FetchItem{imap.FetchEnvelope, section.FetchItem()}
msgChan := make(chan *imap.Message, len(seqNums))
done := make(chan error, 1)
go func() {
done <- c.Fetch(seqSet, items, msgChan)
}()
var messages []graphMessage
for msg := range msgChan {
if msg.Envelope == nil {
continue
}
var fromName, fromAddr string
if len(msg.Envelope.From) > 0 {
fromName = msg.Envelope.From[0].PersonalName
fromAddr = msg.Envelope.From[0].Address()
}
var bodyContent string
bodyReader := msg.GetBody(section)
if bodyReader != nil {
mr, err := mail.CreateReader(bodyReader)
if err == nil {
for {
p, err := mr.NextPart()
if err != nil {
break
}
switch p.Header.(type) {
case *mail.InlineHeader:
b, err := io.ReadAll(p.Body)
if err == nil {
bodyContent = string(b)
}
}
}
}
}
gm := graphMessage{
ID: msg.Envelope.MessageId,
Subject: msg.Envelope.Subject,
ReceivedDateTime: msg.Envelope.Date.Format(time.RFC3339),
From: graphFrom{
EmailAddress: graphEmailAddress{
Name: fromName,
Address: fromAddr,
},
},
Body: graphBody{
ContentType: "text",
Content: bodyContent,
},
}
messages = append(messages, gm)
}
if err := <-done; err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err) return nil, fmt.Errorf("IMAP fetch: %w", err)
} }
// Parse fetched messages - literals contain the raw RFC822 message
var messages []graphMessage
for i := 0; i < len(fetchLines); i++ {
line := fetchLines[i]
// Look for a literal following this line
if strings.Contains(line, "BODY[]") && strings.HasSuffix(line, "}") {
if i+1 < len(fetchLines) {
raw := fetchLines[i+1]
i++ // skip the literal
gm, err := parseRawEmail(raw)
if err != nil {
log.Printf("[EmailSync/IMAP] Error parsing email: %v", err)
continue
}
messages = append(messages, gm)
}
}
}
c.sendCommand("LOGOUT")
return messages, nil return messages, nil
} }
// parseRawEmail parses an RFC822 email into a graphMessage.
func parseRawEmail(raw string) (graphMessage, error) {
msg, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
return graphMessage{}, fmt.Errorf("parsing message: %w", err)
}
header := msg.Header
// Parse From
var fromName, fromAddr string
if fromList, err := header.AddressList("From"); err == nil && len(fromList) > 0 {
fromName = fromList[0].Name
fromAddr = fromList[0].Address
}
// Parse Date
dateStr := header.Get("Date")
parsedDate, err := mail.ParseDate(dateStr)
if err != nil {
parsedDate = time.Now()
}
// Extract body text
bodyContent := extractTextBody(msg.Header, msg.Body)
return graphMessage{
ID: header.Get("Message-ID"),
Subject: decodeHeader(header.Get("Subject")),
ReceivedDateTime: parsedDate.Format(time.RFC3339),
From: graphFrom{
EmailAddress: graphEmailAddress{
Name: fromName,
Address: fromAddr,
},
},
Body: graphBody{
ContentType: "text",
Content: bodyContent,
},
}, nil
}
// extractTextBody pulls the text/plain or text/html content from a message body.
func extractTextBody(header mail.Header, body io.Reader) string {
contentType := header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
}
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
// Try reading as plain text
b, _ := io.ReadAll(body)
return string(b)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
b, _ := io.ReadAll(body)
return string(b)
}
mr := multipart.NewReader(body, boundary)
var textContent, htmlContent string
for {
part, err := mr.NextPart()
if err != nil {
break
}
partType := part.Header.Get("Content-Type")
partMedia, _, _ := mime.ParseMediaType(partType)
b, err := io.ReadAll(part)
if err != nil {
continue
}
switch partMedia {
case "text/plain":
textContent = string(b)
case "text/html":
htmlContent = string(b)
case "multipart/alternative", "multipart/related", "multipart/mixed":
// Recursively handle nested multipart
nested := extractTextBody(
mail.Header{"Content-Type": {partType}},
strings.NewReader(string(b)),
)
if nested != "" && textContent == "" {
textContent = nested
}
}
}
if textContent != "" {
return textContent
}
return htmlContent
}
b, _ := io.ReadAll(body)
return string(b)
}
// decodeHeader decodes RFC 2047 encoded header values.
func decodeHeader(s string) string {
dec := new(mime.WordDecoder)
decoded, err := dec.DecodeHeader(s)
if err != nil {
return s
}
return decoded
}

View File

@@ -4,6 +4,7 @@ services:
volumes: volumes:
- ./vue:/app - ./vue:/app
- /app/node_modules - /app/node_modules
- /app/src/wasm
environment: environment:
- NODE_ENV=development - NODE_ENV=development
backend: backend:
@@ -12,10 +13,10 @@ services:
- GQL_PLAYGROUND=true - GQL_PLAYGROUND=true
- GQL_INTROSPECTION=true - GQL_INTROSPECTION=true
- DEV_MODE=true - DEV_MODE=true
- SEED_DB=true
nginx: nginx:
environment: environment:
- DEV_MODE=true - DEV_MODE=true
- SEED_DB=true
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443

8
vue/package-lock.json generated
View File

@@ -24,7 +24,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.3.2",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3", "vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0" "vite-plugin-wasm": "^3.6.0"
@@ -3840,9 +3840,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",

View File

@@ -29,7 +29,7 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.3.2",
"vite-plugin-top-level-await": "^1.6.0", "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3", "vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0" "vite-plugin-wasm": "^3.6.0"

View File

@@ -17,54 +17,27 @@ import { RouterView } from "vue-router";
</style> </style>
<style> <style>
/* Reset global element styles within CV layout */ .cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4,
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4 { .cv-layout p, .cv-layout small, .cv-layout code, .cv-layout ul, .cv-layout li,
color: inherit; .cv-layout td, .cv-layout tr, .cv-layout table {
font-family: inherit; color: #111;
margin: 0;
}
.cv-layout p, .cv-layout small, .cv-layout code, .cv-layout ul, .cv-layout li {
color: inherit;
} }
.cv-layout h1, .cv-layout h2, .cv-layout h3, .cv-layout h4 { margin: 0; }
.cv-layout a { .cv-layout a {
color: inherit; color: #111;
background-color: transparent !important; background-color: transparent !important;
font-family: inherit;
text-align: inherit;
letter-spacing: normal; letter-spacing: normal;
} }
.cv-layout input, .cv-layout textarea { .cv-layout input, .cv-layout textarea {
color: inherit; color: #111;
border-color: #ccc; background-color: white;
border-width: 1px; border: 1px solid #ccc;
padding: 0; padding: 0;
width: auto; width: auto;
} }
.cv-layout input::placeholder, .cv-layout textarea::placeholder { .cv-layout input::placeholder, .cv-layout textarea::placeholder { color: #999; opacity: 1; }
color: #999; .cv-layout table { border: 0 solid transparent; }
opacity: 1; .cv-layout tr { border-color: transparent; }
} .cv-layout th { border: none; padding: 0; }
.cv-layout table { .cv-layout td { padding: 0; }
border-color: transparent;
border-width: 0;
color: inherit;
}
.cv-layout tr {
border-color: inherit;
color: inherit;
}
.cv-layout th {
border-right-width: 0;
border-style: none;
border-color: inherit;
padding: 0;
}
.cv-layout td {
padding: 0;
color: inherit;
}
.cv-layout textarea {
color: inherit;
background-color: white;
}
</style> </style>