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 (
github.com/99designs/gqlgen v0.17.88
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/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3

View File

@@ -1,14 +1,18 @@
package services
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net"
"net/mail"
"strings"
"sync/atomic"
"time"
"github.com/emersion/go-imap"
"github.com/emersion/go-imap/client"
"github.com/emersion/go-message/mail"
)
type IMAPConfig struct {
@@ -18,90 +22,237 @@ type IMAPConfig struct {
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.
func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) {
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 {
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)
}
_, err = c.Select("INBOX", false)
if err != nil {
if _, err := c.sendCommandOK("SELECT INBOX"); err != nil {
return nil, fmt.Errorf("IMAP select INBOX: %w", err)
}
// Search for emails since the given time
criteria := imap.NewSearchCriteria()
criteria.Since = since.UTC()
seqNums, err := c.Search(criteria)
// SEARCH SINCE uses date only (no time), per IMAP spec
dateStr := since.UTC().Format("02-Jan-2006")
untagged, err := c.sendCommandOK(fmt.Sprintf("SEARCH SINCE %s", dateStr))
if err != nil {
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 {
log.Printf("[EmailSync/IMAP] No messages found since %s", dateStr)
c.sendCommand("LOGOUT")
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.AddNum(seqNums...)
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)
}()
seqSet := strings.Join(seqNums, ",")
fetchLines, err := c.sendFetch(fmt.Sprintf("FETCH %s (BODY[])", seqSet))
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
// Parse fetched messages - literals contain the raw RFC822 message
var messages []graphMessage
for msg := range msgChan {
if msg.Envelope == nil {
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
}
var fromName, fromAddr string
if len(msg.Envelope.From) > 0 {
fromName = msg.Envelope.From[0].PersonalName
fromAddr = msg.Envelope.From[0].Address()
messages = append(messages, gm)
}
}
}
var bodyContent string
bodyReader := msg.GetBody(section)
if bodyReader != nil {
mr, err := mail.CreateReader(bodyReader)
if err == nil {
for {
p, err := mr.NextPart()
c.sendCommand("LOGOUT")
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 {
break
}
switch p.Header.(type) {
case *mail.InlineHeader:
b, err := io.ReadAll(p.Body)
if err == nil {
bodyContent = string(b)
}
}
}
}
return graphMessage{}, fmt.Errorf("parsing message: %w", err)
}
gm := graphMessage{
ID: msg.Envelope.MessageId,
Subject: msg.Envelope.Subject,
ReceivedDateTime: msg.Envelope.Date.Format(time.RFC3339),
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,
@@ -112,14 +263,75 @@ func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, err
ContentType: "text",
Content: bodyContent,
},
}, nil
}
messages = append(messages, gm)
// 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"
}
if err := <-done; err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
// Try reading as plain text
b, _ := io.ReadAll(body)
return string(b)
}
return messages, nil
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:
- ./vue:/app
- /app/node_modules
- /app/src/wasm
environment:
- NODE_ENV=development
backend:
@@ -12,10 +13,10 @@ services:
- GQL_PLAYGROUND=true
- GQL_INTROSPECTION=true
- DEV_MODE=true
- SEED_DB=true
nginx:
environment:
- DEV_MODE=true
- SEED_DB=true
ports:
- 80:80
- 443:443

8
vue/package-lock.json generated
View File

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

View File

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

View File

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