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
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:
@@ -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
|
||||||
|
|||||||
@@ -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,90 +22,237 @@ 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{}
|
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||||
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)
|
|
||||||
}()
|
|
||||||
|
|
||||||
|
// Parse fetched messages - literals contain the raw RFC822 message
|
||||||
var messages []graphMessage
|
var messages []graphMessage
|
||||||
|
for i := 0; i < len(fetchLines); i++ {
|
||||||
for msg := range msgChan {
|
line := fetchLines[i]
|
||||||
if msg.Envelope == nil {
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
messages = append(messages, gm)
|
||||||
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
|
c.sendCommand("LOGOUT")
|
||||||
bodyReader := msg.GetBody(section)
|
return messages, nil
|
||||||
if bodyReader != nil {
|
}
|
||||||
mr, err := mail.CreateReader(bodyReader)
|
|
||||||
if err == nil {
|
// parseRawEmail parses an RFC822 email into a graphMessage.
|
||||||
for {
|
func parseRawEmail(raw string) (graphMessage, error) {
|
||||||
p, err := mr.NextPart()
|
msg, err := mail.ReadMessage(strings.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
break
|
return graphMessage{}, fmt.Errorf("parsing message: %w", err)
|
||||||
}
|
|
||||||
switch p.Header.(type) {
|
|
||||||
case *mail.InlineHeader:
|
|
||||||
b, err := io.ReadAll(p.Body)
|
|
||||||
if err == nil {
|
|
||||||
bodyContent = string(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gm := graphMessage{
|
header := msg.Header
|
||||||
ID: msg.Envelope.MessageId,
|
|
||||||
Subject: msg.Envelope.Subject,
|
// Parse From
|
||||||
ReceivedDateTime: msg.Envelope.Date.Format(time.RFC3339),
|
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{
|
From: graphFrom{
|
||||||
EmailAddress: graphEmailAddress{
|
EmailAddress: graphEmailAddress{
|
||||||
Name: fromName,
|
Name: fromName,
|
||||||
@@ -112,14 +263,75 @@ func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, err
|
|||||||
ContentType: "text",
|
ContentType: "text",
|
||||||
Content: bodyContent,
|
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 {
|
||||||
if err := <-done; err != nil {
|
contentType := header.Get("Content-Type")
|
||||||
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
if contentType == "" {
|
||||||
}
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
return messages, nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
8
vue/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user